From 986e6f986a25a0e8453bf36c8bd6e81e7048ba17 Mon Sep 17 00:00:00 2001 From: squirreljetpacks Date: Tue, 18 Aug 2026 10:51:33 -0400 Subject: [PATCH] feat: help key + broader keybinding modifier support --- src/core/ev_handler.rs | 80 +++++++++++++++ src/help.rs | 152 +++++++++++++++++++++++++++++ src/input/definitions/keydefs.rs | 11 ++- src/input/definitions/mousedefs.rs | 2 +- src/input/hashed_event_register.rs | 137 +++++++++++++++++++++++--- src/input/mod.rs | 47 +++++---- src/input/tests.rs | 47 +++++++++ src/lib.rs | 1 + src/screen/mod.rs | 1 + src/search.rs | 30 +++++- src/state.rs | 87 +++++++++++++++-- 11 files changed, 552 insertions(+), 43 deletions(-) create mode 100644 src/help.rs diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 5ca618ca..b0702711 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -33,12 +33,22 @@ pub fn handle_event( ) { match ev { Command::SetData(text) => { + if let Some(ref mut hs) = p.help_state { + hs.screen.orig_text = text; + hs.screen.line_count = hs.screen.orig_text.lines().count(); + return; + } p.screen.orig_text = text; p.screen.line_count = p.screen.orig_text.lines().count(); p.reformat_display(); command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); } Command::UserInput(InputEvent::Exit) => { + if p.help_state.is_some() { + p.exit_help(); + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + return; + } p.run_hooks(Hook::PrePagerExit); p.exit(); is_exited.store(true, std::sync::atomic::Ordering::SeqCst); @@ -141,11 +151,24 @@ pub fn handle_event( } } Command::UserInput(InputEvent::RestorePrompt) => { + if p.help_state.is_some() { + p.exit_help(); + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + return; + } // Set the message to None and new messages to false as all messages have been shown p.message = None; p.format_prompt(); command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); } + Command::UserInput(InputEvent::ShowHelp) => { + if p.help_state.is_some() { + p.exit_help(); + } else { + p.show_help(); + } + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + } Command::UserInput(InputEvent::UpdateTermArea(c, r)) => { p.rows = r; p.cols = c; @@ -283,6 +306,11 @@ pub fn handle_event( } Command::AppendData(text) => { + if let Some(ref mut hs) = p.help_state { + hs.screen.orig_text.push_str(&text); + hs.screen.line_count = hs.screen.orig_text.lines().count(); + return; + } let prev_unterminated = p.screen.unterminated; let prev_fmt_lines_count = p.screen.formatted_lines_count(); let append_style = p.append_str(text.as_str()); @@ -548,6 +576,58 @@ mod tests { assert_eq!(ps.message.unwrap(), TEST_STR.to_string()); } + #[test] + fn show_help() { + let mut ps = PagerState::new().unwrap(); + ps.screen.orig_text = "original text\n".to_string(); + ps.reformat_display(); + ps.upper_mark = 0; + + let ev = Command::UserInput(InputEvent::ShowHelp); + let mut command_queue = CommandQueue::new_zero(); + + // Showing help sets the screen to the formatted help table + handle_event( + ev, + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + ); + assert!(ps.help_state.is_some()); + assert!(ps.screen.orig_text.contains("COMMAND SUMMARY")); + assert!(ps.prompt.contains("HELP")); + + // Pressing help again toggles it off and restores original text + let ev2 = Command::UserInput(InputEvent::ShowHelp); + handle_event( + ev2, + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + ); + assert!(ps.help_state.is_none()); + assert_eq!(ps.screen.orig_text, "original text\n"); + + // Showing help then exiting with Exit returns to pager + handle_event( + Command::UserInput(InputEvent::ShowHelp), + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + ); + assert!(ps.help_state.is_some()); + let is_exited = Arc::new(AtomicBool::new(false)); + handle_event( + Command::UserInput(InputEvent::Exit), + &mut ps, + &mut command_queue, + &is_exited, + ); + assert!(ps.help_state.is_none()); + assert_eq!(is_exited.load(std::sync::atomic::Ordering::SeqCst), false); + assert_eq!(ps.screen.orig_text, "original text\n"); + } + #[test] #[cfg(feature = "static_output")] fn set_run_no_overflow() { diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 00000000..17cc8e5a --- /dev/null +++ b/src/help.rs @@ -0,0 +1,152 @@ +//! Help text and related definitions for the pager. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Format a [`KeyEvent`] into a human-readable representation (e.g. `"Ctrl-c"`, `"Alt-h"`). +pub fn format_key(ke: &KeyEvent) -> String { + let mut s = String::new(); + if ke.modifiers.contains(KeyModifiers::CONTROL) { + s.push_str("Ctrl-"); + } + if ke.modifiers.contains(KeyModifiers::ALT) { + s.push_str("Alt-"); + } + if ke.modifiers.contains(KeyModifiers::SHIFT) { + if let KeyCode::Char(c) = ke.code { + if !c.is_ascii_uppercase() { + s.push_str("Shift-"); + } + } else { + s.push_str("Shift-"); + } + } + + match ke.code { + KeyCode::Char(c) => s.push(c), + KeyCode::Enter => s.push_str("Enter"), + KeyCode::Tab => s.push_str("Tab"), + KeyCode::BackTab => s.push_str("BackTab"), + KeyCode::Backspace => s.push_str("Backspace"), + KeyCode::Esc => s.push_str("Esc"), + KeyCode::Up => s.push_str("Up"), + KeyCode::Down => s.push_str("Down"), + KeyCode::Left => s.push_str("Left"), + KeyCode::Right => s.push_str("Right"), + KeyCode::PageUp => s.push_str("PageUp"), + KeyCode::PageDown => s.push_str("PageDown"), + KeyCode::Home => s.push_str("Home"), + KeyCode::End => s.push_str("End"), + KeyCode::Delete => s.push_str("Delete"), + KeyCode::Insert => s.push_str("Insert"), + KeyCode::F(n) => s.push_str(&format!("F{n}")), + KeyCode::Null => s.push_str("Null"), + _ => s.push_str("Unknown"), + } + s +} + +/// Format dynamic help table from key event entries and their descriptions. +/// +/// Empty descriptions are omitted. +pub fn format_help_table_from_entries<'a, I>(entries: I) -> String +where + I: IntoIterator, +{ + let mut groups: Vec<(&'a str, Vec)> = Vec::new(); + for (key, desc) in entries { + let trimmed_desc = desc.trim(); + if trimmed_desc.is_empty() { + continue; + } + let key_str = format_key(key); + if let Some((_, keys)) = groups.iter_mut().find(|(d, _)| *d == trimmed_desc) { + if !keys.contains(&key_str) { + keys.push(key_str); + } + } else { + groups.push((trimmed_desc, vec![key_str])); + } + } + + if groups.is_empty() { + return String::new(); + } + + let mut out = String::new(); + out.push_str(" COMMAND SUMMARY\n\n"); + out.push_str(" Key(s) Action\n"); + out.push_str(" ------ ------\n"); + + for (desc, keys) in groups { + let keys_str = keys.join(", "); + out.push_str(&format!(" {:<30} {}\n", keys_str, desc)); + } + + out.push_str("\n -- Press q, Enter, or Alt-h to return to pager --\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEventState; + + #[test] + fn test_format_key() { + let k1 = KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + assert_eq!(format_key(&k1), "q"); + + let k2 = KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + assert_eq!(format_key(&k2), "Ctrl-c"); + + let k3 = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::ALT, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + assert_eq!(format_key(&k3), "Alt-Up"); + } + + #[test] + fn test_format_help_table_from_entries() { + let k1 = KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + let k2 = KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + let k3 = KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + + let entries = vec![(&k1, "quit"), (&k2, "quit"), (&k3, "")]; + let table = format_help_table_from_entries(entries); + assert!(table.contains("COMMAND SUMMARY")); + assert!(table.contains("q, Ctrl-c")); + assert!(table.contains("quit")); + assert!(!table.contains(" x ")); + + let empty_table = format_help_table_from_entries(Vec::<(&KeyEvent, &str)>::new()); + assert!(empty_table.is_empty()); + } +} diff --git a/src/input/definitions/keydefs.rs b/src/input/definitions/keydefs.rs index 35935431..9c2cce94 100644 --- a/src/input/definitions/keydefs.rs +++ b/src/input/definitions/keydefs.rs @@ -99,7 +99,7 @@ impl KeySeq { } } Token::MultipleChar(c) => { - let c = c.to_ascii_lowercase().clone(); + let c = c.to_ascii_lowercase(); SPECIAL_KEYS.get(c.as_str()).map_or_else( || panic!("'{}': Invalid key input sequence given", text), |key| { @@ -316,4 +316,13 @@ fn test_parse_key_event() { state: KeyEventState::NONE, } ); + assert_eq!( + parse_key_event("m-h"), + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::ALT, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + } + ); } diff --git a/src/input/definitions/mousedefs.rs b/src/input/definitions/mousedefs.rs index 90ec8cf1..b13d66f4 100644 --- a/src/input/definitions/mousedefs.rs +++ b/src/input/definitions/mousedefs.rs @@ -67,7 +67,7 @@ fn gen_mouse_event_from_tokenlist(token_list: &[Token], text: &str) -> MouseEven ); } Token::MultipleChar(c) => { - let c = c.to_ascii_lowercase().clone(); + let c = c.to_ascii_lowercase(); MOUSE_ACTIONS.get(c.as_str()).map_or_else( || panic!("'{}': Invalid key input sequence given", text), |k| { diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index a4a9b0a5..bfd77127 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -12,9 +12,17 @@ use std::{ sync::Arc, }; +use std::borrow::Cow; + /// A convenient type for the return type of [`HashedEventRegister::get`] type EventReturnType = Arc InputEvent + Send + Sync>; +#[derive(Clone)] +struct EventCallback { + cb: EventReturnType, + desc: Cow<'static, str>, +} + // ////////////////////////////// // EVENTWRAPPER TYPE // ////////////////////////////// @@ -89,7 +97,7 @@ impl Hash for EventWrapper { /// Each item is a key value pair, where the key is a event and it's value is a callback. When a /// event occurs, it is matched inside and when the related match is found, it's related callback /// is called. -pub struct HashedEventRegister(HashMap); +pub struct HashedEventRegister(HashMap); impl HashedEventRegister { /// Create a new [`HashedEventRegister`] with the default hasher @@ -115,6 +123,15 @@ where fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { self.get(&ev).map(|c| c(ev, ps)) } + + fn format_help(&self) -> Option { + let h = self.format_help(); + if h.is_empty() { + None + } else { + Some(h) + } + } } // #################### @@ -129,6 +146,16 @@ where Self(HashMap::with_hasher(s)) } + /// Format dynamic help table from all registered key bindings that have non-empty descriptions. + #[must_use] + pub fn format_help(&self) -> String { + let entries = self.0.iter().filter_map(|(k, v)| match k { + EventWrapper::ExactMatchEvent(Event::Key(ke)) => Some((ke, v.desc.as_ref())), + _ => None, + }); + crate::help::format_help_table_from_entries(entries) + } + /// Adds a callback to handle all events that failed to match /// /// Sometimes there are bunch of keys having equal importance that should have the same @@ -142,13 +169,20 @@ where &mut self, cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, ) { - self.0.insert(EventWrapper::WildEvent, Arc::new(cb)); + self.0.insert( + EventWrapper::WildEvent, + EventCallback { + cb: Arc::new(cb), + desc: Cow::Borrowed(""), + }, + ); } fn get(&self, k: &Event) -> Option<&EventReturnType> { self.0 .get(&k.into()) - .map_or_else(|| self.0.get(&EventWrapper::WildEvent), |k| Some(k)) + .map_or_else(|| self.0.get(&EventWrapper::WildEvent), Some) + .map(|entry| &entry.cb) } /// Adds a callback for handling resize events @@ -176,8 +210,13 @@ where let v = Arc::new(cb); // The 0, 0 are present just to ensure everything compiles and they can be anything. // These values are never hashed or stored into the HashedEventRegister - self.0 - .insert(EventWrapper::ExactMatchEvent(Event::Resize(0, 0)), v); + self.0.insert( + EventWrapper::ExactMatchEvent(Event::Resize(0, 0)), + EventCallback { + cb: v, + desc: Cow::Borrowed(""), + }, + ); } /// Removes the currently active resize event callback @@ -194,7 +233,7 @@ impl HashedEventRegister where S: BuildHasher, { - /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb` + /// Add all elements of `desc` as key bindings that minus should respond to with the callback `cb` /// /// You should prefer using the [`add_key_events_checked`](HashedEventRegister::add_key_events_checked) /// over this one. @@ -213,17 +252,31 @@ where &mut self, desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + ) { + self.add_described_key_events(desc, "", cb); + } + + /// Add all elements of `keys` as key bindings with a description that minus should respond to with the callback `cb`. + pub fn add_described_key_events( + &mut self, + keys: &[&str], + desc: impl Into>, + cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, ) { let v = Arc::new(cb); - for k in desc { + let d = desc.into(); + for k in keys { self.0.insert( Event::Key(super::definitions::keydefs::parse_key_event(k)).into(), - v.clone(), + EventCallback { + cb: v.clone(), + desc: d.clone(), + }, ); } } - /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb`. + /// Add all elements of `desc` as key bindings that minus should respond to with the callback `cb`. /// /// Prefer using this over [`add_key_events`](HashedEventRegister::add_key_events). /// @@ -247,13 +300,31 @@ where desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, remap: bool, + ) { + self.add_described_key_events_checked(desc, "", cb, remap); + } + + /// Add all elements of `keys` as key bindings with a description that minus should respond to with the callback `cb`, with conflict checking. + pub fn add_described_key_events_checked( + &mut self, + keys: &[&str], + desc: impl Into>, + cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + remap: bool, ) { let v = Arc::new(cb); - for k in desc { + let d = desc.into(); + for k in keys { let def: EventWrapper = Event::Key(super::definitions::keydefs::parse_key_event(k)).into(); assert!(self.0.contains_key(&def) && remap, ""); - self.0.insert(def, v.clone()); + self.0.insert( + def, + EventCallback { + cb: v.clone(), + desc: d.clone(), + }, + ); } } @@ -272,6 +343,37 @@ where .remove(&Event::Key(super::definitions::keydefs::parse_key_event(k)).into()); } } + + /// Add key binding(s) to show help in the pager prompt. + /// + /// If `desc` is empty, defaults to `&["m-h"]`. + /// + /// # Example + /// ``` + /// use minus::input::HashedEventRegister; + /// + /// let mut input_register = HashedEventRegister::default(); + /// // Bind default Meta/Alt-h key to show help + /// input_register.add_help_key(&[]); + /// // Or specify custom keys + /// input_register.add_help_key(&["f1"]); + /// ``` + pub fn add_help_key(&mut self, desc: &[&str]) { + let keys = if desc.is_empty() { &["m-h"][..] } else { desc }; + self.add_described_key_events(keys, "help", |_, _| InputEvent::ShowHelp); + } + + /// Add key binding(s) to show help in the pager prompt with conflict checking. + /// + /// If `desc` is empty, defaults to `&["m-h"]`. + /// + /// # Panics + /// This will panic if any of the keybindings has been previously defined, unless `remap` + /// is set to true. + pub fn add_help_key_checked(&mut self, desc: &[&str], remap: bool) { + let keys = if desc.is_empty() { &["m-h"][..] } else { desc }; + self.add_described_key_events_checked(keys, "help", |_, _| InputEvent::ShowHelp, remap); + } } // ############################### @@ -305,7 +407,10 @@ where for k in desc { self.0.insert( Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(), - v.clone(), + EventCallback { + cb: v.clone(), + desc: Cow::Borrowed(""), + }, ); } } @@ -339,7 +444,13 @@ where let def: EventWrapper = Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(); assert!(self.0.contains_key(&def) && remap, ""); - self.0.insert(def, v.clone()); + self.0.insert( + def, + EventCallback { + cb: v.clone(), + desc: Cow::Borrowed(""), + }, + ); } } diff --git a/src/input/mod.rs b/src/input/mod.rs index f52fff74..7192194c 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -265,6 +265,8 @@ pub enum InputEvent { /// This is similar to [`Pager::follow_output`](crate::pager::Pager::follow_output) except that /// this is used to control it from the user's side. FollowOutput(bool), + /// Show help message in the prompt area. + ShowHelp, } /// Classifies the input and returns the appropriate [`InputEvent`] @@ -280,6 +282,11 @@ pub enum InputEvent { )] pub trait InputClassifier { fn classify_input(&self, ev: Event, ps: &PagerState) -> Option; + + /// Format dynamic help text from registered bindings, if supported. + fn format_help(&self) -> Option { + None + } } /// Insert the default set of actions into the [`HashedEventRegister`] @@ -293,20 +300,20 @@ pub fn generate_default_bindings(map: &mut HashedEventRegister) where S: std::hash::BuildHasher, { - map.add_key_events(&["q", "c-c"], |_, _| InputEvent::Exit); + map.add_described_key_events(&["q", "c-c"], "quit", |_, _| InputEvent::Exit); - map.add_key_events(&["up", "k"], |_, ps| { + map.add_described_key_events(&["up", "k"], "scroll up", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(position)) }); - map.add_key_events(&["down", "j"], |_, ps| { + map.add_described_key_events(&["down", "j"], "scroll down", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(position)) }); - map.add_key_events(&["c-f"], |_, ps| { + map.add_described_key_events(&["c-f"], "toggle follow", |_, ps| { InputEvent::FollowOutput(!ps.follow_output) }); - map.add_key_events(&["enter"], |_, ps| { + map.add_described_key_events(&["enter"], "scroll lines", |_, ps| { if ps.message.is_some() { InputEvent::RestorePrompt } else { @@ -314,17 +321,17 @@ where InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(position)) } }); - map.add_key_events(&["u", "c-u"], |_, ps| { + map.add_described_key_events(&["u", "c-u"], "half-page up", |_, ps| { let half_screen = ps.rows / 2; InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(half_screen)) }); - map.add_key_events(&["d", "c-d"], |_, ps| { + map.add_described_key_events(&["d", "c-d"], "half-page down", |_, ps| { let half_screen = ps.rows / 2; InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(half_screen)) }); - map.add_key_events(&["g", "home"], |_, _| InputEvent::UpdateUpperMark(0)); + map.add_described_key_events(&["g", "home"], "top", |_, _| InputEvent::UpdateUpperMark(0)); - map.add_key_events(&["s-g", "G"], |_, ps| { + map.add_described_key_events(&["s-g", "G"], "bottom", |_, ps| { let mut position = ps .prefix_num .parse::() @@ -344,21 +351,21 @@ where .unwrap_or(&(usize::MAX - 1)); InputEvent::UpdateUpperMark(row_to_go) }); - map.add_key_events(&["pageup"], |_, ps| { + map.add_described_key_events(&["pageup"], "page up", |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(ps.rows - 1)) }); - map.add_key_events(&["pagedown", "space"], |_, ps| { + map.add_described_key_events(&["pagedown", "space"], "page down", |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(ps.rows - 1)) }); - map.add_key_events(&["c-l"], |_, ps| { + map.add_described_key_events(&["c-l"], "toggle line numbers", |_, ps| { InputEvent::UpdateLineNumber(!ps.line_numbers) }); - map.add_key_events(&["end"], |_, _| InputEvent::UpdateUpperMark(usize::MAX - 1)); + map.add_described_key_events(&["end"], "bottom", |_, _| InputEvent::UpdateUpperMark(usize::MAX - 1)); #[cfg(feature = "search")] { - map.add_key_events(&["/"], |_, _| InputEvent::Search(SearchMode::Forward)); - map.add_key_events(&["?"], |_, _| InputEvent::Search(SearchMode::Reverse)); - map.add_key_events(&["n"], |_, ps| { + map.add_described_key_events(&["/"], "search forward", |_, _| InputEvent::Search(SearchMode::Forward)); + map.add_described_key_events(&["?"], "search backward", |_, _| InputEvent::Search(SearchMode::Reverse)); + map.add_described_key_events(&["n"], "next match", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { @@ -369,7 +376,7 @@ where InputEvent::Ignore } }); - map.add_key_events(&["p", "s-n"], |_, ps| { + map.add_described_key_events(&["p", "s-n"], "previous match", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { @@ -407,14 +414,14 @@ where map.add_key_events(&["y"], |_, _| InputEvent::CopySelection); } - map.add_key_events(&["c-s-h", "c-h"], |_, ps| { + map.add_described_key_events(&["c-s-h", "c-h"], "toggle line wrap", |_, ps| { InputEvent::HorizontalScroll(!ps.screen.line_wrapping) }); - map.add_key_events(&["h", "left"], |_, ps| { + map.add_described_key_events(&["h", "left"], "scroll left", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateLeftMark(ps.left_mark.saturating_sub(position)) }); - map.add_key_events(&["l", "right"], |_, ps| { + map.add_described_key_events(&["l", "right"], "scroll right", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateLeftMark(ps.left_mark.saturating_add(position)) }); diff --git a/src/input/tests.rs b/src/input/tests.rs index c8d00a3c..44b1eeaa 100644 --- a/src/input/tests.rs +++ b/src/input/tests.rs @@ -488,3 +488,50 @@ fn test_search_bindings() { ); } } + +#[test] +fn test_help_key() { + use crate::input::{HashedEventRegister, InputClassifier}; + + let pager = PagerState::new().unwrap(); + + // Default register does not have help bound (falls back to wild matcher -> Ignore) + let alt_h = Event::Key(KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::ALT, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }); + assert_eq!(pager.input_classifier.classify_input(alt_h.clone(), &pager), Some(InputEvent::Ignore)); + + // Attach default help key (alt-h / m-h) + let mut reg = HashedEventRegister::default(); + reg.add_help_key(&[]); + assert_eq!(reg.classify_input(alt_h.clone(), &pager), Some(InputEvent::ShowHelp)); + + // Attach custom help key + let mut reg_custom = HashedEventRegister::default(); + reg_custom.add_help_key(&["f1"]); + let f1 = Event::Key(KeyEvent { + code: KeyCode::F(1), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }); + assert_eq!(reg_custom.classify_input(f1, &pager), Some(InputEvent::ShowHelp)); + + // Dynamic help generation with described keys and omitted empty descriptions + let mut reg_dynamic = HashedEventRegister::with_default_hasher(); + reg_dynamic.add_described_key_events(&["q", "c-c"], "quit", |_, _| InputEvent::Exit); + reg_dynamic.add_described_key_events(&["j", "down"], "scroll down", |_, _| InputEvent::UpdateUpperMark(1)); + // Undescribed key (empty description) should not appear in help text + reg_dynamic.add_key_events(&["x"], |_, _| InputEvent::Exit); + + let help = reg_dynamic.format_help(); + assert!(help.contains("q, Ctrl-c") || help.contains("Ctrl-c, q")); + assert!(help.contains("quit")); + assert!(help.contains("j, Down") || help.contains("Down, j")); + assert!(help.contains("scroll down")); + assert!(!help.contains(" x ")); + assert!(help.contains("COMMAND SUMMARY")); +} diff --git a/src/lib.rs b/src/lib.rs index f121bfc8..8c003d2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,6 +189,7 @@ #[cfg(feature = "dynamic_output")] mod dynamic_pager; pub mod error; +pub mod help; pub mod hooks; pub mod input; #[path = "core/mod.rs"] diff --git a/src/screen/mod.rs b/src/screen/mod.rs index 9d537329..9358e53b 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -106,6 +106,7 @@ impl fmt::Display for SearchFormattedRow<'_, '_> { /// /// Most of the functions of this type are cheap as minus does a lot of caching of the analysis /// behind the scenes +#[derive(Clone, Debug)] pub struct Screen { pub(crate) orig_text: OwnedTextBlock, pub(crate) formatted_lines: Rows, diff --git a/src/search.rs b/src/search.rs index beb439a2..adc80457 100644 --- a/src/search.rs +++ b/src/search.rs @@ -652,10 +652,10 @@ where } Event::Key(KeyEvent { code: KeyCode::Char(c), - modifiers: KeyModifiers::NONE, + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, .. }) => { - // For any character key, without a modifier, insert it into so.string before + // For any character key, without a modifier (or with Shift), insert it into so.string before // current cursor position and update the line so.string .insert(so.cursor_position.saturating_sub(1).into(), *c); @@ -1023,6 +1023,32 @@ mod tests { assert_eq!(search_opts.input_status, InputStatus::Confirmed); } + #[test] + fn input_uppercase_and_shifted_text() { + let mut search_opts = new_search_opts(SearchMode::Forward); + let mut out = Vec::with_capacity(1500); + for (i, c) in "Hello World".chars().enumerate() { + let modifiers = if c.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + search_opts.ev = Some(Event::Key(KeyEvent { + code: KeyCode::Char(c), + kind: KeyEventKind::Press, + modifiers, + state: KeyEventState::NONE, + })); + handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); + assert_eq!(search_opts.input_status, InputStatus::Active); + assert_eq!(search_opts.cursor_position as usize, i + 2); + } + search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter)); + handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); + assert_eq!(&search_opts.string, "Hello World"); + assert_eq!(search_opts.input_status, InputStatus::Confirmed); + } + #[test] fn home_end_keys() { // Setup diff --git a/src/state.rs b/src/state.rs index 77f42776..22605795 100644 --- a/src/state.rs +++ b/src/state.rs @@ -94,6 +94,15 @@ pub struct Selection { /// /// Various fields are made public so that their values can be accessed while implementing the /// trait. +#[derive(Clone, Debug)] +pub(crate) struct HelpState { + pub(crate) screen: Screen, + pub(crate) upper_mark: usize, + pub(crate) left_mark: usize, + pub(crate) prompt: String, + pub(crate) follow_output: bool, + pub(crate) line_numbers: LineNumbers, +} #[allow(clippy::module_name_repetitions)] pub struct PagerState { /// Configuration for line numbers. See [`LineNumbers`] @@ -162,6 +171,8 @@ pub struct PagerState { /// See [`follow_output`](crate::pager::Pager::follow_output) for more info on follow mode. pub(crate) follow_output: bool, pub(crate) selection_anchor: Option, + /// Saved state while help screen is active. + pub(crate) help_state: Option, /// The output sink configured for the pager. pub output_sink: Arc>>, } @@ -225,6 +236,7 @@ impl PagerState { lines_to_row_map: LinesRowMap::new(), follow_output: false, selection_anchor: None, + help_state: None, output_sink, }; @@ -337,13 +349,11 @@ impl PagerState { // the prompt/message and the indicators on the right // NOTE: Count chars of prompt_str as they can be non-ASCII let prefix_len = prefix_str.len(); - let extra_space = self.cols.saturating_sub( - search_len + prefix_len + follow_mode_str.len() + prompt_str.chars().count(), - ); + let indicators_len = search_len + prefix_len + follow_mode_str.len(); + let available_space = self.cols.saturating_sub(indicators_len); + let extra_space = available_space.saturating_sub(prompt_str.chars().count()); - let byte_idx = prompt_str - .char_indices() - .nth(search_len + prefix_len + follow_mode_str.len()); + let byte_idx = prompt_str.char_indices().nth(available_space); // The if-case is especially frequent under non-tty conditions let dsp_prompt: &str = if extra_space == 0 @@ -387,6 +397,53 @@ impl PagerState { self.displayed_prompt = format_string; } + /// Enter help mode, displaying the help table screen. + pub(crate) fn show_help(&mut self) { + if self.help_state.is_some() { + return; + } + let help_text = self + .input_classifier + .format_help() + .unwrap_or_default(); + + let saved = HelpState { + screen: std::mem::take(&mut self.screen), + upper_mark: self.upper_mark, + left_mark: self.left_mark, + prompt: std::mem::take(&mut self.prompt), + follow_output: self.follow_output, + line_numbers: self.line_numbers, + }; + + self.screen = Screen::default(); + self.screen.orig_text = help_text; + self.screen.line_count = self.screen.orig_text.lines().count(); + self.screen.line_wrapping = false; + self.upper_mark = 0; + self.left_mark = 0; + self.follow_output = false; + self.line_numbers = LineNumbers::Disabled; + self.prompt = "HELP -- Press q, Enter, or Alt-h to return to pager".to_string(); + self.message = None; + self.help_state = Some(saved); + self.reformat_display(); + } + + /// Exit help mode, restoring the original document and scroll position. + pub(crate) fn exit_help(&mut self) { + if let Some(saved) = self.help_state.take() { + self.screen = saved.screen; + self.upper_mark = saved.upper_mark; + self.left_mark = saved.left_mark; + self.prompt = saved.prompt; + self.follow_output = saved.follow_output; + self.line_numbers = saved.line_numbers; + self.message = None; + self.reformat_display(); + } + } + pub(crate) fn run_hooks(&mut self, hook: crate::hooks::Hook) { let mut hooks = std::mem::take(&mut self.hooks); hooks.run_hooks(hook, self); @@ -722,4 +779,22 @@ mod tests { assert_eq!(ps.extract_selection().as_deref(), Some("cdefghi\njklm")); } + + #[test] + fn format_prompt_truncates_long_message_to_available_width() { + let mut ps = PagerState::new().unwrap(); + ps.cols = 20; + let long_msg = "Help: q:quit | j/k:scroll | Space:page"; + ps.message = Some(long_msg.to_string()); + ps.format_prompt(); + + // Should truncate message to fit 20 cols + assert!(ps.displayed_prompt.contains(&long_msg[..20])); + + // With follow mode [F] (3 chars), prompt should truncate to 17 chars + ps.follow_output = true; + ps.format_prompt(); + assert!(ps.displayed_prompt.contains(&long_msg[..17])); + assert!(ps.displayed_prompt.contains("[F]")); + } }