Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/core/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use crate::{
minus_core::utils::display::AppendStyle,
};

#[cfg(feature = "clipboard")]
use crate::state::ClipboardHandler;

#[cfg(feature = "search")]
use crate::search::SearchOpts;

Expand Down Expand Up @@ -54,6 +57,8 @@ pub enum Command {
// Configuration options
SetExitStrategy(ExitStrategy),
SetInputClassifier(Box<dyn InputClassifier + Send + Sync + 'static>),
#[cfg(feature = "clipboard")]
SetClipboardHandler(ClipboardHandler),
AddExitCallback(Box<dyn FnMut() + Send + Sync + 'static>),
AddHook(Hook, u64, HookCallback),
RemoveHook(Hook, u64),
Expand Down Expand Up @@ -82,6 +87,8 @@ impl PartialEq for Command {
| (Self::AddExitCallback(_), Self::AddExitCallback(_))
| (Self::AddHook(..), Self::AddHook(..))
| (Self::SetOutputSink(_), Self::SetOutputSink(_)) => true,
#[cfg(feature = "clipboard")]
(Self::SetClipboardHandler(_), Self::SetClipboardHandler(_)) => true,
(Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2,
#[cfg(feature = "search")]
(Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true,
Expand All @@ -102,6 +109,8 @@ impl Debug for Command {
Self::LineWrapping(lw) => write!(f, "LineWrapping({lw:?})"),
Self::SetExitStrategy(es) => write!(f, "SetExitStrategy({es:?})"),
Self::SetInputClassifier(_) => write!(f, "SetInputClassifier"),
#[cfg(feature = "clipboard")]
Self::SetClipboardHandler(_) => write!(f, "SetClipboardHandler"),
Self::ShowPrompt(show) => write!(f, "ShowPrompt({show:?})"),
#[cfg(feature = "search")]
Self::IncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"),
Expand Down
121 changes: 117 additions & 4 deletions src/core/ev_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -130,22 +140,37 @@ pub fn handle_event(

#[cfg(feature = "clipboard")]
Command::UserInput(InputEvent::CopySelection) => {
if let Some(text) = p.extract_selection()
&& let Ok(mut clipboard) = arboard::Clipboard::new()
{
let _ = clipboard.set_text(text);
if let Some(text) = p.extract_selection() {
if let Some(handler) = p.clipboard_handler.as_ref() {
handler(&text);
} else if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(text);
}
}
if p.selection.is_some() || p.selection_anchor.is_some() {
p.clear_selection();
command_queue.push_back(Command::Io(IoCommand::RedrawDisplay));
}
}
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;
Expand Down Expand Up @@ -283,6 +308,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());
Expand Down Expand Up @@ -343,6 +373,8 @@ pub fn handle_event(
#[cfg(feature = "search")]
Command::IncrementalSearchCondition(cb) => p.search_state.incremental_search_condition = cb,
Command::SetInputClassifier(clf) => p.input_classifier = clf,
#[cfg(feature = "clipboard")]
Command::SetClipboardHandler(handler) => p.clipboard_handler = Some(handler),
Command::AddExitCallback(cb) => p.exit_callbacks.push(cb),
Command::AddHook(hook, id, cb) => p.hooks.add_callback(hook, id, cb),
Command::RemoveHook(hook, id) => {
Expand Down Expand Up @@ -548,6 +580,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() {
Expand Down Expand Up @@ -717,4 +801,33 @@ mod tests {
Some(Command::Io(IoCommand::RedrawDisplay))
);
}

#[test]
#[cfg(feature = "clipboard")]
fn copy_selection_uses_clipboard_handler() {
let mut ps = PagerState::new().unwrap();
ps.screen.line_wrapping = false;
ps.screen.orig_text = "hello world\n".to_string();
ps.reformat_display();
ps.selection_anchor = ps.selection_from_coordinates(0, 0);
ps.selection = ps.selection_from_coordinates(10, 0);

let copied = Arc::new(std::sync::Mutex::new(None::<String>));
let copied_handler = copied.clone();
ps.clipboard_handler = Some(Box::new(move |text| {
*copied_handler.lock().unwrap() = Some(text.to_string());
}));

let mut command_queue = CommandQueue::new_zero();
handle_event(
Command::UserInput(InputEvent::CopySelection),
&mut ps,
&mut command_queue,
&Arc::new(AtomicBool::new(false)),
);

assert_eq!(copied.lock().unwrap().as_deref(), Some("hello world"));
assert_eq!(ps.selection, None);
assert_eq!(ps.selection_anchor, None);
}
}
152 changes: 152 additions & 0 deletions src/help.rs
Original file line number Diff line number Diff line change
@@ -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<Item = (&'a KeyEvent, &'a str)>,
{
let mut groups: Vec<(&'a str, Vec<String>)> = 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());
}
}
11 changes: 10 additions & 1 deletion src/input/definitions/keydefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down Expand Up @@ -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,
}
);
}
Loading
Loading