diff options
| author | lukeflo | 2024-10-22 21:52:36 +0200 |
|---|---|---|
| committer | lukeflo | 2024-10-22 21:52:36 +0200 |
| commit | 66402a9c23e0975a8a3d8c2707b689b9cde98ccf (patch) | |
| tree | ccba415674b13eadb6739f5a4d0cb53642dc2e62 /src/tui | |
| parent | 0a74206015e764551ec2a0ade8f6853e915b6911 (diff) | |
| download | bibiman-66402a9c23e0975a8a3d8c2707b689b9cde98ccf.tar.gz bibiman-66402a9c23e0975a8a3d8c2707b689b9cde98ccf.zip | |
rearrange code, file and folder structure
Diffstat (limited to 'src/tui')
| -rw-r--r-- | src/tui/app.rs | 257 | ||||
| -rw-r--r-- | src/tui/command.rs | 363 | ||||
| -rw-r--r-- | src/tui/handler.rs | 210 | ||||
| -rw-r--r-- | src/tui/ui.rs | 637 |
4 files changed, 1467 insertions, 0 deletions
diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..b09ae80 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,257 @@ +// bibiman - a TUI for managing BibLaTeX databases +// Copyright (C) 2024 lukeflo +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. +///// + +use super::Event; +use crate::bib::{bibmain::*, search::BibiSearch}; +use crate::cliargs::CLIArgs; +use crate::tui; +use crate::{bib::entries::EntryTable, bib::keywords::TagList, tui::handler::handle_key_events}; +use arboard::Clipboard; +use color_eyre::eyre::{Ok, Result}; +use std::path::PathBuf; + +// Areas in which actions are possible +#[derive(Debug)] +pub enum CurrentArea { + EntryArea, + TagArea, + SearchArea, + HelpArea, + InfoArea, +} + +// Check which area was active when popup set active +#[derive(Debug)] +pub enum FormerArea { + EntryArea, + TagArea, + SearchArea, +} + +// Application. +#[derive(Debug)] +pub struct App { + // Is the application running? + pub running: bool, + // main bib file + pub main_bibfile: PathBuf, + // main bibliography + pub main_biblio: BibiMain, + // search struct: + pub search_struct: BibiSearch, + // tag list + pub tag_list: TagList, + // table items + pub entry_table: EntryTable, + // scroll state info buffer + pub scroll_info: u16, + // area + pub current_area: CurrentArea, + // mode for popup window + pub former_area: Option<FormerArea>, +} + +impl App { + // Constructs a new instance of [`App`]. + pub fn new(args: CLIArgs) -> Result<Self> { + // Self::default() + let running = true; + let main_bibfile = args.bibfilearg; + let main_biblio = BibiMain::new(main_bibfile.clone()); + let tag_list = TagList::new(main_biblio.keyword_list.clone()); + let search_struct = BibiSearch::default(); + let entry_table = EntryTable::new(main_biblio.entry_list.clone()); + let current_area = CurrentArea::EntryArea; + Ok(Self { + running, + main_bibfile, + main_biblio, + tag_list, + search_struct, + entry_table, + scroll_info: 0, + current_area, + former_area: None, + }) + } + + pub async fn run(&mut self) -> Result<()> { + let mut tui = tui::Tui::new()?; + tui.enter()?; + + // Start the main loop. + while self.running { + // Render the user interface. + tui.draw(self)?; + // Handle events. + match tui.next().await? { + Event::Tick => self.tick(), + Event::Key(key_event) => handle_key_events(key_event, self, &mut tui)?, + Event::Mouse(_) => {} + Event::Resize(_, _) => {} + } + } + + // Exit the user interface. + tui.exit()?; + Ok(()) + } + + // Handles the tick event of the terminal. + pub fn tick(&self) {} + + // General commands + + // Set running to false to quit the application. + pub fn quit(&mut self) { + self.running = false; + } + + pub fn update_lists(&mut self) { + self.main_biblio = BibiMain::new(self.main_bibfile.clone()); + // self.tag_list = TagList::from_iter(self.main_biblio.keyword_list.clone()); + self.tag_list = TagList::new(self.main_biblio.keyword_list.clone()); + self.entry_table = EntryTable::new(self.main_biblio.entry_list.clone()); + } + + // Toggle moveable list between entries and tags + pub fn toggle_area(&mut self) { + if let CurrentArea::EntryArea = self.current_area { + self.entry_table.entry_scroll_state = self.entry_table.entry_scroll_state.position(0); + self.current_area = CurrentArea::TagArea; + self.tag_list.tag_list_state.select(Some(0)); + self.tag_list.tag_scroll_state = self + .tag_list + .tag_scroll_state + .position(self.tag_list.tag_list_state.selected().unwrap()); + } else if let CurrentArea::TagArea = self.current_area { + self.current_area = CurrentArea::EntryArea; + self.tag_list.tag_list_state.select(None); + self.entry_table.entry_scroll_state = self + .entry_table + .entry_scroll_state + .position(self.entry_table.entry_table_state.selected().unwrap()); + } + } + + pub fn reset_current_list(&mut self) { + self.entry_table = EntryTable::new(self.main_biblio.entry_list.clone()); + self.tag_list = TagList::new(self.main_biblio.keyword_list.clone()); + if let CurrentArea::TagArea = self.current_area { + self.tag_list.tag_list_state.select(Some(0)) + } + self.entry_table.entry_table_at_search_start.clear(); + self.search_struct.filtered_tag_list.clear(); + self.search_struct.inner_search = false; + self.former_area = None + } + + // Yank the passed string to system clipboard + pub fn yank_text(selection: &str) { + let mut clipboard = Clipboard::new().unwrap(); + let yanked_text = selection.to_string(); + clipboard.set_text(yanked_text).unwrap(); + } + + pub fn scroll_info_down(&mut self) { + self.entry_table.entry_info_scroll = self.entry_table.entry_info_scroll.saturating_add(1); + self.entry_table.entry_info_scroll_state = self + .entry_table + .entry_info_scroll_state + .position(self.entry_table.entry_info_scroll.into()); + } + + pub fn scroll_info_up(&mut self) { + self.entry_table.entry_info_scroll = self.entry_table.entry_info_scroll.saturating_sub(1); + self.entry_table.entry_info_scroll_state = self + .entry_table + .entry_info_scroll_state + .position(self.entry_table.entry_info_scroll.into()); + } + + // Search Area + + // Enter the search area + pub fn enter_search_area(&mut self) { + if let CurrentArea::EntryArea = self.current_area { + if let Some(FormerArea::TagArea) = self.former_area { + self.search_struct.inner_search = true + } + self.entry_table.entry_table_at_search_start = + self.entry_table.entry_table_items.clone(); + self.former_area = Some(FormerArea::EntryArea) + } else if let CurrentArea::TagArea = self.current_area { + self.former_area = Some(FormerArea::TagArea) + } + self.current_area = CurrentArea::SearchArea + } + + // Confirm search: Search former list by pattern + pub fn confirm_search(&mut self) { + if let Some(FormerArea::EntryArea) = self.former_area { + self.current_area = CurrentArea::EntryArea; + self.entry_table.entry_table_state.select(Some(0)) + } else if let Some(FormerArea::TagArea) = self.former_area { + self.current_area = CurrentArea::TagArea; + self.tag_list.tag_list_state.select(Some(0)) + } + self.former_area = Some(FormerArea::SearchArea); + self.search_struct.search_string.clear(); + self.entry_table.entry_table_at_search_start.clear(); + } + + // Break search: leave search area without filtering list + pub fn break_search(&mut self) { + if let Some(FormerArea::EntryArea) = self.former_area { + self.current_area = CurrentArea::EntryArea; + self.entry_table.entry_table_state.select(Some(0)) + } else if let Some(FormerArea::TagArea) = self.former_area { + self.current_area = CurrentArea::TagArea; + self.tag_list.tag_list_state.select(Some(0)) + } + // But keep filtering by tag if applied before entering search area + if !self.search_struct.inner_search { + self.reset_current_list(); + } + self.former_area = None; + // If search is canceled, reset default status of struct + self.search_struct.search_string.clear(); + self.entry_table.entry_table_at_search_start.clear(); + } + + // Remove last char from search pattern and filter list immidiately + pub fn search_pattern_pop(&mut self) { + self.search_struct.search_string.pop(); + if let Some(FormerArea::EntryArea) = self.former_area { + self.search_entries(); + self.filter_tags_by_entries(); + } else if let Some(FormerArea::TagArea) = self.former_area { + self.search_tags(); + } + } + + // Add current char to search pattern and filter list immidiatley + pub fn search_pattern_push(&mut self, search_pattern: char) { + self.search_struct.search_string.push(search_pattern); + if let Some(FormerArea::EntryArea) = self.former_area { + self.search_entries(); + self.filter_tags_by_entries(); + } else if let Some(FormerArea::TagArea) = self.former_area { + self.search_tags(); + } + } +} diff --git a/src/tui/command.rs b/src/tui/command.rs new file mode 100644 index 0000000..9f25f5f --- /dev/null +++ b/src/tui/command.rs @@ -0,0 +1,363 @@ +// bibiman - a TUI for managing BibLaTeX databases +// Copyright (C) 2024 lukeflo +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. +///// + +use crate::bib::entries::EntryTableColumn; +use crate::bib::search::BibiSearch; +use crate::tui::app::{App, FormerArea}; +use crate::tui::Tui; +use color_eyre::eyre::{Context, Ok, Result}; +use core::panic; +use editor_command::EditorBuilder; +use ratatui::widgets::ScrollbarState; +use std::process::{Command, Stdio}; + +impl App { + // Entry Table commands + + // Movement + pub fn select_next_entry(&mut self, entries: u16) { + self.entry_table.entry_info_scroll = 0; + self.entry_table.entry_info_scroll_state = + self.entry_table.entry_info_scroll_state.position(0); + self.entry_table.entry_table_state.scroll_down_by(entries); + self.entry_table.entry_scroll_state = self + .entry_table + .entry_scroll_state + .position(self.entry_table.entry_table_state.selected().unwrap()); + } + + pub fn select_previous_entry(&mut self, entries: u16) { + self.entry_table.entry_info_scroll = 0; + self.entry_table.entry_info_scroll_state = + self.entry_table.entry_info_scroll_state.position(0); + self.entry_table.entry_table_state.scroll_up_by(entries); + self.entry_table.entry_scroll_state = self + .entry_table + .entry_scroll_state + .position(self.entry_table.entry_table_state.selected().unwrap()); + } + + pub fn select_first_entry(&mut self) { + self.entry_table.entry_info_scroll = 0; + self.entry_table.entry_info_scroll_state = + self.entry_table.entry_info_scroll_state.position(0); + self.entry_table.entry_table_state.select_first(); + self.entry_table.entry_scroll_state = self.entry_table.entry_scroll_state.position(0); + } + + pub fn select_last_entry(&mut self) { + self.entry_table.entry_info_scroll = 0; + self.entry_table.entry_info_scroll_state = + self.entry_table.entry_info_scroll_state.position(0); + self.entry_table.entry_table_state.select_last(); + self.entry_table.entry_scroll_state = self + .entry_table + .entry_scroll_state + .position(self.entry_table.entry_table_items.len()); + } + + pub fn select_next_column(&mut self) { + match self.entry_table.entry_table_selected_column { + EntryTableColumn::Authors => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Title; + } + EntryTableColumn::Title => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Year; + } + EntryTableColumn::Year => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Pubtype; + } + EntryTableColumn::Pubtype => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Authors; + } + } + } + + pub fn select_prev_column(&mut self) { + match self.entry_table.entry_table_selected_column { + EntryTableColumn::Authors => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Pubtype; + } + EntryTableColumn::Title => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Authors; + } + EntryTableColumn::Year => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Title; + } + EntryTableColumn::Pubtype => { + self.entry_table.entry_table_selected_column = EntryTableColumn::Year; + } + } + } + + // Get the citekey of the selected entry + pub fn get_selected_citekey(&self) -> &str { + let idx = self.entry_table.entry_table_state.selected().unwrap(); + let citekey = &self.entry_table.entry_table_items[idx].citekey; + citekey + } + + pub fn run_editor(&mut self, tui: &mut Tui) -> Result<()> { + // get filecontent and citekey for calculating line number + let citekey = self.get_selected_citekey(); + // create independent copy of citekey for finding entry after updating list + let saved_key = citekey.to_owned(); + let filepath = self.main_biblio.bibfile.display().to_string(); + let filecontent = self.main_biblio.bibfilestring.clone(); + let mut line_count = 0; + + for line in filecontent.lines() { + line_count = line_count + 1; + // if reaching the citekey break the loop + // if reaching end of lines without match, reset to 0 + if line.contains(&citekey) { + break; + } else if line_count == filecontent.len() { + eprintln!( + "Citekey {} not found, opening file {} at line 1", + &citekey, &filepath + ); + line_count = 0; + break; + } + } + + // Exit TUI to enter editor + tui.exit()?; + // Use VISUAL or EDITOR. Set "vi" as last fallback + let mut cmd: Command = EditorBuilder::new() + .environment() + .source(Some("vi")) + .build() + .unwrap(); + // Prepare arguments to open file at specific line + let args: Vec<String> = vec![format!("+{}", line_count), filepath]; + let status = cmd.args(&args).status()?; + if !status.success() { + eprintln!("Spawning editor failed with status {}", status); + } + + // Enter TUI again + tui.enter()?; + tui.terminal.clear()?; + + // Update the database and the lists to show changes + self.update_lists(); + + // Search for entry, selected before editing, by matching citekeys + // Use earlier saved copy of citekey to match + let mut idx_count = 0; + loop { + if self.entry_table.entry_table_items[idx_count] + .citekey + .contains(&saved_key) + { + break; + } + idx_count = idx_count + 1 + } + + // Set selected entry to vec-index of match + self.entry_table.entry_table_state.select(Some(idx_count)); + + Ok(()) + } + + // Search entry list + pub fn search_entries(&mut self) { + // Use snapshot of entry list saved when starting the search + // so deleting a char, will show former entries too + let orig_list = self.entry_table.entry_table_at_search_start.clone(); + let filtered_list = + BibiSearch::search_entry_list(&mut self.search_struct.search_string, orig_list.clone()); + self.entry_table.entry_table_items = filtered_list; + if self.entry_table.entry_table_reversed_sort { + self.entry_table.sort_entry_table(false); + } + self.entry_table.entry_scroll_state = ScrollbarState::content_length( + self.entry_table.entry_scroll_state, + self.entry_table.entry_table_items.len(), + ); + } + + // Open file connected with entry through 'file' or 'pdf' field + pub fn open_connected_file(&mut self) -> Result<()> { + let idx = self.entry_table.entry_table_state.selected().unwrap(); + let filepath = &self.entry_table.entry_table_items[idx].filepath.clone(); + + // Build command to execute pdf-reader. 'xdg-open' is Linux standard + let cmd = { + match std::env::consts::OS { + "linux" => String::from("xdg-open"), + "macos" => String::from("open"), + "windows" => String::from("start"), + _ => panic!("Couldn't detect OS for setting correct opener"), + } + }; + + // Pass filepath as argument, pipe stdout and stderr to /dev/null + // to keep the TUI clean (where is it piped on Windows???) + let _ = Command::new(&cmd) + .arg(&filepath) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .wrap_err("Opening file not possible"); + + Ok(()) + } + + pub fn open_doi_url(&mut self) -> Result<()> { + let idx = self.entry_table.entry_table_state.selected().unwrap(); + let web_adress = self.entry_table.entry_table_items[idx].doi_url.clone(); + + // Resolve strings using the resolving function of dx.doi.org, so the + // terminal is not blocked by the resolving process + let url = if web_adress.starts_with("10.") { + let prefix = "https://doi.org/".to_string(); + prefix + &web_adress + } else if web_adress.starts_with("www.") { + let prefix = "https://".to_string(); + prefix + &web_adress + } else { + web_adress + }; + + // Build command to execute browser. 'xdg-open' is Linux standard + let cmd = { + match std::env::consts::OS { + "linux" => String::from("xdg-open"), + "macos" => String::from("open"), + "windows" => String::from("start"), + _ => panic!("Couldn't detect OS for setting correct opener"), + } + }; + + // Pass filepath as argument, pipe stdout and stderr to /dev/null + // to keep the TUI clean (where is it piped on Windows???) + let _ = Command::new(&cmd) + .arg(url) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .wrap_err("Opening file not possible"); + + Ok(()) + } +} + +impl App { + // Tag List commands + + // Movement + pub fn select_next_tag(&mut self, keywords: u16) { + self.tag_list.tag_list_state.scroll_down_by(keywords); + self.tag_list.tag_scroll_state = self + .tag_list + .tag_scroll_state + .position(self.tag_list.tag_list_state.selected().unwrap()); + } + + pub fn select_previous_tag(&mut self, keywords: u16) { + self.tag_list.tag_list_state.scroll_up_by(keywords); + self.tag_list.tag_scroll_state = self + .tag_list + .tag_scroll_state + .position(self.tag_list.tag_list_state.selected().unwrap()); + } + + pub fn select_first_tag(&mut self) { + self.tag_list.tag_list_state.select_first(); + self.tag_list.tag_scroll_state = self.tag_list.tag_scroll_state.position(0); + } + + pub fn select_last_tag(&mut self) { + self.tag_list.tag_list_state.select_last(); + self.tag_list.tag_scroll_state = self + .tag_list + .tag_scroll_state + .position(self.tag_list.tag_list_items.len()); + } + + pub fn get_selected_tag(&self) -> &str { + let idx = self.tag_list.tag_list_state.selected().unwrap(); + let keyword = &self.tag_list.tag_list_items[idx]; + // let keyword = &self.tag_list.tag_list_items[idx].keyword; + keyword + } + + pub fn search_tags(&mut self) { + let orig_list = &self.main_biblio.keyword_list; + let filtered_list = + BibiSearch::search_tag_list(&self.search_struct.search_string, orig_list.clone()); + self.tag_list.tag_list_items = filtered_list; + // Update scrollbar length after filtering list + self.tag_list.tag_scroll_state = ScrollbarState::content_length( + self.tag_list.tag_scroll_state, + self.tag_list.tag_list_items.len(), + ); + } + + pub fn filter_tags_by_entries(&mut self) { + let mut filtered_keywords: Vec<String> = Vec::new(); + + let orig_list = &self.entry_table.entry_table_items; + + for e in orig_list { + if !e.keywords.is_empty() { + let mut key_vec: Vec<String> = e + .keywords + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + filtered_keywords.append(&mut key_vec); + } + } + + filtered_keywords.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + filtered_keywords.dedup(); + + self.search_struct.filtered_tag_list = filtered_keywords.clone(); + self.tag_list.tag_list_items = filtered_keywords; + self.tag_list.tag_scroll_state = ScrollbarState::content_length( + self.tag_list.tag_scroll_state, + self.tag_list.tag_list_items.len(), + ); + } + + // Filter the entry list by tags when hitting enter + // If already inside a filtered tag or entry list, apply the filtering + // to the already filtered list only + pub fn filter_for_tags(&mut self) { + let orig_list = &self.entry_table.entry_table_items; + let keyword = self.get_selected_tag(); + let filtered_list = BibiSearch::filter_entries_by_tag(&keyword, &orig_list); + // self.tag_list.selected_keyword = keyword.to_string(); + self.tag_list.selected_keywords.push(keyword.to_string()); + self.entry_table.entry_table_items = filtered_list; + // Update scrollbar state with new lenght of itemlist + self.entry_table.entry_scroll_state = ScrollbarState::content_length( + self.entry_table.entry_scroll_state, + self.entry_table.entry_table_items.len(), + ); + self.filter_tags_by_entries(); + self.toggle_area(); + self.entry_table.entry_table_state.select(Some(0)); + self.former_area = Some(FormerArea::TagArea); + } +} diff --git a/src/tui/handler.rs b/src/tui/handler.rs new file mode 100644 index 0000000..5a196b5 --- /dev/null +++ b/src/tui/handler.rs @@ -0,0 +1,210 @@ +// bibiman - a TUI for managing BibLaTeX databases +// Copyright (C) 2024 lukeflo +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. +///// + +use crate::tui::app::App; +use crate::tui::Tui; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::app::CurrentArea; +use color_eyre::eyre::Result; + +/// Handles the key events and updates the state of [`App`]. +pub fn handle_key_events(key_event: KeyEvent, app: &mut App, tui: &mut Tui) -> Result<()> { + // Keycodes activated for every area (high priority) + match key_event.code { + // Exit application on `ESC` or `q` + KeyCode::Char('Q') | KeyCode::Char('q') => { + app.quit(); + } + // Exit application on `Ctrl-C` + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit(); + } + } + KeyCode::PageDown => { + app.scroll_info_down(); + } + KeyCode::PageUp => { + app.scroll_info_up(); + } + _ => {} + } + // Keycodes for specific areas + match app.current_area { + // Keycodes for the tag area + CurrentArea::TagArea => match key_event.code { + KeyCode::Down => { + app.select_next_tag(1); + } + KeyCode::Up => { + app.select_previous_tag(1); + } + KeyCode::Char('j') => { + if key_event.modifiers == KeyModifiers::ALT { + app.scroll_info_down(); + } else { + app.select_next_tag(1); + } + } + KeyCode::Char('k') => { + if key_event.modifiers == KeyModifiers::ALT { + app.scroll_info_up(); + } else { + app.select_previous_tag(1); + } + } + KeyCode::Char('d') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.select_next_tag(5) + } + } + KeyCode::Char('u') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.select_previous_tag(5) + } + } + KeyCode::Char('g') | KeyCode::Home => { + app.select_first_tag(); + } + KeyCode::Char('G') | KeyCode::End => { + app.select_last_tag(); + } + KeyCode::Char('/') => { + app.enter_search_area(); + } + KeyCode::Char('f') | KeyCode::Char('F') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.enter_search_area(); + } + } + KeyCode::Tab | KeyCode::BackTab => { + app.toggle_area(); + } + KeyCode::Esc => { + app.reset_current_list(); + } + KeyCode::Enter => { + app.filter_for_tags(); + } + _ => {} + }, + // Keycodes for the entry area + CurrentArea::EntryArea => match key_event.code { + KeyCode::Down => { + app.select_next_entry(1); + } + KeyCode::Up => { + app.select_previous_entry(1); + } + KeyCode::Char('j') => { + if key_event.modifiers == KeyModifiers::ALT { + app.scroll_info_down(); + } else { + app.select_next_entry(1); + } + } + KeyCode::Char('k') => { + if key_event.modifiers == KeyModifiers::ALT { + app.scroll_info_up(); + } else { + app.select_previous_entry(1); + } + } + KeyCode::Char('d') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.select_next_entry(5); + } + } + KeyCode::Char('u') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.select_previous_entry(5); + } else { + app.open_doi_url()?; + } + } + KeyCode::Char('g') | KeyCode::Home => { + app.select_first_entry(); + } + KeyCode::Char('G') | KeyCode::End => { + app.select_last_entry(); + } + KeyCode::Char('h') => { + app.select_prev_column(); + } + KeyCode::Char('l') => { + app.select_next_column(); + } + KeyCode::Char('s') => { + app.entry_table.sort_entry_table(true); + } + KeyCode::Char('y') => { + App::yank_text(&app.get_selected_citekey()); + } + KeyCode::Char('e') => { + app.run_editor(tui)?; + } + KeyCode::Char('o') => { + app.open_connected_file()?; + } + KeyCode::Char('/') => { + app.enter_search_area(); + } + KeyCode::Char('f') | KeyCode::Char('F') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.enter_search_area(); + } + } + KeyCode::Tab | KeyCode::BackTab => { + app.toggle_area(); + } + KeyCode::Esc => { + app.reset_current_list(); + } + _ => {} + }, + // Keycodes for the search area (rendered in footer) + CurrentArea::SearchArea => match key_event.code { + KeyCode::Esc => { + app.break_search(); + } + KeyCode::Enter => { + app.confirm_search(); + } + KeyCode::Backspace => { + app.search_pattern_pop(); + } + KeyCode::Char(search_pattern) => { + app.search_pattern_push(search_pattern); + } + _ => {} + }, + // Keycodes for the help area (popup) + CurrentArea::HelpArea => match key_event.code { + KeyCode::Char('q') => { + app.quit(); + } + KeyCode::Esc => { + app.toggle_area(); + app.former_area = None; + } + _ => {} + }, + CurrentArea::InfoArea => {} + } + Ok(()) +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..07bc88d --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,637 @@ +// bibiman - a TUI for managing BibLaTeX databases +// Copyright (C) 2024 lukeflo +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. +///// + +use super::app::{CurrentArea, FormerArea}; +use crate::bib::entries::EntryTableColumn; +use crate::bib::keywords::TagListItem; +use crate::tui::app::App; +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + symbols, + text::{Line, Span, Text}, + widgets::{ + Block, Borders, Cell, HighlightSpacing, List, ListItem, Padding, Paragraph, Row, Scrollbar, + ScrollbarOrientation, StatefulWidget, Table, Widget, Wrap, + }, +}; + +const MAIN_BLUE_COLOR: Color = Color::Indexed(39); +// const MAIN_PURPLE_COLOR: Color = Color::Indexed(129); +const BOX_SELECTED_BOX_STYLE: Style = Style::new().fg(TEXT_FG_COLOR); +const BOX_SELECTED_TITLE_STYLE: Style = Style::new().fg(TEXT_FG_COLOR).add_modifier(Modifier::BOLD); +const BOX_UNSELECTED_BORDER_STYLE: Style = Style::new().fg(TEXT_UNSELECTED_FG_COLOR); +const BOX_UNSELECTED_TITLE_STYLE: Style = Style::new() + .fg(TEXT_UNSELECTED_FG_COLOR) + .add_modifier(Modifier::BOLD); +const NORMAL_ROW_BG: Color = Color::Black; +const ALT_ROW_BG_COLOR: Color = Color::Indexed(234); +const SELECTED_STYLE: Style = Style::new() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED); +const TEXT_FG_COLOR: Color = Color::Indexed(252); +const TEXT_UNSELECTED_FG_COLOR: Color = Color::Indexed(245); +const SORTED_ENTRIES: &str = "▼"; +const SORTED_ENTRIES_REVERSED: &str = "▲"; +const HEADER_FOOTER_BG: Color = Color::Indexed(235); + +const SCROLLBAR_UPPER_CORNER: Option<&str> = Some("┓"); +const SCROLLBAR_LOWER_CORNER: Option<&str> = Some("┛"); + +pub const fn alternate_colors(i: usize) -> Color { + if i % 2 == 0 { + NORMAL_ROW_BG + } else { + ALT_ROW_BG_COLOR + } +} + +impl From<&TagListItem> for ListItem<'_> { + fn from(value: &TagListItem) -> Self { + let line = Line::styled(format!("{}", value.keyword), TEXT_FG_COLOR); + ListItem::new(line) + } +} + +impl Widget for &mut App { + fn render(self, area: Rect, buf: &mut Buffer) { + let [header_area, main_area, footer_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .areas(area); + + let [list_area, item_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(main_area); + + let [entry_area, entry_info_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(list_area); + + let [tag_area, info_area] = + Layout::horizontal([Constraint::Max(25), Constraint::Min(35)]).areas(item_area); + + // Render header and footer + App::render_header(header_area, buf); + self.render_footer(footer_area, buf); + // Render list area where entry gets selected + self.render_entrytable(entry_area, buf); + self.render_file_info(entry_info_area, buf); + // Render infos related to selected entry + self.render_taglist(tag_area, buf); + self.render_selected_item(info_area, buf); + } +} + +impl App { + pub fn render_header(area: Rect, buf: &mut Buffer) { + Paragraph::new("BIBIMAN – BibLaTeX manager TUI") + .bold() + .fg(MAIN_BLUE_COLOR) + .centered() + .render(area, buf); + } + + pub fn render_footer(&mut self, area: Rect, buf: &mut Buffer) { + match &self.current_area { + CurrentArea::SearchArea => { + let search_title = { + match self.former_area { + Some(FormerArea::EntryArea) => { + let search_title = " Search Entries ".to_string(); + search_title + } + Some(FormerArea::TagArea) => { + let search_title = " Search Keywords ".to_string(); + search_title + } + _ => { + let search_title = " Search ".to_string(); + search_title + } + } + }; + + let block = Block::bordered() + .title(Line::styled(search_title, BOX_SELECTED_TITLE_STYLE)) + .border_style(BOX_SELECTED_BOX_STYLE) + .border_set(symbols::border::THICK); + Paragraph::new(self.search_struct.search_string.clone()) + .block(block) + .render(area, buf); + } + _ => { + let style_emph = Style::new().bold().fg(TEXT_FG_COLOR); + let block = Block::bordered() + .title(Line::raw(" Basic Commands ").centered()) + .border_style(BOX_UNSELECTED_BORDER_STYLE) + .border_set(symbols::border::PLAIN); + Paragraph::new(Line::from(vec![ + Span::styled("j/k: ", style_emph), + Span::raw("move | "), + Span::styled("g/G: ", style_emph), + Span::raw("top/bottom | "), + Span::styled("TAB: ", style_emph), + Span::raw("switch tab | "), + Span::styled("y: ", style_emph), + Span::raw("yank citekey | "), + Span::styled("e: ", style_emph), + Span::raw("edit | "), + Span::styled("/: ", style_emph), + Span::raw("search | "), + Span::styled("o/u: ", style_emph), + Span::raw("open PDF/DOI"), + ])) + .block(block) + .centered() + .render(area, buf); + } + } + } + + // Render info of the current file and process + // 1. Basename of the currently loaded file + // 2. Keyword by which the entries are filtered at the moment + // 3. Currently selected entry and total count of entries + pub fn render_file_info(&mut self, area: Rect, buf: &mut Buffer) { + let block = Block::new() // can also be Block::new + // Leave Top empty to simulate one large box with borders of entry list + .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM) + .border_set(if let CurrentArea::EntryArea = self.current_area { + symbols::border::THICK + } else { + symbols::border::PLAIN + }) + .border_style(if let CurrentArea::EntryArea = self.current_area { + BOX_SELECTED_BOX_STYLE + } else { + BOX_UNSELECTED_BORDER_STYLE + }); + + let [file_area, keyword_area, count_area] = Layout::horizontal([ + Constraint::Fill(3), + Constraint::Fill(4), + Constraint::Fill(1), + ]) + .horizontal_margin(1) + .areas(area); + + Line::from(vec![ + Span::raw("File: ").bold(), + Span::raw(self.main_bibfile.file_name().unwrap().to_string_lossy()).bold(), + ]) + .bg(HEADER_FOOTER_BG) + .render(file_area, buf); + + Line::from(if !self.tag_list.selected_keywords.is_empty() { + vec![ + Span::raw("Selected keywords: "), + // Show all keywords in correct order if list is filtered + // successively by multiple keywords + Span::raw(self.tag_list.selected_keywords.join(" → ")) + .bold() + .green(), + ] + } else { + vec![Span::raw(" ")] + }) + .bg(HEADER_FOOTER_BG) + .render(keyword_area, buf); + + Line::from(if self.entry_table.entry_table_state.selected().is_some() { + vec![ + Span::raw((self.entry_table.entry_table_state.selected().unwrap() + 1).to_string()) + .bold(), + Span::raw("/"), + Span::raw(self.entry_table.entry_table_items.len().to_string()), + ] + } else { + vec![Span::raw("No entries")] + }) + .right_aligned() + .bg(HEADER_FOOTER_BG) + .render(count_area, buf); + + // Render that stuff + Widget::render(block, area, buf) + } + + pub fn render_entrytable(&mut self, area: Rect, buf: &mut Buffer) { + let block = Block::new() // can also be Block::new + .title( + Line::styled( + " Bibliographic Entries ", + if let CurrentArea::EntryArea = self.current_area { + BOX_SELECTED_TITLE_STYLE + } else { + BOX_UNSELECTED_TITLE_STYLE + }, + ) + .centered(), + ) + .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP) + .border_set(if let CurrentArea::EntryArea = self.current_area { + symbols::border::THICK + } else { + symbols::border::PLAIN + }) + .border_style(if let CurrentArea::EntryArea = self.current_area { + BOX_SELECTED_BOX_STYLE + } else { + BOX_UNSELECTED_BORDER_STYLE + }); + + let header_style = Style::default() + .bold() + .fg(TEXT_FG_COLOR) + .bg(HEADER_FOOTER_BG); + + let header_selected_col = Style::default().underlined(); + + let header = Row::new(vec![ + Cell::from(Line::from(vec![ + { + if let EntryTableColumn::Authors = self.entry_table.entry_table_selected_column + { + Span::styled("Author", header_selected_col) + } else { + Span::raw("Author") + } + }, + { + if let EntryTableColumn::Authors = self.entry_table.entry_table_sorted_by_col { + Span::raw(format!( + " {}", + if self.entry_table.entry_table_reversed_sort { + SORTED_ENTRIES_REVERSED + } else { + SORTED_ENTRIES + } + )) + } else { + Span::raw("") + } + }, + ])), + Cell::from(Line::from(vec![ + { + if let EntryTableColumn::Title = self.entry_table.entry_table_selected_column { + Span::styled("Title", header_selected_col) + } else { + Span::raw("Title") + } + }, + { + if let EntryTableColumn::Title = self.entry_table.entry_table_sorted_by_col { + Span::raw(format!( + " {}", + if self.entry_table.entry_table_reversed_sort { + SORTED_ENTRIES_REVERSED + } else { + SORTED_ENTRIES + } + )) + } else { + Span::raw("") + } + }, + ])), + Cell::from(Line::from(vec![ + { + if let EntryTableColumn::Year = self.entry_table.entry_table_selected_column { + Span::styled("Year", header_selected_col) + } else { + Span::raw("Year") + } + }, + { + if let EntryTableColumn::Year = self.entry_table.entry_table_sorted_by_col { + Span::raw(format!( + " {}", + if self.entry_table.entry_table_reversed_sort { + SORTED_ENTRIES_REVERSED + } else { + SORTED_ENTRIES + } + )) + } else { + Span::raw("") + } + }, + ])), + Cell::from(Line::from(vec![ + { + if let EntryTableColumn::Pubtype = self.entry_table.entry_table_selected_column + { + Span::styled("Pubtype", header_selected_col) + } else { + Span::raw("Pubtype") + } + }, + { + if let EntryTableColumn::Pubtype = self.entry_table.entry_table_sorted_by_col { + Span::raw(format!( + " {}", + if self.entry_table.entry_table_reversed_sort { + SORTED_ENTRIES_REVERSED + } else { + SORTED_ENTRIES + } + )) + } else { + Span::raw("") + } + }, + ])), + ]) + .style(header_style) + .height(1); + + // Iterate over vector storing each entries data fields + let rows = self + .entry_table + .entry_table_items + .iter_mut() + .enumerate() + .map(|(_i, data)| { + let item = data.ref_vec(); + item.into_iter() + .map(|content| Cell::from(Text::from(format!("{content}")))) + .collect::<Row>() + .style(Style::new().fg(TEXT_FG_COLOR)) //.bg(alternate_colors(i))) + .height(1) + }); + let entry_table = Table::new( + rows, + [ + Constraint::Percentage(20), + Constraint::Fill(1), + Constraint::Length( + if let EntryTableColumn::Year = self.entry_table.entry_table_sorted_by_col { + 6 + } else { + 4 + }, + ), + Constraint::Percentage(10), + ], + ) + .block(block) + .header(header) + .column_spacing(2) + .row_highlight_style(SELECTED_STYLE) + // .bg(Color::Black) + .highlight_spacing(HighlightSpacing::Always); + StatefulWidget::render( + entry_table, + area, + buf, + &mut self.entry_table.entry_table_state, + ); + + // Scrollbar for entry table + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .track_symbol(None) + .begin_symbol(SCROLLBAR_UPPER_CORNER) + .end_symbol(None) + .thumb_style(Style::new().fg(Color::DarkGray)); + + if let CurrentArea::EntryArea = self.current_area { + // render the scrollbar + StatefulWidget::render( + scrollbar, + area, + buf, + &mut self.entry_table.entry_scroll_state, + ); + } + } + + pub fn render_selected_item(&mut self, area: Rect, buf: &mut Buffer) { + // We get the info depending on the item's state. + let style_value = Style::new().bold().fg(TEXT_FG_COLOR); + let style_value_sec = Style::new() + .add_modifier(Modifier::ITALIC) + .fg(TEXT_FG_COLOR); + let lines = { + // if self.entry_table.entry_table_items.len() > 0 { + if self.entry_table.entry_table_state.selected().is_some() { + let idx = self.entry_table.entry_table_state.selected().unwrap(); + let cur_entry = &self.entry_table.entry_table_items[idx]; + let mut lines = vec![]; + lines.push(Line::from(vec![ + Span::styled("Authors: ", style_value), + // Span::styled(cur_entry.authors.clone(), Style::new().green()), + Span::styled(cur_entry.authors(), Style::new().green()), + ])); + lines.push(Line::from(vec![ + Span::styled("Title: ", style_value), + Span::styled(cur_entry.title(), Style::new().magenta()), + ])); + lines.push(Line::from(vec![ + Span::styled("Year: ", style_value), + Span::styled(cur_entry.year(), Style::new().light_magenta()), + ])); + // Render keywords in info box in Markdown code style + if !cur_entry.keywords.is_empty() { + let kw: Vec<&str> = cur_entry + .keywords + .split(",") + .map(|k| k.trim()) + .filter(|k| !k.is_empty()) + .collect(); + let mut content = vec![Span::styled("Keywords: ", style_value)]; + for k in kw { + // Add half block highlighted in bg color to enlarge block + content.push(Span::raw("▐").fg(HEADER_FOOTER_BG)); + content.push(Span::styled( + k, + Style::default().bg(HEADER_FOOTER_BG).fg( + // Highlight selected keyword green + if self.tag_list.selected_keywords.iter().any(|e| e == k) { + Color::Green + } else { + TEXT_FG_COLOR + }, + ), + )); + content.push(Span::raw("▌").fg(HEADER_FOOTER_BG)); + } + lines.push(Line::from(content)) + } + if !cur_entry.doi_url.is_empty() || !cur_entry.filepath.is_empty() { + lines.push(Line::raw("")); + } + if !cur_entry.doi_url.is_empty() { + lines.push(Line::from(vec![ + Span::styled("DOI/URL: ", style_value_sec), + Span::styled( + cur_entry.doi_url(), + Style::default().fg(TEXT_FG_COLOR).underlined(), + ), + ])); + } + if !cur_entry.filepath.is_empty() { + lines.push(Line::from(vec![ + Span::styled("File: ", style_value_sec), + Span::styled(cur_entry.filepath(), Style::default().fg(TEXT_FG_COLOR)), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + cur_entry.abstract_text.clone(), + Style::default().fg(TEXT_FG_COLOR), + )])); + lines + } else { + let lines = vec![ + Line::from(" "), + Line::from("No entry selected".bold().into_centered_line().red()), + ]; + lines + } + }; + let info = Text::from(lines); + + // We show the list item's info under the list in this paragraph + let block = Block::bordered() + .title(Line::raw(" Entry Information ").centered().bold()) + // .borders(Borders::TOP) + .border_set(symbols::border::PLAIN) + .border_style(BOX_UNSELECTED_BORDER_STYLE) + // .bg(Color::Black) + .padding(Padding::horizontal(1)); + + // INFO: '.line_count' method only possible with unstable-rendered-line-info feature -> API might change: https://github.com/ratatui/ratatui/issues/293#ref-pullrequest-2027056434 + let box_height = Paragraph::new(info.clone()) + .block(block.clone()) + .wrap(Wrap { trim: false }) + .line_count(area.width); + // Make sure to allow scroll only if text is larger than the rendered area and stop scrolling when last line is reached + let scroll_height = { + if self.entry_table.entry_info_scroll == 0 { + self.entry_table.entry_info_scroll + } else if area.height > box_height as u16 { + self.entry_table.entry_info_scroll = 0; + self.entry_table.entry_info_scroll + } else if self.entry_table.entry_info_scroll > (box_height as u16 + 2 - area.height) { + self.entry_table.entry_info_scroll = box_height as u16 + 2 - area.height; + self.entry_table.entry_info_scroll + } else { + self.entry_table.entry_info_scroll + } + }; + + // We can now render the item info + Paragraph::new(info) + .block( + block + // Render arrows to show that info box has content outside the block + .title_bottom( + Line::from( + if box_height > area.height.into() + && self.entry_table.entry_info_scroll + < box_height as u16 + 2 - area.height + { + " ▼ " + } else { + "" + }, + ) + .alignment(Alignment::Right), + ) + .title_top( + Line::from(if scroll_height > 0 { " ▲ " } else { "" }) + .alignment(Alignment::Right), + ), + ) + // .fg(TEXT_FG_COLOR) + .wrap(Wrap { trim: false }) + .scroll((scroll_height, 0)) + .render(area, buf); + } + + pub fn render_taglist(&mut self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .title( + Line::styled( + " Keywords ", + if let CurrentArea::TagArea = self.current_area { + BOX_SELECTED_TITLE_STYLE + } else { + BOX_UNSELECTED_TITLE_STYLE + }, + ) + .centered(), + ) + .border_set(if let CurrentArea::TagArea = self.current_area { + symbols::border::THICK + } else { + symbols::border::PLAIN + }) + .border_style(if let CurrentArea::TagArea = self.current_area { + BOX_SELECTED_BOX_STYLE + } else { + BOX_UNSELECTED_BORDER_STYLE + }); + // .bg(Color::Black); + + // Iterate through all elements in the `items` and stylize them. + let items: Vec<ListItem> = self + .tag_list + .tag_list_items + .iter() + .enumerate() + .map(|(_i, todo_item)| { + // let color = alternate_colors(i); + ListItem::from(todo_item.to_owned()) //.bg(color) + }) + .collect(); + + // Create a List from all list items and highlight the currently selected one + let list = List::new(items) + .block(block) + .highlight_style(SELECTED_STYLE) + // .highlight_symbol("> ") + .highlight_spacing(HighlightSpacing::Always); + + // Save list length for calculating scrollbar need + // Add 2 to compmensate lines of the block border + let list_length = list.len() + 2; + + // We need to disambiguate this trait method as both `Widget` and `StatefulWidget` share the + // same method name `render`. + StatefulWidget::render(list, area, buf, &mut self.tag_list.tag_list_state); + + // Scrollbar for keyword list + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .track_symbol(None) + .begin_symbol(SCROLLBAR_UPPER_CORNER) + .end_symbol(SCROLLBAR_LOWER_CORNER) + .thumb_style(Style::new().fg(Color::DarkGray)); + + if list_length > area.height.into() { + if let CurrentArea::TagArea = self.current_area { + // render the scrollbar + StatefulWidget::render(scrollbar, area, buf, &mut self.tag_list.tag_scroll_state); + } + } + } +} |
