diff --git a/Cargo.lock b/Cargo.lock index 942a228..6c1f3aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,9 +159,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -347,7 +347,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "crossterm_winapi", "futures-core", "libc", @@ -933,7 +933,7 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "libc", "redox_syscall 0.4.1", ] @@ -1120,7 +1120,7 @@ version = "0.10.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -1386,7 +1386,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cassowary", "compact_str", "crossterm", @@ -1523,7 +1523,7 @@ version = "0.38.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0c3dde1fc030af041adc40e79c0e7fbcf431dd24870053d187d7c66e4b87453" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", @@ -1603,7 +1603,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cssparser", "derive_more", "fxhash", @@ -1618,18 +1618,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.188" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.188" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -2274,9 +2274,9 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "uuid" -version = "1.6.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" dependencies = [ "getrandom", ] @@ -2395,7 +2395,7 @@ name = "wiki-api" version = "0.1.1" dependencies = [ "anyhow", - "bitflags 2.6.0", + "bitflags 2.9.1", "ego-tree", "html5ever", "markup5ever_rcdom", @@ -2428,13 +2428,14 @@ version = "0.9.1" dependencies = [ "anyhow", "better-panic", - "bitflags 2.6.0", + "bitflags 2.9.1", "clap", "color-eyre", "crossterm", "directories", "futures", "human-panic", + "itertools", "libc", "log", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index d34ff83..1ff47e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,8 @@ tracing-log = "0.2.0" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } tui-input = "0.9" tui-logger = { version = "0.11.1", default-features = false, features = ["crossterm", "tracing-support"] } -bitflags = { version = "2.6.0", features = ["serde"] } +bitflags = { version = "2.8.0", features = ["serde"] } +itertools = "0.12.1" [dependencies.wiki-api] path = "wiki-api" diff --git a/src/app.rs b/src/app.rs index 471bc04..5c08c6d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,7 @@ use tokio::sync::mpsc; use crate::{ action::{Action, ActionPacket, ActionResult}, components::{ + help_popup::HelpPopupComponent, logger::LoggerComponent, message_popup::MessagePopupComponent, page_viewer::PageViewer, @@ -170,6 +171,15 @@ impl Component for AppComponent { self.theme.clone(), ))); ActionResult::consumed() + }, + + help => { + self.popups + .push(Box::new(HelpPopupComponent::new( + self.config.clone(), + self.theme.clone(), + ))); + ActionResult::consumed() } ); diff --git a/src/components/help_popup.rs b/src/components/help_popup.rs new file mode 100644 index 0000000..7cc6202 --- /dev/null +++ b/src/components/help_popup.rs @@ -0,0 +1,224 @@ +use std::{cmp::max, sync::Arc}; + +use itertools::Itertools; +use ratatui::{ + layout::{Constraint, Flex::Center, Layout, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, +}; + +use crate::{ + action::{Action, ActionResult}, + config::{Config, Keybinding, Theme}, + terminal::Frame, + ui::centered_rect, +}; + +use super::Component; + +struct Binding { + pub bindings: Box<[Box]>, + pub name: Box, +} + +impl Binding { + pub fn keys_len(&self) -> usize { + self.bindings.iter().fold(0, |acc, elm| acc + elm.len() + 2) - 2 + } + + pub fn to_line(&self, gap: usize, highlight: Color) -> Line { + Itertools::intersperse( + self.bindings + .iter() + .map(|b| Span::styled(b.as_ref(), Style::default().fg(highlight))), + Span::raw(", "), + ) + .take(self.bindings.len() * 2 - 1) + .chain([ + (self.keys_len()..gap) + .map(|_| ".") + .collect::() + .into(), + Span::raw(self.name.as_ref()), + ]) + .collect() + } +} + +impl Binding { + pub fn from_keys(key: &Keybinding, desc: &str) -> Self { + let bindings = key + .bindings() + .iter() + .map(|b| b.to_string().into()) + .collect(); + Self { + bindings, + name: desc.into(), + } + } +} + +pub struct HelpPopupComponent { + line: u16, + + global_bindings_list: Arc<[Binding]>, + search_bindings_list: Arc<[Binding]>, + page_bindings_list: Arc<[Binding]>, + config: Arc, + theme: Arc, +} + +impl HelpPopupComponent { + pub fn new(config: Arc, theme: Arc) -> Self { + macro_rules! convert_binding { + ($binding:expr) => { + Binding::from_keys(&$binding, { + let string_name = stringify!($binding); + &match string_name.rsplit_once('.') { + None => string_name, + Some((_, name)) => name, + } + .replace('_', " ") + }) + }; + } + + let global_bindings_list = vec![ + convert_binding!(config.bindings.global.scroll_down), + convert_binding!(config.bindings.global.scroll_up), + convert_binding!(config.bindings.global.scroll_to_top), + convert_binding!(config.bindings.global.scroll_to_bottom), + convert_binding!(config.bindings.global.pop_popup), + convert_binding!(config.bindings.global.half_down), + convert_binding!(config.bindings.global.half_up), + convert_binding!(config.bindings.global.unselect_scroll), + convert_binding!(config.bindings.global.submit), + convert_binding!(config.bindings.global.quit), + convert_binding!(config.bindings.global.enter_search_bar), + convert_binding!(config.bindings.global.exit_search_bar), + convert_binding!(config.bindings.global.switch_context_search), + convert_binding!(config.bindings.global.switch_context_page), + convert_binding!(config.bindings.global.toggle_search_language_selection), + convert_binding!(config.bindings.global.toggle_logger), + convert_binding!(config.bindings.global.help), + ] + .into(); + + let search_bindings_list = + vec![convert_binding!(config.bindings.search.continue_search)].into(); + + let page_bindings_list = vec![ + convert_binding!(config.bindings.page.pop_page), + convert_binding!(config.bindings.page.jump_to_header), + convert_binding!(config.bindings.page.select_first_link), + convert_binding!(config.bindings.page.select_last_link), + convert_binding!(config.bindings.page.select_prev_link), + convert_binding!(config.bindings.page.select_next_link), + convert_binding!(config.bindings.page.open_link), + convert_binding!(config.bindings.page.toggle_page_language_selection), + convert_binding!(config.bindings.page.toggle_zen_mode), + convert_binding!(config.bindings.page.toggle_toc), + ] + .into(); + + Self { + line: 0, + + global_bindings_list, + search_bindings_list, + page_bindings_list, + config, + theme, + } + } + + pub fn data_size(&self) -> u16 { + (self.global_bindings_list.len() + + self.search_bindings_list.len() + + self.page_bindings_list.len()) as u16 + } +} + +impl Component for HelpPopupComponent { + fn handle_key_events(&mut self, key: crossterm::event::KeyEvent) -> ActionResult { + if self.config.bindings.global.pop_popup.matches_event(key) + | self.config.bindings.global.quit.matches_event(key) + { + return Action::PopPopup.into(); + } + + ActionResult::Ignored + } + + fn update(&mut self, action: Action) -> ActionResult { + match action { + Action::ScrollUp(n) => { + self.line = self.line.saturating_sub(n); + + ActionResult::consumed() + } + Action::ScrollDown(n) => { + self.line += n; + ActionResult::consumed() + } + Action::Quit => Action::PopPopup.into(), + _ => ActionResult::Ignored, + } + } + + fn render(&mut self, f: &mut Frame<'_>, area: Rect) { + let popup_block = self + .theme + .default_block() + .title("Help") + .style(Style::default().bg(self.theme.bg)); + let area = centered_rect(area, 95, 95); + + f.render_widget(Clear, area); + f.render_widget(popup_block, area); + + let [text_area] = Layout::vertical([Constraint::Percentage(100)]) + .margin(1) + .flex(Center) + .areas(area); + + let gap = self + .global_bindings_list + .iter() + .chain(self.search_bindings_list.iter()) + .chain(self.page_bindings_list.iter()) + .fold(0, |acc, elm| max(acc, elm.keys_len())) + + 1; + let highlight_color = self.theme.search_title_fg; + macro_rules! to_line { + ($bindings:expr) => { + $bindings + .iter() + .map(|x| x.to_line(gap, highlight_color)) + .collect() + }; + } + + let consolidated_list: Vec = vec![ + vec![Line::raw("Global").underlined()], + to_line!(self.global_bindings_list), + vec![Line::default(), Line::raw("Search").underlined()], + to_line!(self.search_bindings_list), + vec![Line::default(), Line::raw("Page").underlined()], + to_line!(self.page_bindings_list), + ] + .concat(); + + if self.data_size() < text_area.height { + self.line = 0; + } else if self.line > self.data_size() - text_area.height { + self.line = self.data_size() - text_area.height; + }; + + let paragraph_widget = Paragraph::new(consolidated_list).scroll((self.line, 0)); + + f.render_widget(paragraph_widget, text_area); + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 91e0d1f..882281f 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -12,6 +12,7 @@ use crate::{ terminal::Frame, }; +pub mod help_popup; pub mod logger; pub mod message_popup; pub mod page; diff --git a/src/config.rs b/src/config.rs index d7a4f9b..60d2606 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Context, Result}; -use bitflags::bitflags; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use bitflags::{bitflags, bitflags_match}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode}; use directories::ProjectDirs; use ratatui::{ layout::Constraint, @@ -349,11 +349,78 @@ pub struct TocConfig { } #[derive(Deserialize)] -struct Binding { +pub struct Binding { code: KeyCode, modifiers: KeyModifiers, } +impl ToString for Binding { + fn to_string(&self) -> String { + macro_rules! vim_string { + ($s:tt) => { + std::format!("<{}>", $s) + }; + } + + let mut string = String::with_capacity(10); + + bitflags_match!(self.modifiers, + { + KeyModifiers::CONTROL => string.push_str("CTRL-"), + KeyModifiers::META => string.push_str("META-"), + KeyModifiers::ALT => string.push_str("ALT-"), + KeyModifiers::SUPER => string.push_str("SUPER-"), + KeyModifiers::HYPER => string.push_str("HYPER-"), + _ => (), + }); + + string.push_str(&match self.code { + KeyCode::Null | KeyCode::Modifier(_) => String::default(), + KeyCode::F(n) => format!(""), + KeyCode::Char(c) => c.to_string(), + KeyCode::Media(media_key) => match media_key { + MediaKeyCode::Play => vim_string!("Play"), + MediaKeyCode::Pause => vim_string!("Pause"), + MediaKeyCode::PlayPause => vim_string!("Play Pause"), + MediaKeyCode::Reverse => vim_string!("Reverse"), + MediaKeyCode::Stop => vim_string!("Stop"), + MediaKeyCode::FastForward => vim_string!("Fast Forward"), + MediaKeyCode::Rewind => vim_string!("Rewind"), + MediaKeyCode::TrackNext => vim_string!("Track Next"), + MediaKeyCode::TrackPrevious => vim_string!("Track Previous"), + MediaKeyCode::Record => vim_string!("Record"), + MediaKeyCode::LowerVolume => vim_string!("Lower Volume"), + MediaKeyCode::RaiseVolume => vim_string!("Raise Volume"), + MediaKeyCode::MuteVolume => vim_string!("Mute Volume"), + }, + KeyCode::Backspace => vim_string!("Backspace"), + KeyCode::Enter => vim_string!("Enter"), + KeyCode::Left => vim_string!("Left"), + KeyCode::Right => vim_string!("Right"), + KeyCode::Up => vim_string!("Up"), + KeyCode::Down => vim_string!("Down"), + KeyCode::Home => vim_string!("Home"), + KeyCode::End => vim_string!("End"), + KeyCode::PageUp => vim_string!("Page Up"), + KeyCode::PageDown => vim_string!("Page Down"), + KeyCode::Tab => vim_string!("Tab"), + KeyCode::BackTab => vim_string!("Back Tab"), + KeyCode::Delete => vim_string!("Delete"), + KeyCode::Insert => vim_string!("Insert"), + KeyCode::Esc => vim_string!("Esc"), + KeyCode::CapsLock => vim_string!("Caps Lock"), + KeyCode::ScrollLock => vim_string!("Scroll Lock"), + KeyCode::NumLock => vim_string!("Num Lock"), + KeyCode::PrintScreen => vim_string!("Print Screen"), + KeyCode::Pause => vim_string!("Pause"), + KeyCode::Menu => vim_string!("Menu"), + KeyCode::KeypadBegin => vim_string!("Keypad Begin"), + }); + + string + } +} + #[derive(Deserialize)] pub struct Keybinding { bindings: Vec, @@ -377,6 +444,20 @@ impl Keybinding { .iter() .any(|x| x.code == event.code && x.modifiers == event.modifiers); } + + pub fn bindings(&self) -> &[Binding] { + &self.bindings + } +} + +impl ToString for Keybinding { + fn to_string(&self) -> String { + self.bindings + .iter() + .map(|x| x.to_string()) + .collect::>() + .join(", ") + } } pub struct GlobalKeybindings { @@ -402,6 +483,8 @@ pub struct GlobalKeybindings { pub toggle_search_language_selection: Keybinding, pub toggle_logger: Keybinding, + + pub help: Keybinding, } pub struct SearchKeybindings { @@ -509,6 +592,8 @@ impl Config { toggle_search_language_selection: keybinding!([KeyCode::F(2);]), toggle_logger: keybinding!([KeyCode::Char('l');]), + + help: keybinding!([KeyCode::Char('?'); ]), }, search: SearchKeybindings { continue_search: keybinding!([KeyCode::Char('c');]),