// 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 . ///// use crate::bibiman::CurrentArea; use crate::cliargs::CLIArgs; use crate::config::BibiConfig; use crate::tui::commands::InputCmdAction; use crate::tui::popup::{PopupItem, PopupKind}; use crate::tui::{self, Tui}; use crate::{bibiman::Bibiman, tui::commands::CmdAction}; use color_eyre::eyre::{Context, Ok, Result}; use crossterm::event::KeyCode; use std::ffi::OsStr; use std::path::PathBuf; use std::process::{Command, Stdio}; use tui::Event; use tui_input::backend::crossterm::EventHandler; use tui_input::Input; // Application. #[derive(Debug)] pub struct App { // Is the application running? pub running: bool, // bibimain pub bibiman: Bibiman, // Input mode pub input: Input, // Input mode bool pub input_mode: bool, } impl App { // Constructs a new instance of [`App`]. pub fn new(args: &mut CLIArgs, cfg: &mut BibiConfig) -> Result { // Self::default() let running = true; let input = Input::default(); let bibiman = Bibiman::new(args, cfg)?; Ok(Self { running, bibiman, input, input_mode: false, }) } pub async fn run(&mut self, cfg: &BibiConfig) -> Result<()> { let mut tui = tui::Tui::new()?; tui.enter()?; // Start the main loop. while self.running { // Render the user interface. tui.draw(self, cfg)?; // 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::Key(key_event) => { // Automatically close message popups on next keypress if let Some(PopupKind::MessageConfirm) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup() } else if let Some(PopupKind::MessageError) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup() } else if let Some(PopupKind::YankItem) | Some(PopupKind::OpenRes) = self.bibiman.popup_area.popup_kind { self.bibiman.fast_selection(cfg, &mut tui, key_event.code)?; // if a fast match char was used, restart event-loop. // otherwise, the fast match char will be executed as command match key_event.code { KeyCode::Char('o' | 'l' | 'n' | 'y') => continue, _ => {} } } let command = if self.input_mode { CmdAction::Input(InputCmdAction::parse(key_event, &self.input)) } else { CmdAction::from(key_event) }; self.run_command(command, cfg, &mut tui)? } Event::Mouse(mouse_event) => { self.run_command(CmdAction::from(mouse_event), cfg, &mut tui)? } 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 run_command(&mut self, cmd: CmdAction, cfg: &BibiConfig, tui: &mut Tui) -> Result<()> { match cmd { CmdAction::Input(cmd) => match cmd { InputCmdAction::Nothing => {} InputCmdAction::Handle(event) => { self.input.handle_event(&event); if let CurrentArea::SearchArea = self.bibiman.current_area { self.bibiman.search_list_by_pattern(&self.input); } } InputCmdAction::Enter => { self.input_mode = true; // Logic for TABS to be added // self.bibiman.enter_search_area(); } InputCmdAction::Confirm => { // Logic for TABS to be added if let CurrentArea::SearchArea = self.bibiman.current_area { self.bibiman.confirm_search(); } else if let CurrentArea::PopupArea = self.bibiman.current_area { match self.bibiman.popup_area.popup_kind { Some(PopupKind::AddEntry) => { let doi = self.input.value(); self.bibiman.close_popup(); self.input_mode = false; // Check if the DOI pattern is valid. If not, show warning and break if doi.starts_with("10.") || doi.starts_with("https://doi.org") || doi.starts_with("https://dx.doi.org") || doi.starts_with("http://doi.org") || doi.starts_with("http://dx.doi.org") { self.bibiman.handle_new_entry_submission(doi)?; } else { self.bibiman.open_popup( PopupKind::MessageError, Some("No valid DOI pattern: "), Some(doi), None, )?; } } _ => {} } } self.input = Input::default(); self.input_mode = false; } InputCmdAction::Exit => { self.input = Input::default(); self.input_mode = false; if let CurrentArea::SearchArea = self.bibiman.current_area { self.bibiman.break_search(); } else if let CurrentArea::PopupArea = self.bibiman.current_area { self.bibiman.close_popup(); } } }, CmdAction::SelectNextRow(amount) => match self.bibiman.current_area { // Here add logic to select TAB CurrentArea::EntryArea => { self.bibiman.select_next_entry(amount); } CurrentArea::TagArea => { self.bibiman.select_next_tag(amount); } CurrentArea::PopupArea => match self.bibiman.popup_area.popup_kind { Some(PopupKind::Help) => { self.bibiman.popup_area.popup_scroll_down(); } Some(PopupKind::OpenRes) | Some(PopupKind::AppendToFile) | Some(PopupKind::YankItem) | Some(PopupKind::CreateNote) => { self.bibiman.popup_area.popup_state.scroll_down_by(1) } _ => {} }, _ => {} }, CmdAction::SelectPrevRow(amount) => match self.bibiman.current_area { // Here add logic to select TAB CurrentArea::EntryArea => { self.bibiman.select_previous_entry(amount); } CurrentArea::TagArea => { self.bibiman.select_previous_tag(amount); } CurrentArea::PopupArea => match self.bibiman.popup_area.popup_kind { Some(PopupKind::Help) => { self.bibiman.popup_area.popup_scroll_up(); } Some(PopupKind::OpenRes) | Some(PopupKind::AppendToFile) | Some(PopupKind::YankItem) | Some(PopupKind::CreateNote) => { self.bibiman.popup_area.popup_state.scroll_up_by(1) } _ => {} }, _ => {} }, CmdAction::SelectNextCol => { if let CurrentArea::EntryArea = self.bibiman.current_area { self.bibiman.select_next_column(); } } CmdAction::SelectPrevCol => { if let CurrentArea::EntryArea = self.bibiman.current_area { self.bibiman.select_prev_column(); } } CmdAction::ScrollInfoDown => { self.bibiman.scroll_info_down(); } CmdAction::ScrollInfoUp => { self.bibiman.scroll_info_up(); } CmdAction::Bottom => match self.bibiman.current_area { CurrentArea::EntryArea => { self.bibiman.select_last_entry(); } CurrentArea::TagArea => { self.bibiman.select_last_tag(); } _ => {} }, CmdAction::Top => match self.bibiman.current_area { CurrentArea::EntryArea => { self.bibiman.select_first_entry(); } CurrentArea::TagArea => { self.bibiman.select_first_tag(); } _ => {} }, CmdAction::ToggleArea => { self.bibiman.toggle_area(); } CmdAction::SearchList => { self.input_mode = true; self.bibiman.enter_search_area(); } CmdAction::Reset => { if let CurrentArea::PopupArea = self.bibiman.current_area { if let Some(PopupKind::Help) = self.bibiman.popup_area.popup_kind { self.bibiman.popup_area.popup_scroll_pos = 0; self.bibiman.close_popup() } else if let Some(PopupKind::OpenRes) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup() } else if let Some(PopupKind::AppendToFile) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup(); } else if let Some(PopupKind::YankItem) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup(); } else if let Some(PopupKind::CreateNote) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup(); } } else { self.bibiman.reset_current_list(); } } CmdAction::Confirm => { if let CurrentArea::TagArea = self.bibiman.current_area { self.bibiman.filter_for_tags(); } else if let CurrentArea::PopupArea = self.bibiman.current_area { if let Some(PopupKind::Help) = self.bibiman.popup_area.popup_kind { self.bibiman.close_popup(); } else if let Some(PopupKind::OpenRes) = self.bibiman.popup_area.popup_kind { self.bibiman.open_connected_res(cfg, tui)?; } else if let Some(PopupKind::AppendToFile) = self.bibiman.popup_area.popup_kind { self.bibiman.append_entry_to_file(cfg)? } else if let Some(PopupKind::YankItem) = self.bibiman.popup_area.popup_kind { self.bibiman.yank_entry_field()? } else if let Some(PopupKind::CreateNote) = self.bibiman.popup_area.popup_kind { self.bibiman.create_note(cfg)? } } } CmdAction::SortList => { if let CurrentArea::EntryArea = self.bibiman.current_area { self.bibiman.entry_table.sort_entry_table(true); } } CmdAction::SortById => { self.bibiman.entry_table.sort_by_id(); } CmdAction::YankItem => { if let CurrentArea::EntryArea = self.bibiman.current_area { let idx = self .bibiman .entry_table .entry_table_state .selected() .unwrap(); let entry = self.bibiman.entry_table.entry_table_items[idx].clone(); let mut items = vec![( "Citekey: ".to_string(), entry.citekey.clone(), PopupItem::Citekey, )]; if entry.doi_url.is_some() { items.push(( "Weblink: ".into(), entry.doi_url.unwrap().clone(), PopupItem::Link, )) } if entry.filepath.is_some() { entry.filepath.unwrap().iter().for_each(|p| { items.push(( "Filepath: ".into(), p.clone().into_string().unwrap(), PopupItem::Entryfile, )) }); // items.push(( // "Filepath: ".into(), // entry.filepath.unwrap()[0].clone().into_string().unwrap(), // )) } // self.bibiman.popup_area.popup_kind = Some(PopupKind::YankItem); // self.bibiman.popup_area.popup_selection(items); // self.bibiman.former_area = Some(FormerArea::EntryArea); // self.bibiman.current_area = CurrentArea::PopupArea; // self.bibiman.popup_area.popup_state.select(Some(0)); self.bibiman .open_popup(PopupKind::YankItem, None, None, Some(items))?; } } CmdAction::EditFile => { if let CurrentArea::EntryArea = self.bibiman.current_area { self.bibiman.run_editor(cfg, tui)?; } } CmdAction::Open => { if let CurrentArea::EntryArea = self.bibiman.current_area { let idx = self .bibiman .entry_table .entry_table_state .selected() .unwrap(); let entry = self.bibiman.entry_table.entry_table_items[idx].clone(); let mut items: Vec<(String, String, PopupItem)> = vec![]; if entry.filepath.is_some() || entry.doi_url.is_some() || entry.notes.is_some() { if entry.doi_url.is_some() { items.push(( "Link: ".into(), entry.doi_url.unwrap().clone(), PopupItem::Link, )) } if entry.filepath.is_some() { entry.filepath.unwrap().iter().for_each(|p| { items.push(( "File: ".into(), // p.clone().into_string().unwrap(), if entry.file_field && cfg.general.file_prefix.is_some() { cfg.general .file_prefix .clone() .unwrap() .join(p) .into_os_string() .into_string() .unwrap() } else { p.clone().into_string().unwrap() }, PopupItem::Entryfile, )) }); } if entry.notes.is_some() { entry.notes.unwrap().iter().for_each(|n| { items.push(( "Note: ".into(), n.clone().into_string().unwrap(), PopupItem::Notefile, )); }); } self.bibiman .open_popup(PopupKind::OpenRes, None, None, Some(items))?; } else { self.bibiman.open_popup( PopupKind::MessageError, Some("Selected entry has no connected resources: "), Some(&entry.citekey), None, )?; } } } CmdAction::AddEntry => { if let CurrentArea::EntryArea = self.bibiman.current_area { self.input_mode = true; self.bibiman.add_entry(); } } CmdAction::CreateNote => { if let CurrentArea::EntryArea = self.bibiman.current_area { let citekey = self.bibiman.entry_table.entry_table_items[self .bibiman .entry_table .entry_table_state .selected() .unwrap()] .citekey .clone(); // disallow chars which can cause other shell executions if citekey.contains("/") | citekey.contains("|") | citekey.contains("#") | citekey.contains("\\") | citekey.contains("*") | citekey.contains("\"") | citekey.contains(";") | citekey.contains("!") | citekey.contains("\'") { self.bibiman.open_popup( PopupKind::MessageError, Some("Selected entrys citekey contains special char: "), Some(&citekey), None, )?; } else if cfg.general.note_path.is_some() && cfg.general.note_extensions.is_some() && self.bibiman.entry_table.entry_table_items[self .bibiman .entry_table .entry_table_state .selected() .unwrap()] .notes .is_none() { let mut items = vec![]; for ex in cfg.general.note_extensions.as_ref().unwrap() { items.push(( self.bibiman.entry_table.entry_table_items[self .bibiman .entry_table .entry_table_state .selected() .unwrap()] .citekey() .to_string(), ex.clone(), PopupItem::Notefile, )); } self.bibiman .open_popup(PopupKind::CreateNote, None, None, Some(items))?; } else if cfg.general.note_path.is_some() && self.bibiman.entry_table.entry_table_items[self .bibiman .entry_table .entry_table_state .selected() .unwrap()] .notes .is_some() { self.bibiman.open_popup( PopupKind::MessageError, Some("Selected entry already has a connected note"), None, None, )?; } else { self.bibiman.open_popup( PopupKind::MessageError, Some("No note path found. Set it in config file."), None, None, )?; } } } CmdAction::ShowHelp => { self.bibiman.open_popup(PopupKind::Help, None, None, None)?; } CmdAction::Exit => { self.quit(); } CmdAction::Nothing => {} } Ok(()) } } pub fn open_connected_file(cfg: &BibiConfig, file: &OsStr) -> Result<()> { // Build command to execute pdf-reader. 'xdg-open' is Linux standard let cmd = &cfg.general.pdf_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(file) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .wrap_err("Opening file not possible"); Ok(()) } pub fn open_connected_link(cfg: &BibiConfig, link: &str) -> Result<()> { // Build command to execute pdf-reader. 'xdg-open' is Linux standard let cmd = &cfg.general.url_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(link) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .wrap_err("Opening link not possible"); Ok(()) } pub fn prepare_weblink(url: &str) -> String { let url = if url.starts_with("10.") { "https://doi.org/".to_string() + url } else if url.starts_with("www.") { "https://".to_string() + url } else { url.to_string() }; url } /// Expand leading tilde (`~`) to `/home/user` pub fn expand_home(path: &PathBuf) -> PathBuf { if path.starts_with("~") { let mut home = dirs::home_dir().unwrap(); let path = path.strip_prefix("~").unwrap(); home.push(path); home } else { path.into() } } /// Convert `Vec<(&str, &str)` to `Vec<(String, String)` pub fn convert_to_owned_vec(mut items: Vec<(&str, &str)>) -> Vec<(String, String)> { items .iter_mut() .map(|(msg, obj)| (msg.to_string(), obj.to_string())) .collect() } #[cfg(test)] mod test { use super::*; #[test] fn test_home_expansion() { let path: PathBuf = "~/path/to/file.txt".into(); let path = expand_home(&path); let home: String = dirs::home_dir().unwrap().to_str().unwrap().to_string(); let full_path = home + "/path/to/file.txt"; assert_eq!(path, PathBuf::from(full_path)) } }