// 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))
}
}