From 66402a9c23e0975a8a3d8c2707b689b9cde98ccf Mon Sep 17 00:00:00 2001 From: lukeflo Date: Tue, 22 Oct 2024 21:52:36 +0200 Subject: rearrange code, file and folder structure --- Cargo.lock | 24 +- Cargo.toml | 2 +- src/backend.rs | 20 -- src/backend/bib.rs | 279 -------------------- src/backend/cliargs.rs | 89 ------- src/backend/search.rs | 137 ---------- src/bib.rs | 21 ++ src/bib/bibmain.rs | 279 ++++++++++++++++++++ src/bib/entries.rs | 258 +++++++++++++++++++ src/bib/keywords.rs | 55 ++++ src/bib/search.rs | 136 ++++++++++ src/cliargs.rs | 89 +++++++ src/frontend.rs | 23 -- src/frontend/app.rs | 259 ------------------- src/frontend/entries.rs | 499 ------------------------------------ src/frontend/handler.rs | 210 --------------- src/frontend/keywords.rs | 159 ------------ src/frontend/tui.rs | 223 ---------------- src/frontend/ui.rs | 646 ----------------------------------------------- src/main.rs | 9 +- src/tui.rs | 228 +++++++++++++++++ src/tui/app.rs | 257 +++++++++++++++++++ src/tui/command.rs | 363 ++++++++++++++++++++++++++ src/tui/handler.rs | 210 +++++++++++++++ src/tui/ui.rs | 637 ++++++++++++++++++++++++++++++++++++++++++++++ 25 files changed, 2557 insertions(+), 2555 deletions(-) delete mode 100644 src/backend.rs delete mode 100644 src/backend/bib.rs delete mode 100644 src/backend/cliargs.rs delete mode 100644 src/backend/search.rs create mode 100644 src/bib.rs create mode 100644 src/bib/bibmain.rs create mode 100644 src/bib/entries.rs create mode 100644 src/bib/keywords.rs create mode 100644 src/bib/search.rs create mode 100644 src/cliargs.rs delete mode 100644 src/frontend.rs delete mode 100644 src/frontend/app.rs delete mode 100644 src/frontend/entries.rs delete mode 100644 src/frontend/handler.rs delete mode 100644 src/frontend/keywords.rs delete mode 100644 src/frontend/tui.rs delete mode 100644 src/frontend/ui.rs create mode 100644 src/tui.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/command.rs create mode 100644 src/tui/handler.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 4373b63..156a27c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ [[package]] name = "bibiman" -version = "0.4.3" +version = "0.4.4" dependencies = [ "arboard", "biblatex", @@ -716,6 +716,12 @@ dependencies = [ "serde", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "instability" version = "0.3.2" @@ -1127,23 +1133,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.6.0", "cassowary", "compact_str", "crossterm", + "indoc", "instability", "itertools", "lru", "paste", "strum", - "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -1587,7 +1593,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1596,6 +1602,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 01f6b91..1346a21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ futures = "0.3.30" hayagriva = "0.8.0" itertools = "0.13.0" nucleo-matcher = "0.3.1" -ratatui = { version = "0.28.1", features = ["unstable-rendered-line-info"]} +ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"]} sarge = "7.2.5" signal-hook = "0.3.17" tokio = { version = "1.39.3", features = ["full"] } diff --git a/src/backend.rs b/src/backend.rs deleted file mode 100644 index 75adb9f..0000000 --- a/src/backend.rs +++ /dev/null @@ -1,20 +0,0 @@ -// 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 . -///// - -pub mod bib; -pub mod cliargs; -pub mod search; diff --git a/src/backend/bib.rs b/src/backend/bib.rs deleted file mode 100644 index a7df951..0000000 --- a/src/backend/bib.rs +++ /dev/null @@ -1,279 +0,0 @@ -// 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 biblatex::{self, Bibliography}; -use biblatex::{ChunksExt, Type}; -use itertools::Itertools; -use std::{fs, path::PathBuf}; - -#[derive(Debug)] -pub enum FileFormat { - BibLatex, - Hayagriva, -} - -// Set necessary fields -// TODO: can surely be made more efficient/simpler -#[derive(Debug)] -pub struct BibiMain { - pub bibfile: PathBuf, // path to bibfile - pub bibfile_format: FileFormat, // Format of passed file - pub bibfilestring: String, // content of bibfile as string - pub bibliography: Bibliography, // parsed bibliography - pub citekeys: Vec, // list of all citekeys - pub keyword_list: Vec, // list of all available keywords - pub entry_list: Vec, // List of all entries -} - -#[derive(Debug, Clone)] -pub struct BibiData { - pub authors: String, - pub title: String, - pub year: String, - pub pubtype: String, - pub keywords: String, - pub citekey: String, - pub abstract_text: String, - pub doi_url: String, - pub filepath: String, -} - -impl BibiMain { - pub fn new(main_bibfile: PathBuf) -> Self { - // TODO: Needs check for config file path as soon as config file is impl - let bibfile_format = Self::check_file_format(&main_bibfile); - let bibfile = main_bibfile; - let bibfilestring = fs::read_to_string(&bibfile).unwrap(); - let bibliography = biblatex::Bibliography::parse(&bibfilestring).unwrap(); - let citekeys = Self::get_citekeys(&bibliography); - let keyword_list = Self::collect_tag_list(&citekeys, &bibliography); - let entry_list = Self::create_entry_list(&citekeys, &bibliography); - Self { - bibfile, - bibfile_format, - bibfilestring, - bibliography, - citekeys, - keyword_list, - entry_list, - } - } - - // Check which file format the passed file has - fn check_file_format(main_bibfile: &PathBuf) -> FileFormat { - let extension = main_bibfile.extension().unwrap().to_str(); - - match extension { - Some("yml") => FileFormat::Hayagriva, - Some("yaml") => FileFormat::Hayagriva, - Some("bib") => FileFormat::BibLatex, - Some(_) => panic!("The extension {:?} is no valid bibfile", extension.unwrap()), - None => panic!("The given path {:?} holds no valid file", main_bibfile), - } - } - - fn create_entry_list(citekeys: &[String], bibliography: &Bibliography) -> Vec { - citekeys - .into_iter() - .map(|k| BibiData { - authors: Self::get_authors(&k, &bibliography), - title: Self::get_title(&k, &bibliography), - year: Self::get_year(&k, &bibliography), - pubtype: Self::get_pubtype(&k, &bibliography), - keywords: Self::get_keywords(&k, &bibliography), - citekey: k.to_owned(), - abstract_text: Self::get_abstract(&k, &bibliography), - doi_url: Self::get_weblink(&k, &bibliography), - filepath: Self::get_filepath(&k, &bibliography), - }) - .collect() - } - - // get list of citekeys from the given bibfile - // this list is the base for further operations on the bibentries - // since it is the entry point of the biblatex crate. - pub fn get_citekeys(bibstring: &Bibliography) -> Vec { - let citekeys: Vec = bibstring.keys().map(|k| k.to_owned()).collect(); - citekeys - } - - // collect all keywords present in the bibliography - // sort them and remove duplicates - // this list is for fast filtering entries by topics/keyowrds - pub fn collect_tag_list(citekeys: &[String], biblio: &Bibliography) -> Vec { - // Initialize vector collecting all keywords - let mut keyword_list = vec![]; - - // Loop over entries and collect all keywords - for i in citekeys { - if biblio.get(&i).unwrap().keywords().is_ok() { - let items = biblio - .get(&i) - .unwrap() - .keywords() - .unwrap() - .format_verbatim(); - // Split keyword string into slices, trim leading and trailing - // whitespaces, remove empty slices, and collect them - let mut key_vec: Vec = items - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - // Append keywords to vector - keyword_list.append(&mut key_vec); - } - } - - // Sort the vector and remove duplicates - keyword_list.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); - keyword_list.dedup(); - keyword_list - } - - pub fn get_authors(citekey: &str, biblio: &Bibliography) -> String { - if biblio.get(&citekey).unwrap().author().is_ok() { - let authors = biblio.get(&citekey).unwrap().author().unwrap(); - if authors.len() > 1 { - let all_authors = authors.iter().map(|a| &a.name).join(", "); - all_authors - } else if authors.len() == 1 { - let authors = authors[0].name.to_string(); - authors - } else { - let editors_authors = format!("empty"); - editors_authors - } - } else { - if !biblio.get(&citekey).unwrap().editors().unwrap().is_empty() { - let editors = biblio.get(&citekey).unwrap().editors().unwrap(); - if editors[0].0.len() > 1 { - // let editors = format!("{} (ed.) et al.", editors[0].0[0].name); - let mut editors = editors[0].0.iter().map(|e| &e.name).join(", "); - editors.push_str(" (ed.)"); - editors - } else if editors[0].0.len() == 1 { - let editors = format!("{} (ed.)", editors[0].0[0].name); - editors - } else { - let editors_authors = format!("empty"); - editors_authors - } - } else { - let editors_authors = format!("empty"); - editors_authors - } - } - } - - pub fn get_title(citekey: &str, biblio: &Bibliography) -> String { - let title = { - if biblio.get(&citekey).unwrap().title().is_ok() { - let title = biblio - .get(&citekey) - .unwrap() - .title() - .unwrap() - .format_verbatim(); - title - } else { - let title = format!("no title"); - title - } - }; - title - } - - pub fn get_year(citekey: &str, biblio: &Bibliography) -> String { - let year = biblio.get(&citekey).unwrap(); - let year = { - if year.date().is_ok() { - let year = year.date().unwrap().to_chunks().format_verbatim(); - let year = year[..4].to_string(); - year - } else { - let year = format!("n.d."); - year - } - }; - year - } - - pub fn get_pubtype(citekey: &str, biblio: &Bibliography) -> String { - let pubtype = biblio.get(&citekey).unwrap().entry_type.to_string(); - pubtype - } - - pub fn get_keywords(citekey: &str, biblio: &Bibliography) -> String { - let keywords = { - if biblio.get(&citekey).unwrap().keywords().is_ok() { - let keywords = biblio - .get(&citekey) - .unwrap() - .keywords() - .unwrap() - .format_verbatim(); - keywords - } else { - let keywords = String::from(""); - keywords - } - }; - keywords - } - - pub fn get_abstract(citekey: &str, biblio: &Bibliography) -> String { - let text = { - if biblio.get(&citekey).unwrap().abstract_().is_ok() { - let abstract_text = biblio - .get(&citekey) - .unwrap() - .abstract_() - .unwrap() - .format_verbatim(); - abstract_text - } else { - let abstract_text = format!("No abstract"); - abstract_text - } - }; - text - } - - pub fn get_weblink(citekey: &str, biblio: &Bibliography) -> String { - if let true = biblio.get(&citekey).unwrap().doi().is_ok() { - let url = biblio.get(&citekey).unwrap().doi().unwrap(); - url - } else if let true = biblio.get(&citekey).unwrap().url().is_ok() { - let url = biblio.get(&citekey).unwrap().url().unwrap(); - url - } else { - let url = "".to_string(); - url - } - } - - pub fn get_filepath(citekey: &str, biblio: &Bibliography) -> String { - if let true = biblio.get(&citekey).unwrap().file().is_ok() { - let file = biblio.get(&citekey).unwrap().file().unwrap(); - file - } else { - let file = "".to_string(); - file - } - } -} diff --git a/src/backend/cliargs.rs b/src/backend/cliargs.rs deleted file mode 100644 index d3a4652..0000000 --- a/src/backend/cliargs.rs +++ /dev/null @@ -1,89 +0,0 @@ -// 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 std::path::PathBuf; - -use sarge::prelude::*; - -sarge! { - // Name of the struct - ArgumentsCLI, - - // Show help and exit. - 'h' help: bool, - - // Show version and exit. - 'v' version: bool, -} - -// struct for CLIArgs -pub struct CLIArgs { - pub helparg: bool, - pub versionarg: bool, - pub bibfilearg: PathBuf, -} - -impl CLIArgs { - pub fn new() -> Self { - let (cli_args, pos_args) = ArgumentsCLI::parse().expect("Could not parse CLI arguments"); - let bibfilearg = if pos_args.len() > 1 { - PathBuf::from(&pos_args[1]) - // pos_args[1].to_string() - } else { - PathBuf::new() - }; - Self { - helparg: cli_args.help, - versionarg: cli_args.version, - bibfilearg, - } - } -} - -pub fn help_func() -> String { - let help = format!( - "\ -{} {} - -USAGE: - bibiman [FLAGS] [file] - -POSITIONAL ARGS: - Path to .bib file - -FLAGS: - -h, --help Show this help and exit - -v, --version Show the version and exit", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION"), - ); - help -} - -pub fn version_func() -> String { - let version = format!( - "\ -{} {} -{} -{}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION"), - env!("CARGO_PKG_AUTHORS"), - env!("CARGO_PKG_LICENSE") - ); - version -} diff --git a/src/backend/search.rs b/src/backend/search.rs deleted file mode 100644 index 65d97d7..0000000 --- a/src/backend/search.rs +++ /dev/null @@ -1,137 +0,0 @@ -use nucleo_matcher::{ - pattern::{CaseMatching, Normalization, Pattern}, - Config, Matcher, -}; -use std::collections::HashMap; - -use crate::frontend::entries::EntryTableItem; - -#[derive(Debug)] -pub struct BibiSearch { - pub search_string: String, // Search string show in footer, used for search - pub inner_search: bool, // True, if we trigger a search for already filtered list - pub filtered_tag_list: Vec, -} - -impl Default for BibiSearch { - fn default() -> Self { - Self { - search_string: String::new(), - inner_search: false, - filtered_tag_list: Vec::new(), - } - } -} - -impl BibiSearch { - // Stringify EntryTableItem by joining/concat - fn convert_to_string(inner_vec: &EntryTableItem) -> String { - let entry_table_item_str = { - format!( - "{} {} {} {} {} {}", - &inner_vec.authors, - &inner_vec.title, - &inner_vec.year, - &inner_vec.pubtype, - &inner_vec.keywords, - &inner_vec.citekey - ) - }; - entry_table_item_str - } - - // Return a filtered entry list - pub fn search_entry_list( - search_pattern: &str, - orig_list: Vec, - ) -> Vec { - // Create a hashmap to connect stingified entry with entry vec - let mut entry_string_hm: HashMap = HashMap::new(); - - // Convert all entries to string and insert them into the hashmap - // next to the original inner Vec of the entry list - for entry in orig_list { - entry_string_hm.insert(Self::convert_to_string(&entry), entry); - } - - // Set up matcher (TODO: One time needed only, move to higher level) - let mut matcher = Matcher::new(Config::DEFAULT); - - // Filter the stringified entries and collect them into a vec - let filtered_matches: Vec = { - let matches = - Pattern::parse(search_pattern, CaseMatching::Ignore, Normalization::Smart) - .match_list(entry_string_hm.keys(), &mut matcher); - matches.into_iter().map(|f| f.0.to_string()).collect() - }; - - // Create filtered entry list and push the inner entry vec's to it - // Use the filtered stringified hm-key as index - let mut filtered_list: Vec = Vec::new(); - for m in filtered_matches { - filtered_list.push(entry_string_hm[&m].to_owned()); - } - filtered_list.sort(); - filtered_list - } - - pub fn search_tag_list(search_pattern: &str, orig_list: Vec) -> Vec { - // Set up matcher (TODO: One time needed only) - let mut matcher = Matcher::new(Config::DEFAULT); - - // Filter the list items by search pattern - let filtered_matches: Vec = { - let matches = - Pattern::parse(search_pattern, CaseMatching::Ignore, Normalization::Smart) - .match_list(orig_list, &mut matcher); - matches.into_iter().map(|f| f.0.to_string()).collect() - }; - filtered_matches - } - - pub fn filter_entries_by_tag( - keyword: &str, - orig_list: &Vec, - ) -> Vec { - let mut filtered_list: Vec = Vec::new(); - - // Loop over the whole given entry table - // Check if the selected keyword is present in the current entry - // If present, push the entry to the filtered list - for e in orig_list { - if e.keywords.contains(keyword) { - filtered_list.push(e.to_owned()); - } - } - - filtered_list - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_vector_join() { - let bibvec: EntryTableItem = EntryTableItem { - authors: "Author".to_string(), - short_author: "".to_string(), - title: "Title".to_string(), - year: "1999".to_string(), - pubtype: "article".to_string(), - keywords: "hello, bye".to_string(), - citekey: "author_1999".to_string(), - abstract_text: "An abstract with multiple sentences. Here is the second".to_string(), - doi_url: "https://www.bibiman.org".to_string(), - filepath: "/home/file/path.pdf".to_string(), - }; - - let joined_vec = BibiSearch::convert_to_string(&bibvec); - - assert_eq!( - joined_vec, - "Author Title 1999 article hello, bye author_1999" - ) - } -} diff --git a/src/bib.rs b/src/bib.rs new file mode 100644 index 0000000..8443b9a --- /dev/null +++ b/src/bib.rs @@ -0,0 +1,21 @@ +// 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 . +///// + +pub mod bibmain; +pub mod entries; +pub mod keywords; +pub mod search; diff --git a/src/bib/bibmain.rs b/src/bib/bibmain.rs new file mode 100644 index 0000000..a7df951 --- /dev/null +++ b/src/bib/bibmain.rs @@ -0,0 +1,279 @@ +// 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 biblatex::{self, Bibliography}; +use biblatex::{ChunksExt, Type}; +use itertools::Itertools; +use std::{fs, path::PathBuf}; + +#[derive(Debug)] +pub enum FileFormat { + BibLatex, + Hayagriva, +} + +// Set necessary fields +// TODO: can surely be made more efficient/simpler +#[derive(Debug)] +pub struct BibiMain { + pub bibfile: PathBuf, // path to bibfile + pub bibfile_format: FileFormat, // Format of passed file + pub bibfilestring: String, // content of bibfile as string + pub bibliography: Bibliography, // parsed bibliography + pub citekeys: Vec, // list of all citekeys + pub keyword_list: Vec, // list of all available keywords + pub entry_list: Vec, // List of all entries +} + +#[derive(Debug, Clone)] +pub struct BibiData { + pub authors: String, + pub title: String, + pub year: String, + pub pubtype: String, + pub keywords: String, + pub citekey: String, + pub abstract_text: String, + pub doi_url: String, + pub filepath: String, +} + +impl BibiMain { + pub fn new(main_bibfile: PathBuf) -> Self { + // TODO: Needs check for config file path as soon as config file is impl + let bibfile_format = Self::check_file_format(&main_bibfile); + let bibfile = main_bibfile; + let bibfilestring = fs::read_to_string(&bibfile).unwrap(); + let bibliography = biblatex::Bibliography::parse(&bibfilestring).unwrap(); + let citekeys = Self::get_citekeys(&bibliography); + let keyword_list = Self::collect_tag_list(&citekeys, &bibliography); + let entry_list = Self::create_entry_list(&citekeys, &bibliography); + Self { + bibfile, + bibfile_format, + bibfilestring, + bibliography, + citekeys, + keyword_list, + entry_list, + } + } + + // Check which file format the passed file has + fn check_file_format(main_bibfile: &PathBuf) -> FileFormat { + let extension = main_bibfile.extension().unwrap().to_str(); + + match extension { + Some("yml") => FileFormat::Hayagriva, + Some("yaml") => FileFormat::Hayagriva, + Some("bib") => FileFormat::BibLatex, + Some(_) => panic!("The extension {:?} is no valid bibfile", extension.unwrap()), + None => panic!("The given path {:?} holds no valid file", main_bibfile), + } + } + + fn create_entry_list(citekeys: &[String], bibliography: &Bibliography) -> Vec { + citekeys + .into_iter() + .map(|k| BibiData { + authors: Self::get_authors(&k, &bibliography), + title: Self::get_title(&k, &bibliography), + year: Self::get_year(&k, &bibliography), + pubtype: Self::get_pubtype(&k, &bibliography), + keywords: Self::get_keywords(&k, &bibliography), + citekey: k.to_owned(), + abstract_text: Self::get_abstract(&k, &bibliography), + doi_url: Self::get_weblink(&k, &bibliography), + filepath: Self::get_filepath(&k, &bibliography), + }) + .collect() + } + + // get list of citekeys from the given bibfile + // this list is the base for further operations on the bibentries + // since it is the entry point of the biblatex crate. + pub fn get_citekeys(bibstring: &Bibliography) -> Vec { + let citekeys: Vec = bibstring.keys().map(|k| k.to_owned()).collect(); + citekeys + } + + // collect all keywords present in the bibliography + // sort them and remove duplicates + // this list is for fast filtering entries by topics/keyowrds + pub fn collect_tag_list(citekeys: &[String], biblio: &Bibliography) -> Vec { + // Initialize vector collecting all keywords + let mut keyword_list = vec![]; + + // Loop over entries and collect all keywords + for i in citekeys { + if biblio.get(&i).unwrap().keywords().is_ok() { + let items = biblio + .get(&i) + .unwrap() + .keywords() + .unwrap() + .format_verbatim(); + // Split keyword string into slices, trim leading and trailing + // whitespaces, remove empty slices, and collect them + let mut key_vec: Vec = items + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + // Append keywords to vector + keyword_list.append(&mut key_vec); + } + } + + // Sort the vector and remove duplicates + keyword_list.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); + keyword_list.dedup(); + keyword_list + } + + pub fn get_authors(citekey: &str, biblio: &Bibliography) -> String { + if biblio.get(&citekey).unwrap().author().is_ok() { + let authors = biblio.get(&citekey).unwrap().author().unwrap(); + if authors.len() > 1 { + let all_authors = authors.iter().map(|a| &a.name).join(", "); + all_authors + } else if authors.len() == 1 { + let authors = authors[0].name.to_string(); + authors + } else { + let editors_authors = format!("empty"); + editors_authors + } + } else { + if !biblio.get(&citekey).unwrap().editors().unwrap().is_empty() { + let editors = biblio.get(&citekey).unwrap().editors().unwrap(); + if editors[0].0.len() > 1 { + // let editors = format!("{} (ed.) et al.", editors[0].0[0].name); + let mut editors = editors[0].0.iter().map(|e| &e.name).join(", "); + editors.push_str(" (ed.)"); + editors + } else if editors[0].0.len() == 1 { + let editors = format!("{} (ed.)", editors[0].0[0].name); + editors + } else { + let editors_authors = format!("empty"); + editors_authors + } + } else { + let editors_authors = format!("empty"); + editors_authors + } + } + } + + pub fn get_title(citekey: &str, biblio: &Bibliography) -> String { + let title = { + if biblio.get(&citekey).unwrap().title().is_ok() { + let title = biblio + .get(&citekey) + .unwrap() + .title() + .unwrap() + .format_verbatim(); + title + } else { + let title = format!("no title"); + title + } + }; + title + } + + pub fn get_year(citekey: &str, biblio: &Bibliography) -> String { + let year = biblio.get(&citekey).unwrap(); + let year = { + if year.date().is_ok() { + let year = year.date().unwrap().to_chunks().format_verbatim(); + let year = year[..4].to_string(); + year + } else { + let year = format!("n.d."); + year + } + }; + year + } + + pub fn get_pubtype(citekey: &str, biblio: &Bibliography) -> String { + let pubtype = biblio.get(&citekey).unwrap().entry_type.to_string(); + pubtype + } + + pub fn get_keywords(citekey: &str, biblio: &Bibliography) -> String { + let keywords = { + if biblio.get(&citekey).unwrap().keywords().is_ok() { + let keywords = biblio + .get(&citekey) + .unwrap() + .keywords() + .unwrap() + .format_verbatim(); + keywords + } else { + let keywords = String::from(""); + keywords + } + }; + keywords + } + + pub fn get_abstract(citekey: &str, biblio: &Bibliography) -> String { + let text = { + if biblio.get(&citekey).unwrap().abstract_().is_ok() { + let abstract_text = biblio + .get(&citekey) + .unwrap() + .abstract_() + .unwrap() + .format_verbatim(); + abstract_text + } else { + let abstract_text = format!("No abstract"); + abstract_text + } + }; + text + } + + pub fn get_weblink(citekey: &str, biblio: &Bibliography) -> String { + if let true = biblio.get(&citekey).unwrap().doi().is_ok() { + let url = biblio.get(&citekey).unwrap().doi().unwrap(); + url + } else if let true = biblio.get(&citekey).unwrap().url().is_ok() { + let url = biblio.get(&citekey).unwrap().url().unwrap(); + url + } else { + let url = "".to_string(); + url + } + } + + pub fn get_filepath(citekey: &str, biblio: &Bibliography) -> String { + if let true = biblio.get(&citekey).unwrap().file().is_ok() { + let file = biblio.get(&citekey).unwrap().file().unwrap(); + file + } else { + let file = "".to_string(); + file + } + } +} diff --git a/src/bib/entries.rs b/src/bib/entries.rs new file mode 100644 index 0000000..41edba8 --- /dev/null +++ b/src/bib/entries.rs @@ -0,0 +1,258 @@ +// 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::bib::bibmain::BibiData; +use ratatui::widgets::{ScrollbarState, TableState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryTableColumn { + Authors, + Title, + Year, + Pubtype, +} + +// Define list containing entries as table +#[derive(Debug, PartialEq, Eq)] +pub struct EntryTable { + pub entry_table_items: Vec, + pub entry_table_at_search_start: Vec, + pub entry_table_selected_column: EntryTableColumn, + pub entry_table_sorted_by_col: EntryTableColumn, + pub entry_table_reversed_sort: bool, + pub entry_table_state: TableState, + pub entry_scroll_state: ScrollbarState, + pub entry_info_scroll: u16, + pub entry_info_scroll_state: ScrollbarState, +} + +impl EntryTable { + pub fn new(entry_list: Vec) -> Self { + let entry_table_items = Self::set_entry_table(entry_list); + let entry_table_state = TableState::default().with_selected(0); + let entry_scroll_state = ScrollbarState::new(entry_table_items.len()); + let entry_info_scroll_state = ScrollbarState::default(); + Self { + entry_table_items, + entry_table_at_search_start: Vec::new(), + entry_table_selected_column: EntryTableColumn::Authors, + entry_table_sorted_by_col: EntryTableColumn::Authors, + entry_table_reversed_sort: false, + entry_table_state, + entry_scroll_state, + entry_info_scroll: 0, + entry_info_scroll_state, + } + } + + pub fn set_entry_table(entry_list: Vec) -> Vec { + let mut entry_table: Vec = entry_list + .into_iter() + .map(|e| EntryTableItem { + authors: e.authors, + short_author: String::new(), + title: e.title, + year: e.year, + pubtype: e.pubtype, + keywords: e.keywords, + citekey: e.citekey, + abstract_text: e.abstract_text, + doi_url: e.doi_url, + filepath: e.filepath, + }) + .collect(); + + entry_table.sort_by(|a, b| a.authors.to_lowercase().cmp(&b.authors.to_lowercase())); + entry_table + } + + // Sort entry table by specific column. + // Toggle sorting by hitting same key again + pub fn sort_entry_table(&mut self, toggle: bool) { + if toggle { + self.entry_table_reversed_sort = !self.entry_table_reversed_sort; + } + if self.entry_table_selected_column != self.entry_table_sorted_by_col { + self.entry_table_reversed_sort = false + } + self.entry_table_sorted_by_col = self.entry_table_selected_column.clone(); + if self.entry_table_reversed_sort { + match self.entry_table_selected_column { + EntryTableColumn::Authors => self + .entry_table_items + .sort_by(|a, b| b.authors.to_lowercase().cmp(&a.authors.to_lowercase())), + EntryTableColumn::Title => self + .entry_table_items + .sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())), + EntryTableColumn::Year => self + .entry_table_items + .sort_by(|a, b| b.year.to_lowercase().cmp(&a.year.to_lowercase())), + EntryTableColumn::Pubtype => self + .entry_table_items + .sort_by(|a, b| b.pubtype.to_lowercase().cmp(&a.pubtype.to_lowercase())), + } + } else if !self.entry_table_reversed_sort { + match self.entry_table_selected_column { + EntryTableColumn::Authors => self + .entry_table_items + .sort_by(|a, b| a.authors.to_lowercase().cmp(&b.authors.to_lowercase())), + EntryTableColumn::Title => self + .entry_table_items + .sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())), + EntryTableColumn::Year => self + .entry_table_items + .sort_by(|a, b| a.year.to_lowercase().cmp(&b.year.to_lowercase())), + EntryTableColumn::Pubtype => self + .entry_table_items + .sort_by(|a, b| a.pubtype.to_lowercase().cmp(&b.pubtype.to_lowercase())), + } + } + } +} + +// Define contents of each entry table row +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct EntryTableItem { + pub authors: String, + pub short_author: String, + pub title: String, + pub year: String, + pub pubtype: String, + pub keywords: String, + pub citekey: String, + pub abstract_text: String, + pub doi_url: String, + pub filepath: String, +} + +impl EntryTableItem { + // This functions decides which fields are rendered in the entry table + // Fields which should be usable but not visible can be left out + pub fn ref_vec(&mut self) -> Vec<&str> { + self.short_author = match self.authors.split_once(",") { + Some((first, _rest)) => { + if self.authors.contains("(ed.)") { + let first_author = format!("{} et al. (ed.)", first); + first_author + } else { + let first_author = format!("{} et al.", first); + first_author + } + } + None => String::from(""), + }; + + vec![ + { + if self.short_author.is_empty() { + &self.authors + } else { + &self.short_author + } + }, + &self.title, + &self.year, + &self.pubtype, + ] + } + + pub fn authors(&self) -> &str { + &self.authors + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn year(&self) -> &str { + &self.year + } + + pub fn pubtype(&self) -> &str { + &self.pubtype + } + + pub fn citekey(&self) -> &str { + &self.citekey + } + + pub fn doi_url(&self) -> &str { + &self.doi_url + } + + pub fn filepath(&self) -> &str { + &self.filepath + } +} + +#[cfg(test)] +mod tests { + use super::EntryTableItem; + + #[test] + fn check_os() { + let os = std::env::consts::OS; + assert_eq!( + os, + "linux", + "You're not coding on linux, but on {}... Switch to linux, now!", + std::env::consts::OS + ) + } + + #[test] + fn shorten_authors() { + let mut entry: EntryTableItem = EntryTableItem { + authors: "Miller, Schmitz, Bernard".to_string(), + short_author: "".to_string(), + title: "A title".to_string(), + year: "2000".to_string(), + pubtype: "article".to_string(), + keywords: "key1, key2".to_string(), + citekey: "miller_2000".to_string(), + abstract_text: "An abstract".to_string(), + doi_url: "www.text.org".to_string(), + filepath: "/home/test".to_string(), + }; + + let entry_vec = EntryTableItem::ref_vec(&mut entry); + + let mut entry_editors: EntryTableItem = EntryTableItem { + authors: "Miller, Schmitz, Bernard (ed.)".to_string(), + short_author: "".to_string(), + title: "A title".to_string(), + year: "2000".to_string(), + pubtype: "article".to_string(), + keywords: "key1, key2".to_string(), + citekey: "miller_2000".to_string(), + abstract_text: "An abstract".to_string(), + doi_url: "www.text.org".to_string(), + filepath: "/home/test".to_string(), + }; + + let entry_vec_editors = EntryTableItem::ref_vec(&mut entry_editors); + + assert_eq!( + entry_vec, + vec!["Miller et al.", "A title", "2000", "article"] + ); + assert_eq!( + entry_vec_editors, + vec!["Miller et al. (ed.)", "A title", "2000", "article"] + ) + } +} diff --git a/src/bib/keywords.rs b/src/bib/keywords.rs new file mode 100644 index 0000000..2668323 --- /dev/null +++ b/src/bib/keywords.rs @@ -0,0 +1,55 @@ +// 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 ratatui::widgets::{ListState, ScrollbarState}; + +#[derive(Debug)] +pub struct TagList { + pub tag_list_items: Vec, + pub tag_list_state: ListState, + pub tag_scroll_state: ScrollbarState, + pub selected_keywords: Vec, +} + +// Structure of the list items. +#[derive(Debug)] +pub struct TagListItem { + pub keyword: String, +} + +// Function to process inputed characters and convert them (to string, or more complex function) +impl TagListItem { + pub fn new(info: &str) -> Self { + Self { + keyword: info.to_string(), + } + } +} + +impl TagList { + pub fn new(keyword_list: Vec) -> Self { + let tag_list_items = keyword_list; + let tag_list_state = ListState::default(); // for preselection: .with_selected(Some(0)); + let tag_scroll_state = ScrollbarState::new(tag_list_items.len()); + Self { + tag_list_items, + tag_list_state, + tag_scroll_state, + selected_keywords: Vec::new(), + } + } +} diff --git a/src/bib/search.rs b/src/bib/search.rs new file mode 100644 index 0000000..f6e8d14 --- /dev/null +++ b/src/bib/search.rs @@ -0,0 +1,136 @@ +use super::entries::EntryTableItem; +use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, +}; +use std::collections::HashMap; + +#[derive(Debug)] +pub struct BibiSearch { + pub search_string: String, // Search string show in footer, used for search + pub inner_search: bool, // True, if we trigger a search for already filtered list + pub filtered_tag_list: Vec, +} + +impl Default for BibiSearch { + fn default() -> Self { + Self { + search_string: String::new(), + inner_search: false, + filtered_tag_list: Vec::new(), + } + } +} + +impl BibiSearch { + // Stringify EntryTableItem by joining/concat + fn convert_to_string(inner_vec: &EntryTableItem) -> String { + let entry_table_item_str = { + format!( + "{} {} {} {} {} {}", + &inner_vec.authors, + &inner_vec.title, + &inner_vec.year, + &inner_vec.pubtype, + &inner_vec.keywords, + &inner_vec.citekey + ) + }; + entry_table_item_str + } + + // Return a filtered entry list + pub fn search_entry_list( + search_pattern: &str, + orig_list: Vec, + ) -> Vec { + // Create a hashmap to connect stingified entry with entry vec + let mut entry_string_hm: HashMap = HashMap::new(); + + // Convert all entries to string and insert them into the hashmap + // next to the original inner Vec of the entry list + for entry in orig_list { + entry_string_hm.insert(Self::convert_to_string(&entry), entry); + } + + // Set up matcher (TODO: One time needed only, move to higher level) + let mut matcher = Matcher::new(Config::DEFAULT); + + // Filter the stringified entries and collect them into a vec + let filtered_matches: Vec = { + let matches = + Pattern::parse(search_pattern, CaseMatching::Ignore, Normalization::Smart) + .match_list(entry_string_hm.keys(), &mut matcher); + matches.into_iter().map(|f| f.0.to_string()).collect() + }; + + // Create filtered entry list and push the inner entry vec's to it + // Use the filtered stringified hm-key as index + let mut filtered_list: Vec = Vec::new(); + for m in filtered_matches { + filtered_list.push(entry_string_hm[&m].to_owned()); + } + filtered_list.sort(); + filtered_list + } + + pub fn search_tag_list(search_pattern: &str, orig_list: Vec) -> Vec { + // Set up matcher (TODO: One time needed only) + let mut matcher = Matcher::new(Config::DEFAULT); + + // Filter the list items by search pattern + let filtered_matches: Vec = { + let matches = + Pattern::parse(search_pattern, CaseMatching::Ignore, Normalization::Smart) + .match_list(orig_list, &mut matcher); + matches.into_iter().map(|f| f.0.to_string()).collect() + }; + filtered_matches + } + + pub fn filter_entries_by_tag( + keyword: &str, + orig_list: &Vec, + ) -> Vec { + let mut filtered_list: Vec = Vec::new(); + + // Loop over the whole given entry table + // Check if the selected keyword is present in the current entry + // If present, push the entry to the filtered list + for e in orig_list { + if e.keywords.contains(keyword) { + filtered_list.push(e.to_owned()); + } + } + + filtered_list + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vector_join() { + let bibvec: EntryTableItem = EntryTableItem { + authors: "Author".to_string(), + short_author: "".to_string(), + title: "Title".to_string(), + year: "1999".to_string(), + pubtype: "article".to_string(), + keywords: "hello, bye".to_string(), + citekey: "author_1999".to_string(), + abstract_text: "An abstract with multiple sentences. Here is the second".to_string(), + doi_url: "https://www.bibiman.org".to_string(), + filepath: "/home/file/path.pdf".to_string(), + }; + + let joined_vec = BibiSearch::convert_to_string(&bibvec); + + assert_eq!( + joined_vec, + "Author Title 1999 article hello, bye author_1999" + ) + } +} diff --git a/src/cliargs.rs b/src/cliargs.rs new file mode 100644 index 0000000..d3a4652 --- /dev/null +++ b/src/cliargs.rs @@ -0,0 +1,89 @@ +// 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 std::path::PathBuf; + +use sarge::prelude::*; + +sarge! { + // Name of the struct + ArgumentsCLI, + + // Show help and exit. + 'h' help: bool, + + // Show version and exit. + 'v' version: bool, +} + +// struct for CLIArgs +pub struct CLIArgs { + pub helparg: bool, + pub versionarg: bool, + pub bibfilearg: PathBuf, +} + +impl CLIArgs { + pub fn new() -> Self { + let (cli_args, pos_args) = ArgumentsCLI::parse().expect("Could not parse CLI arguments"); + let bibfilearg = if pos_args.len() > 1 { + PathBuf::from(&pos_args[1]) + // pos_args[1].to_string() + } else { + PathBuf::new() + }; + Self { + helparg: cli_args.help, + versionarg: cli_args.version, + bibfilearg, + } + } +} + +pub fn help_func() -> String { + let help = format!( + "\ +{} {} + +USAGE: + bibiman [FLAGS] [file] + +POSITIONAL ARGS: + Path to .bib file + +FLAGS: + -h, --help Show this help and exit + -v, --version Show the version and exit", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + help +} + +pub fn version_func() -> String { + let version = format!( + "\ +{} {} +{} +{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_AUTHORS"), + env!("CARGO_PKG_LICENSE") + ); + version +} diff --git a/src/frontend.rs b/src/frontend.rs deleted file mode 100644 index dc16cb5..0000000 --- a/src/frontend.rs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 . -///// - -pub mod app; -pub mod entries; -pub mod handler; -pub mod keywords; -pub mod tui; -pub mod ui; diff --git a/src/frontend/app.rs b/src/frontend/app.rs deleted file mode 100644 index 822c6f0..0000000 --- a/src/frontend/app.rs +++ /dev/null @@ -1,259 +0,0 @@ -// 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 super::tui; -use crate::backend::cliargs::CLIArgs; -use crate::backend::{bib::*, search::BibiSearch}; -use crate::{ - frontend::entries::EntryTable, frontend::handler::handle_key_events, - frontend::keywords::TagList, frontend::tui::Event, -}; -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, -} - -impl App { - // Constructs a new instance of [`App`]. - pub fn new(args: CLIArgs) -> Result { - // 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/frontend/entries.rs b/src/frontend/entries.rs deleted file mode 100644 index 7883a17..0000000 --- a/src/frontend/entries.rs +++ /dev/null @@ -1,499 +0,0 @@ -// 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 super::app::App; -use super::tui::Tui; -use crate::backend::{bib::BibiData, search::BibiSearch}; -use color_eyre::eyre::{Context, Ok, Result}; -use core::panic; -use editor_command::EditorBuilder; -use ratatui::widgets::{ScrollbarState, TableState}; -use std::process::{Command, Stdio}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EntryTableColumn { - Authors, - Title, - Year, - Pubtype, -} - -// Define list containing entries as table -#[derive(Debug, PartialEq, Eq)] -pub struct EntryTable { - pub entry_table_items: Vec, - pub entry_table_at_search_start: Vec, - pub entry_table_selected_column: EntryTableColumn, - pub entry_table_sorted_by_col: EntryTableColumn, - pub entry_table_reversed_sort: bool, - pub entry_table_state: TableState, - pub entry_scroll_state: ScrollbarState, - pub entry_info_scroll: u16, - pub entry_info_scroll_state: ScrollbarState, -} - -impl EntryTable { - pub fn new(entry_list: Vec) -> Self { - let entry_table_items = Self::set_entry_table(entry_list); - let entry_table_state = TableState::default().with_selected(0); - let entry_scroll_state = ScrollbarState::new(entry_table_items.len()); - let entry_info_scroll_state = ScrollbarState::default(); - Self { - entry_table_items, - entry_table_at_search_start: Vec::new(), - entry_table_selected_column: EntryTableColumn::Authors, - entry_table_sorted_by_col: EntryTableColumn::Authors, - entry_table_reversed_sort: false, - entry_table_state, - entry_scroll_state, - entry_info_scroll: 0, - entry_info_scroll_state, - } - } - - pub fn set_entry_table(entry_list: Vec) -> Vec { - let mut entry_table: Vec = entry_list - .into_iter() - .map(|e| EntryTableItem { - authors: e.authors, - short_author: String::new(), - title: e.title, - year: e.year, - pubtype: e.pubtype, - keywords: e.keywords, - citekey: e.citekey, - abstract_text: e.abstract_text, - doi_url: e.doi_url, - filepath: e.filepath, - }) - .collect(); - - entry_table.sort_by(|a, b| a.authors.to_lowercase().cmp(&b.authors.to_lowercase())); - entry_table - } - - // Sort entry table by specific column. - // Toggle sorting by hitting same key again - pub fn sort_entry_table(&mut self, toggle: bool) { - if toggle { - self.entry_table_reversed_sort = !self.entry_table_reversed_sort; - } - if self.entry_table_selected_column != self.entry_table_sorted_by_col { - self.entry_table_reversed_sort = false - } - self.entry_table_sorted_by_col = self.entry_table_selected_column.clone(); - if self.entry_table_reversed_sort { - match self.entry_table_selected_column { - EntryTableColumn::Authors => self - .entry_table_items - .sort_by(|a, b| b.authors.to_lowercase().cmp(&a.authors.to_lowercase())), - EntryTableColumn::Title => self - .entry_table_items - .sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())), - EntryTableColumn::Year => self - .entry_table_items - .sort_by(|a, b| b.year.to_lowercase().cmp(&a.year.to_lowercase())), - EntryTableColumn::Pubtype => self - .entry_table_items - .sort_by(|a, b| b.pubtype.to_lowercase().cmp(&a.pubtype.to_lowercase())), - } - } else if !self.entry_table_reversed_sort { - match self.entry_table_selected_column { - EntryTableColumn::Authors => self - .entry_table_items - .sort_by(|a, b| a.authors.to_lowercase().cmp(&b.authors.to_lowercase())), - EntryTableColumn::Title => self - .entry_table_items - .sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())), - EntryTableColumn::Year => self - .entry_table_items - .sort_by(|a, b| a.year.to_lowercase().cmp(&b.year.to_lowercase())), - EntryTableColumn::Pubtype => self - .entry_table_items - .sort_by(|a, b| a.pubtype.to_lowercase().cmp(&b.pubtype.to_lowercase())), - } - } - } -} - -// Define contents of each entry table row -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct EntryTableItem { - pub authors: String, - pub short_author: String, - pub title: String, - pub year: String, - pub pubtype: String, - pub keywords: String, - pub citekey: String, - pub abstract_text: String, - pub doi_url: String, - pub filepath: String, -} - -impl EntryTableItem { - // This functions decides which fields are rendered in the entry table - // Fields which should be usable but not visible can be left out - pub fn ref_vec(&mut self) -> Vec<&str> { - self.short_author = match self.authors.split_once(",") { - Some((first, _rest)) => { - if self.authors.contains("(ed.)") { - let first_author = format!("{} et al. (ed.)", first); - first_author - } else { - let first_author = format!("{} et al.", first); - first_author - } - } - None => String::from(""), - }; - - vec![ - { - if self.short_author.is_empty() { - &self.authors - } else { - &self.short_author - } - }, - &self.title, - &self.year, - &self.pubtype, - ] - } - - pub fn authors(&self) -> &str { - &self.authors - } - - pub fn title(&self) -> &str { - &self.title - } - - pub fn year(&self) -> &str { - &self.year - } - - pub fn pubtype(&self) -> &str { - &self.pubtype - } - - pub fn citekey(&self) -> &str { - &self.citekey - } - - pub fn doi_url(&self) -> &str { - &self.doi_url - } - - pub fn filepath(&self) -> &str { - &self.filepath - } -} - -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 = 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(()) - } -} - -#[cfg(test)] -mod tests { - use super::EntryTableItem; - - #[test] - fn check_os() { - let os = std::env::consts::OS; - assert_eq!( - os, - "linux", - "You're not coding on linux, but on {}... Switch to linux, now!", - std::env::consts::OS - ) - } - - #[test] - fn shorten_authors() { - let mut entry: EntryTableItem = EntryTableItem { - authors: "Miller, Schmitz, Bernard".to_string(), - short_author: "".to_string(), - title: "A title".to_string(), - year: "2000".to_string(), - pubtype: "article".to_string(), - keywords: "key1, key2".to_string(), - citekey: "miller_2000".to_string(), - abstract_text: "An abstract".to_string(), - doi_url: "www.text.org".to_string(), - filepath: "/home/test".to_string(), - }; - - let entry_vec = EntryTableItem::ref_vec(&mut entry); - - let mut entry_editors: EntryTableItem = EntryTableItem { - authors: "Miller, Schmitz, Bernard (ed.)".to_string(), - short_author: "".to_string(), - title: "A title".to_string(), - year: "2000".to_string(), - pubtype: "article".to_string(), - keywords: "key1, key2".to_string(), - citekey: "miller_2000".to_string(), - abstract_text: "An abstract".to_string(), - doi_url: "www.text.org".to_string(), - filepath: "/home/test".to_string(), - }; - - let entry_vec_editors = EntryTableItem::ref_vec(&mut entry_editors); - - assert_eq!( - entry_vec, - vec!["Miller et al.", "A title", "2000", "article"] - ); - assert_eq!( - entry_vec_editors, - vec!["Miller et al. (ed.)", "A title", "2000", "article"] - ) - } -} diff --git a/src/frontend/handler.rs b/src/frontend/handler.rs deleted file mode 100644 index 39ec7a2..0000000 --- a/src/frontend/handler.rs +++ /dev/null @@ -1,210 +0,0 @@ -// 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::frontend::app::App; -use crate::frontend::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/frontend/keywords.rs b/src/frontend/keywords.rs deleted file mode 100644 index 5605a59..0000000 --- a/src/frontend/keywords.rs +++ /dev/null @@ -1,159 +0,0 @@ -// 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 super::app::{App, FormerArea}; -use crate::backend::search::BibiSearch; -use ratatui::widgets::{ListState, ScrollbarState}; - -#[derive(Debug)] -pub struct TagList { - pub tag_list_items: Vec, - pub tag_list_state: ListState, - pub tag_scroll_state: ScrollbarState, - pub selected_keywords: Vec, -} - -// Structure of the list items. -#[derive(Debug)] -pub struct TagListItem { - pub keyword: String, -} - -// Function to process inputed characters and convert them (to string, or more complex function) -impl TagListItem { - pub fn new(info: &str) -> Self { - Self { - keyword: info.to_string(), - } - } -} - -impl TagList { - pub fn new(keyword_list: Vec) -> Self { - let tag_list_items = keyword_list; - let tag_list_state = ListState::default(); // for preselection: .with_selected(Some(0)); - let tag_scroll_state = ScrollbarState::new(tag_list_items.len()); - Self { - tag_list_items, - tag_list_state, - tag_scroll_state, - selected_keywords: Vec::new(), - } - } -} - -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 = 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 = 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/frontend/tui.rs b/src/frontend/tui.rs deleted file mode 100644 index e3c9c1a..0000000 --- a/src/frontend/tui.rs +++ /dev/null @@ -1,223 +0,0 @@ -// 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::frontend::app::App; -use crossterm::{ - cursor, - event::{ - DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, KeyEvent, MouseEvent, - }, - terminal::{EnterAlternateScreen, LeaveAlternateScreen}, -}; -// use ratatui::backend::{Backend, CrosstermBackend}; -use color_eyre::eyre::{OptionExt, Result}; -use futures::{FutureExt, StreamExt}; -use ratatui::backend::CrosstermBackend as Backend; -use std::io::{stdout, Stdout}; -use std::panic; -use std::{ - ops::{Deref, DerefMut}, - time::Duration, -}; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; - -// Terminal events. -#[derive(Clone, Copy, Debug)] -pub enum Event { - /// Terminal tick. - Tick, - /// Key press. - Key(KeyEvent), - /// Mouse click/scroll. - Mouse(MouseEvent), - /// Terminal resize. - Resize(u16, u16), -} - -#[derive(Debug)] -pub struct Tui { - /// Interface to the Terminal. - pub terminal: ratatui::Terminal>, - /// Event sender channel. - sender: mpsc::UnboundedSender, - /// Event receiver channel. - receiver: mpsc::UnboundedReceiver, - /// Event handler thread. - handler: tokio::task::JoinHandle<()>, - cancellation_token: CancellationToken, -} - -impl Tui { - // Constructs a new instance of [`Tui`]. - pub fn new() -> Result { - let terminal = ratatui::Terminal::new(Backend::new(stdout()))?; - let (sender, receiver) = mpsc::unbounded_channel(); - let handler = tokio::spawn(async {}); - let cancellation_token = CancellationToken::new(); - Ok(Self { - terminal, - sender, - receiver, - handler, - cancellation_token, - }) - } - - pub fn start(&mut self) { - let tick_rate = Duration::from_millis(1000); - self.cancel(); - self.cancellation_token = CancellationToken::new(); - let event_loop = Self::event_loop( - self.sender.clone(), - self.cancellation_token.clone(), - tick_rate, - ); - // let _cancellation_token = self.cancellation_token.clone(); - // let _sender = self.sender.clone(); - self.handler = tokio::spawn(async { - event_loop.await; - }); - } - - async fn event_loop( - sender: mpsc::UnboundedSender, - cancellation_token: CancellationToken, - tick_rate: Duration, - ) { - let mut reader = crossterm::event::EventStream::new(); - let mut tick = tokio::time::interval(tick_rate); - loop { - let tick_delay = tick.tick(); - let crossterm_event = reader.next().fuse(); - tokio::select! { - // _ = sender.closed() => { - // break; - // } - _ = cancellation_token.cancelled() => { - break; - } - Some(Ok(evt)) = crossterm_event => { - match evt { - CrosstermEvent::Key(key) => { - if key.kind == crossterm::event::KeyEventKind::Press { - sender.send(Event::Key(key)).unwrap(); - } - }, - CrosstermEvent::Mouse(mouse) => { - sender.send(Event::Mouse(mouse)).unwrap(); - }, - CrosstermEvent::Resize(x, y) => { - sender.send(Event::Resize(x, y)).unwrap(); - }, - CrosstermEvent::FocusLost => { - }, - CrosstermEvent::FocusGained => { - }, - CrosstermEvent::Paste(_) => { - }, - } - } - _ = tick_delay => { - sender.send(Event::Tick).unwrap(); - } - }; - } - cancellation_token.cancel(); - } - - pub fn enter(&mut self) -> Result<()> { - crossterm::terminal::enable_raw_mode()?; - crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; - // if self.mouse { - crossterm::execute!(stdout(), EnableMouseCapture)?; - // } - // if self.paste { - // crossterm::execute!(stdout(), EnableBracketedPaste)?; - // } - // Self::init_error_hooks()?; - self.start(); - Ok(()) - } - - pub fn cancel(&self) { - self.cancellation_token.cancel(); - } - - pub fn suspend(&mut self) -> Result<()> { - self.exit()?; - #[cfg(not(windows))] - signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; - Ok(()) - } - - pub fn resume(&mut self) -> Result<()> { - self.enter()?; - Ok(()) - } - - pub fn exit(&mut self) -> Result<()> { - self.cancellation_token.cancel(); - if crossterm::terminal::is_raw_mode_enabled()? { - self.flush()?; - // if self.paste { - // crossterm::execute!(stdout(), DisableBracketedPaste)?; - // } - // if self.mouse { - crossterm::execute!(stdout(), DisableMouseCapture)?; - // } - crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; - crossterm::terminal::disable_raw_mode()?; - } - Ok(()) - } - - // [`Draw`] the terminal interface by [`rendering`] the widgets. - // - // [`Draw`]: ratatui::Terminal::draw - // [`rendering`]: crate::ui::render - pub fn draw(&mut self, app: &mut App) -> Result<()> { - // self.terminal.draw(|frame| ui::render(app, frame))?; - self.terminal - .draw(|frame| frame.render_widget(app, frame.area()))?; - Ok(()) - } - - pub async fn next(&mut self) -> Result { - self.receiver.recv().await.ok_or_eyre("This is an IO error") - } -} - -impl Deref for Tui { - type Target = ratatui::Terminal>; - - fn deref(&self) -> &Self::Target { - &self.terminal - } -} - -impl DerefMut for Tui { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.terminal - } -} - -impl Drop for Tui { - fn drop(&mut self) { - self.exit().unwrap(); - } -} diff --git a/src/frontend/ui.rs b/src/frontend/ui.rs deleted file mode 100644 index 45ccd60..0000000 --- a/src/frontend/ui.rs +++ /dev/null @@ -1,646 +0,0 @@ -// 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 color_eyre::owo_colors::OwoColorize; -use itertools::Itertools; -use ratatui::{ - buffer::Buffer, - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, - symbols, - text::{Line, Span, Text}, - widgets::{ - block::{Position, Title}, - Block, Borders, Cell, HighlightSpacing, List, ListItem, Padding, Paragraph, Row, Scrollbar, - ScrollbarOrientation, StatefulWidget, Table, Widget, Wrap, - }, -}; - -use crate::frontend::{app::App, keywords::TagListItem}; - -use super::{ - app::{CurrentArea, FormerArea}, - entries::EntryTableColumn, - keywords, -}; - -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::() - .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) - .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( - Title::from( - if box_height > area.height.into() - && self.entry_table.entry_info_scroll - < box_height as u16 + 2 - area.height - { - " ▼ " - } else { - "" - }, - ) - .position(Position::Bottom) - .alignment(Alignment::Right), - ) - .title( - Title::from(if scroll_height > 0 { " ▲ " } else { "" }) - .position(Position::Top) - .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 = 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); - } - } - } -} diff --git a/src/main.rs b/src/main.rs index 979c4cf..eaa9e05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,14 +15,15 @@ // along with this program. If not, see . ///// -use backend::cliargs::{self, CLIArgs}; +use cliargs::CLIArgs; use color_eyre::eyre::Result; use errorsetup::init_error_hooks; -use frontend::app::App; +use tui::app::App; -pub mod backend; +pub mod bib; +pub mod cliargs; pub mod errorsetup; -pub mod frontend; +pub mod tui; #[tokio::main] async fn main() -> Result<()> { diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..83d0b13 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,228 @@ +// 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 . +///// + +pub mod app; +pub mod command; +pub mod handler; +pub mod ui; + +use crate::tui::app::App; +use crossterm::{ + cursor, + event::{ + DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, KeyEvent, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +// use ratatui::backend::{Backend, CrosstermBackend}; +use color_eyre::eyre::{OptionExt, Result}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend; +use std::io::{stdout, Stdout}; +use std::panic; +use std::{ + ops::{Deref, DerefMut}, + time::Duration, +}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +// Terminal events. +#[derive(Clone, Copy, Debug)] +pub enum Event { + /// Terminal tick. + Tick, + /// Key press. + Key(KeyEvent), + /// Mouse click/scroll. + Mouse(MouseEvent), + /// Terminal resize. + Resize(u16, u16), +} + +#[derive(Debug)] +pub struct Tui { + /// Interface to the Terminal. + pub terminal: ratatui::Terminal>, + /// Event sender channel. + sender: mpsc::UnboundedSender, + /// Event receiver channel. + receiver: mpsc::UnboundedReceiver, + /// Event handler thread. + handler: tokio::task::JoinHandle<()>, + cancellation_token: CancellationToken, +} + +impl Tui { + // Constructs a new instance of [`Tui`]. + pub fn new() -> Result { + let terminal = ratatui::Terminal::new(CrosstermBackend::new(stdout()))?; + let (sender, receiver) = mpsc::unbounded_channel(); + let handler = tokio::spawn(async {}); + let cancellation_token = CancellationToken::new(); + Ok(Self { + terminal, + sender, + receiver, + handler, + cancellation_token, + }) + } + + pub fn start(&mut self) { + let tick_rate = Duration::from_millis(1000); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let event_loop = Self::event_loop( + self.sender.clone(), + self.cancellation_token.clone(), + tick_rate, + ); + // let _cancellation_token = self.cancellation_token.clone(); + // let _sender = self.sender.clone(); + self.handler = tokio::spawn(async { + event_loop.await; + }); + } + + async fn event_loop( + sender: mpsc::UnboundedSender, + cancellation_token: CancellationToken, + tick_rate: Duration, + ) { + let mut reader = crossterm::event::EventStream::new(); + let mut tick = tokio::time::interval(tick_rate); + loop { + let tick_delay = tick.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + // _ = sender.closed() => { + // break; + // } + _ = cancellation_token.cancelled() => { + break; + } + Some(Ok(evt)) = crossterm_event => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == crossterm::event::KeyEventKind::Press { + sender.send(Event::Key(key)).unwrap(); + } + }, + CrosstermEvent::Mouse(mouse) => { + sender.send(Event::Mouse(mouse)).unwrap(); + }, + CrosstermEvent::Resize(x, y) => { + sender.send(Event::Resize(x, y)).unwrap(); + }, + CrosstermEvent::FocusLost => { + }, + CrosstermEvent::FocusGained => { + }, + CrosstermEvent::Paste(_) => { + }, + } + } + _ = tick_delay => { + sender.send(Event::Tick).unwrap(); + } + }; + } + cancellation_token.cancel(); + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; + // if self.mouse { + crossterm::execute!(stdout(), EnableMouseCapture)?; + // } + // if self.paste { + // crossterm::execute!(stdout(), EnableBracketedPaste)?; + // } + // Self::init_error_hooks()?; + self.start(); + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.cancellation_token.cancel(); + if crossterm::terminal::is_raw_mode_enabled()? { + self.terminal.flush()?; + // if self.paste { + // crossterm::execute!(stdout(), DisableBracketedPaste)?; + // } + // if self.mouse { + crossterm::execute!(stdout(), DisableMouseCapture)?; + // } + crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + // [`Draw`] the terminal interface by [`rendering`] the widgets. + // + // [`Draw`]: ratatui::Terminal::draw + // [`rendering`]: crate::ui::render + pub fn draw(&mut self, app: &mut App) -> Result<()> { + // self.terminal.draw(|frame| ui::render(app, frame))?; + self.terminal + .draw(|frame| frame.render_widget(app, frame.area()))?; + Ok(()) + } + + pub async fn next(&mut self) -> Result { + self.receiver.recv().await.ok_or_eyre("This is an IO error") + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} 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 . +///// + +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, +} + +impl App { + // Constructs a new instance of [`App`]. + pub fn new(args: CLIArgs) -> Result { + // 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 . +///// + +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 = 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 = 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 = 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 . +///// + +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 . +///// + +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::() + .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 = 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); + } + } + } +} -- cgit v1.2.3