diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 83012cd..07ce3ad 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -43,15 +43,65 @@ pub enum Action { Generate, ConfirmDiscard, KeepEditing, + Initialize, + InsertEntry, + GenerateEntry, + Grep, + RemoveEntry, + MoveEntry, + CopyEntry, + GitPull, + GitPush, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WorkflowAction { + Initialize, + InsertEntry, + GenerateEntry, + Grep, + RemoveEntry, + MoveEntry, + CopyEntry, + GitPull, + GitPush, +} + +impl Action { + pub fn workflow(self) -> Option { + match self { + Self::Initialize => Some(WorkflowAction::Initialize), + Self::InsertEntry => Some(WorkflowAction::InsertEntry), + Self::GenerateEntry => Some(WorkflowAction::GenerateEntry), + Self::Grep => Some(WorkflowAction::Grep), + Self::RemoveEntry => Some(WorkflowAction::RemoveEntry), + Self::MoveEntry => Some(WorkflowAction::MoveEntry), + Self::CopyEntry => Some(WorkflowAction::CopyEntry), + Self::GitPull => Some(WorkflowAction::GitPull), + Self::GitPush => Some(WorkflowAction::GitPush), + _ => None, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct KeyBinding { pub code: KeyCode, pub modifiers: KeyModifiers, + pub then: Option<(KeyCode, KeyModifiers)>, pub display: &'static str, } +impl KeyBinding { + fn is_single(self) -> bool { + self.then.is_none() + } + + fn starts_with(self, code: KeyCode, modifiers: KeyModifiers) -> bool { + self.code == code && self.modifiers == modifiers + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ActionSpec { pub action: Action, @@ -65,10 +115,46 @@ impl ActionSpec { pub fn is_available(self, mode: Mode) -> bool { self.modes.contains(&mode) } + + pub fn help(self) -> &'static str { + match self.action { + Action::RemoveEntry => "destructive; opens confirmation (y accepts, n/Esc cancels)", + Action::ConfirmDiscard => "confirm the destructive dialog choice", + Action::KeepEditing => "decline the destructive dialog choice", + Action::GitPull | Action::GitPush => "two-key Git sequence", + Action::Lock => "manual lock; unsaved secrets are discarded", + Action::Command => "other Git operations are available through command mode", + _ => "", + } + } + + pub fn is_compact(self) -> bool { + matches!( + self.action, + Action::Quit + | Action::Help + | Action::Cancel + | Action::Lock + | Action::Next + | Action::Previous + | Action::Activate + | Action::Filter + | Action::FocusNext + | Action::Copy + | Action::EditEntry + | Action::BeginInput + | Action::SaveEditor + | Action::ConfirmDiscard + | Action::KeepEditing + | Action::Unlock + ) + } } const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer]; +const ENTRY_CONTEXT: &[Mode] = &[Mode::Browser, Mode::Viewer]; const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor]; +const QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked]; const LOCKABLE: &[Mode] = &[ Mode::Browser, Mode::Viewer, @@ -91,7 +177,23 @@ const ALL: &[Mode] = &[ macro_rules! keys { ($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => { - &[$(KeyBinding { code: $code, modifiers: $modifiers, display: $display }),+] + &[$(KeyBinding { + code: $code, + modifiers: $modifiers, + then: None, + display: $display, + }),+] + }; +} + +macro_rules! sequences { + ($(($code:expr, $modifiers:expr, $then_code:expr, $then_modifiers:expr, $display:expr)),+ $(,)?) => { + &[$(KeyBinding { + code: $code, + modifiers: $modifiers, + then: Some(($then_code, $then_modifiers)), + display: $display, + }),+] }; } @@ -101,7 +203,7 @@ pub static ACTIONS: &[ActionSpec] = &[ label: "quit", command: "quit", bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")), - modes: BROWSER_LIKE, + modes: QUITTABLE, }, ActionSpec { action: Action::Help, @@ -378,9 +480,91 @@ pub static ACTIONS: &[ActionSpec] = &[ bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), modes: &[Mode::Dialog], }, + ActionSpec { + action: Action::Initialize, + label: "initialize recipients", + command: "init", + bindings: keys!((KeyCode::Char('I'), KeyModifiers::SHIFT, "I")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::InsertEntry, + label: "insert entry", + command: "insert", + bindings: keys!((KeyCode::Char('i'), KeyModifiers::NONE, "i")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::GenerateEntry, + label: "generate entry", + command: "generate", + bindings: keys!((KeyCode::Char('p'), KeyModifiers::NONE, "p")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::Grep, + label: "grep decrypted entries", + command: "grep", + bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")), + modes: &[Mode::Browser], + }, + ActionSpec { + action: Action::RemoveEntry, + label: "remove entry", + command: "remove-entry", + bindings: sequences!(( + KeyCode::Char('d'), + KeyModifiers::NONE, + KeyCode::Char('d'), + KeyModifiers::NONE, + "d d" + )), + modes: ENTRY_CONTEXT, + }, + ActionSpec { + action: Action::MoveEntry, + label: "move entry", + command: "move-entry", + bindings: keys!((KeyCode::Char('m'), KeyModifiers::NONE, "m")), + modes: ENTRY_CONTEXT, + }, + ActionSpec { + action: Action::CopyEntry, + label: "copy entry", + command: "copy-entry", + bindings: keys!((KeyCode::Char('c'), KeyModifiers::NONE, "c")), + modes: ENTRY_CONTEXT, + }, + ActionSpec { + action: Action::GitPull, + label: "Git pull", + command: "git-pull", + bindings: sequences!(( + KeyCode::Char('g'), + KeyModifiers::NONE, + KeyCode::Char('p'), + KeyModifiers::NONE, + "g p" + )), + modes: BROWSER_LIKE, + }, + ActionSpec { + action: Action::GitPush, + label: "Git push", + command: "git-push", + bindings: sequences!(( + KeyCode::Char('g'), + KeyModifiers::NONE, + KeyCode::Char('P'), + KeyModifiers::SHIFT, + "g P" + )), + modes: BROWSER_LIKE, + }, ]; pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option { + let modifiers = normalize_modifiers(code, modifiers); ACTIONS .iter() .find(|spec| { @@ -388,15 +572,94 @@ pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option && spec .bindings .iter() - .any(|binding| binding.code == code && binding.modifiers == modifiers) + .any(|binding| binding.is_single() && binding.starts_with(code, modifiers)) }) .map(|spec| spec.action) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum KeyResolution { + Action(Action), + Pending, + Unavailable, +} + +#[derive(Debug, Default)] +pub struct KeyResolver { + mode: Option, + pending: Option<(KeyCode, KeyModifiers)>, +} + +impl KeyResolver { + pub fn feed(&mut self, mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> KeyResolution { + if self.mode != Some(mode) { + self.pending = None; + self.mode = Some(mode); + } + let modifiers = normalize_modifiers(code, modifiers); + if let Some((first_code, first_modifiers)) = self.pending.take() + && code != KeyCode::Esc + { + return ACTIONS + .iter() + .filter(|spec| spec.is_available(mode)) + .find_map(|spec| { + spec.bindings + .iter() + .any(|binding| { + binding.starts_with(first_code, first_modifiers) + && binding.then == Some((code, modifiers)) + }) + .then_some(KeyResolution::Action(spec.action)) + }) + .unwrap_or(KeyResolution::Unavailable); + } + + if let Some(action) = resolve_key(mode, code, modifiers) { + return KeyResolution::Action(action); + } + if ACTIONS + .iter() + .filter(|spec| spec.is_available(mode)) + .flat_map(|spec| spec.bindings) + .any(|binding| !binding.is_single() && binding.starts_with(code, modifiers)) + { + self.pending = Some((code, modifiers)); + KeyResolution::Pending + } else { + KeyResolution::Unavailable + } + } + + pub fn reset(&mut self) { + self.pending = None; + self.mode = None; + } +} + +fn normalize_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyModifiers { + let mut normalized = modifiers + & (KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER); + if code == KeyCode::BackTab { + normalized.insert(KeyModifiers::SHIFT); + } + normalized +} + pub fn available_actions(mode: Mode) -> impl Iterator { ACTIONS.iter().filter(move |spec| spec.is_available(mode)) } +pub fn context_actions(mode: Mode) -> impl Iterator { + available_actions(mode).filter(|spec| spec.is_compact()) +} + +pub fn help_actions(context: Mode) -> impl Iterator { + ACTIONS + .iter() + .filter(move |spec| spec.is_available(context) || spec.is_available(Mode::Help)) +} + #[cfg(test)] mod tests { use super::*; @@ -414,6 +677,12 @@ mod tests { for binding in action.bindings { for other_binding in other.bindings { assert_ne!(binding, other_binding); + assert!( + !(binding + .starts_with(other_binding.code, other_binding.modifiers) + && (binding.is_single() || other_binding.is_single())), + "a single binding may not prefix another binding in {mode:?}" + ); } } } @@ -427,10 +696,17 @@ mod tests { for mode in ALL { for spec in available_actions(*mode) { for binding in spec.bindings { - assert_eq!( - resolve_key(*mode, binding.code, binding.modifiers), - Some(spec.action) - ); + let mut resolver = KeyResolver::default(); + let first = resolver.feed(*mode, binding.code, binding.modifiers); + if let Some((code, modifiers)) = binding.then { + assert_eq!(first, KeyResolution::Pending); + assert_eq!( + resolver.feed(*mode, code, modifiers), + KeyResolution::Action(spec.action) + ); + } else { + assert_eq!(first, KeyResolution::Action(spec.action)); + } } } } @@ -439,4 +715,73 @@ mod tests { None ); } + + #[test] + fn mode_precedence_and_terminal_key_normalization_are_explicit() { + let mut resolver = KeyResolver::default(); + assert_eq!( + resolver.feed(Mode::Browser, KeyCode::Char('i'), KeyModifiers::NONE), + KeyResolution::Action(Action::InsertEntry) + ); + assert_eq!( + resolver.feed(Mode::Editor, KeyCode::Char('i'), KeyModifiers::NONE), + KeyResolution::Action(Action::BeginInput) + ); + assert_eq!( + resolver.feed(Mode::Editor, KeyCode::BackTab, KeyModifiers::NONE), + KeyResolution::Action(Action::FocusPrevious) + ); + assert_eq!( + resolver.feed(Mode::Viewer, KeyCode::Down, KeyModifiers::NONE), + KeyResolution::Action(Action::ScrollDown) + ); + } + + #[test] + fn repeated_sequences_unavailable_keys_and_escape_are_deterministic() { + let mut resolver = KeyResolver::default(); + assert_eq!( + resolver.feed(Mode::Browser, KeyCode::Char('d'), KeyModifiers::NONE), + KeyResolution::Pending + ); + assert_eq!( + resolver.feed(Mode::Browser, KeyCode::Char('d'), KeyModifiers::NONE), + KeyResolution::Action(Action::RemoveEntry) + ); + assert_eq!( + resolver.feed(Mode::Viewer, KeyCode::Char('g'), KeyModifiers::NONE), + KeyResolution::Pending + ); + assert_eq!( + resolver.feed(Mode::Viewer, KeyCode::Esc, KeyModifiers::NONE), + KeyResolution::Action(Action::CloseEntry) + ); + assert_eq!( + resolver.feed(Mode::Browser, KeyCode::Char('g'), KeyModifiers::NONE), + KeyResolution::Pending + ); + assert_eq!( + resolver.feed(Mode::Browser, KeyCode::Char('x'), KeyModifiers::NONE), + KeyResolution::Unavailable + ); + assert_eq!( + resolver.feed(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE), + KeyResolution::Unavailable + ); + } + + #[test] + fn confirmation_shortcuts_are_mode_scoped_and_discoverable() { + let mut resolver = KeyResolver::default(); + assert_eq!( + resolver.feed(Mode::Dialog, KeyCode::Char('y'), KeyModifiers::NONE), + KeyResolution::Action(Action::ConfirmDiscard) + ); + assert_eq!( + resolver.feed(Mode::Dialog, KeyCode::Char('n'), KeyModifiers::NONE), + KeyResolution::Action(Action::KeepEditing) + ); + assert!(help_actions(Mode::Dialog).any(|spec| spec.action == Action::ConfirmDiscard)); + assert!(help_actions(Mode::Dialog).any(|spec| spec.action == Action::KeepEditing)); + } } diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index ceb86b6..dc5bcdd 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -13,7 +13,7 @@ use ironstorage::{ }; use crate::{ - action::Action, + action::{Action, WorkflowAction}, editor::EntryEditor, sidebar::{Sidebar, SidebarIntent}, viewer::EntryViewer, @@ -137,6 +137,7 @@ pub enum AppEffect { entry: String, editor: Box, }, + OpenWorkflow(WorkflowAction), ManualLock, } @@ -200,6 +201,14 @@ impl App { self.mode } + pub fn help_context_mode(&self) -> Mode { + if self.mode == Mode::Help { + self.suspended_mode.unwrap_or(Mode::Browser) + } else { + self.mode + } + } + pub fn focus(&self) -> PaneFocus { self.focus } @@ -434,7 +443,7 @@ impl App { return AppEffect::None; } match action { - Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => { + Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer | Mode::Locked) => { self.should_quit = true; } Action::Help => { @@ -618,6 +627,19 @@ impl App { } Action::ConfirmDiscard => self.discard_editor(), Action::KeepEditing => self.keep_editing(), + Action::Initialize + | Action::InsertEntry + | Action::GenerateEntry + | Action::Grep + | Action::RemoveEntry + | Action::MoveEntry + | Action::CopyEntry + | Action::GitPull + | Action::GitPush => { + let workflow = action.workflow().expect("matched workflow action"); + self.status = format!("Selected {} workflow", workflow_label(workflow)); + return AppEffect::OpenWorkflow(workflow); + } Action::Quit => {} } AppEffect::None @@ -931,6 +953,20 @@ impl App { } } +fn workflow_label(workflow: WorkflowAction) -> &'static str { + match workflow { + WorkflowAction::Initialize => "initialization", + WorkflowAction::InsertEntry => "entry insertion", + WorkflowAction::GenerateEntry => "entry generation", + WorkflowAction::Grep => "decrypted grep", + WorkflowAction::RemoveEntry => "entry removal", + WorkflowAction::MoveEntry => "entry move", + WorkflowAction::CopyEntry => "entry copy", + WorkflowAction::GitPull => "Git pull", + WorkflowAction::GitPush => "Git push", + } +} + #[cfg(test)] mod tests { use super::*; @@ -1135,4 +1171,20 @@ mod tests { b"generated-under-help" ); } + + #[test] + fn direct_workflow_keys_dispatch_typed_ui_effects_without_domain_work() { + let mut app = App::new(); + assert!(matches!( + app.dispatch(Action::InsertEntry), + AppEffect::OpenWorkflow(WorkflowAction::InsertEntry) + )); + assert!(app.status().contains("entry insertion")); + app.open_test_document("email/personal", fixture_document("email/personal")); + assert!(matches!( + app.dispatch(Action::RemoveEntry), + AppEffect::OpenWorkflow(WorkflowAction::RemoveEntry) + )); + assert!(app.status().contains("entry removal")); + } } diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 6952454..4ebf7cc 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -22,7 +22,7 @@ use crossterm::event::{self, Event, KeyEventKind}; use ratatui::DefaultTerminal; use crate::{ - action::resolve_key, + action::{KeyResolution, KeyResolver}, app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData}, runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent}, }; @@ -54,6 +54,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut clipboard_cancellations = ClipboardCancellations::default(); let mut authentication = None; let mut authentication_initialized = false; + let mut key_resolver = KeyResolver::default(); let startup = app.begin_latest_request(); executor.submit(startup, || { load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup))) @@ -93,7 +94,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { } match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { + Event::Key(key) if is_dispatchable_key_kind(key.kind) => { if let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.touch_user_activity() { @@ -105,37 +106,40 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { | crossterm::event::KeyModifiers::SUPER, ) && app.handle_editor_input(key.code) { + key_resolver.reset(); continue; } if app.sidebar().is_editing_filter() { + key_resolver.reset(); if handle_filter_key(&mut app, key.code) { submit_filter(&mut app, &executor); } - } else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) { - match app.dispatch(action) { - AppEffect::RefreshTree => { - if let Some(config) = app.config().cloned() { - let token = app.begin_latest_request(); - executor.submit(token, move || { - load_tree(&config).map(AsyncPayload::Refreshed) - }); + } else { + match key_resolver.feed(app.mode(), key.code, key.modifiers) { + KeyResolution::Action(action) => match app.dispatch(action) { + AppEffect::RefreshTree => { + if let Some(config) = app.config().cloned() { + let token = app.begin_latest_request(); + executor.submit(token, move || { + load_tree(&config).map(AsyncPayload::Refreshed) + }); + } } - } - AppEffect::AuthenticateEntry(entry) => { - if let Some(coordinator) = authentication.as_mut() { - coordinator.request(entry); - } else { - app.authentication_failed( - "operating-system secure storage is unavailable".to_owned(), - ); + AppEffect::AuthenticateEntry(entry) => { + if let Some(coordinator) = authentication.as_mut() { + coordinator.request(entry); + } else { + app.authentication_failed( + "operating-system secure storage is unavailable".to_owned(), + ); + } } - } - AppEffect::CopyFocused(value) => { - if let Some(config) = app.config().cloned() { - let (cancel, cancellation) = mpsc::channel(); - clipboard_cancellations.register(cancel); - let token = app.begin_request(); - executor.submit(token, move || { + AppEffect::CopyFocused(value) => { + if let Some(config) = app.config().cloned() { + let (cancel, cancellation) = mpsc::channel(); + clipboard_cancellations.register(cancel); + let token = app.begin_request(); + executor.submit(token, move || { let mut clipboard = ironstorage::presentation::NativeClipboardManager::system( config.clipboard_timeout(), @@ -155,40 +159,49 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { .map(AsyncPayload::ClipboardFinished) .map_err(|error| error.to_string()) }); + } } - } - AppEffect::GenerateField(target) => { - let token = app.begin_request(); - executor.submit(token, move || { - ironstorage::generate::GeneratorConfig::pass_defaults() - .generate_secret(None, false) - .map(|password| AsyncPayload::GeneratedField { - target, - password, - }) - .map_err(|error| error.to_string()) - }); - } - AppEffect::SaveDocument { - config, - entry, - editor, - } => { - let token = app.begin_request(); - executor - .submit(token, move || Ok(save_document(&config, entry, editor))); - } - AppEffect::ManualLock => { - if let Some(coordinator) = authentication.as_mut() - && let Err(error) = coordinator.lock() - { - app.forced_relock("manual lock requested"); - app.report_status(format!( - "locked after secure-store cleanup failed: {error}" - )); + AppEffect::GenerateField(target) => { + let token = app.begin_request(); + executor.submit(token, move || { + ironstorage::generate::GeneratorConfig::pass_defaults() + .generate_secret(None, false) + .map(|password| AsyncPayload::GeneratedField { + target, + password, + }) + .map_err(|error| error.to_string()) + }); } + AppEffect::SaveDocument { + config, + entry, + editor, + } => { + let token = app.begin_request(); + executor.submit(token, move || { + Ok(save_document(&config, entry, editor)) + }); + } + AppEffect::ManualLock => { + if let Some(coordinator) = authentication.as_mut() + && let Err(error) = coordinator.lock() + { + app.forced_relock("manual lock requested"); + app.report_status(format!( + "locked after secure-store cleanup failed: {error}" + )); + } + } + AppEffect::OpenWorkflow(_) => {} + AppEffect::None => {} + }, + KeyResolution::Pending => { + app.report_status("Key sequence pending; Esc cancels".to_owned()); + } + KeyResolution::Unavailable => { + app.report_status("Key is unavailable in the current mode".to_owned()); } - AppEffect::None => {} } } } @@ -205,6 +218,10 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { Ok(()) } +fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool { + matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat) +} + fn load_startup() -> Result { let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; let repository = ironstorage::repository::Repository::open(config.vault()) @@ -381,6 +398,13 @@ mod tests { use super::*; use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from}; + #[test] + fn press_and_terminal_repeat_events_dispatch_but_release_does_not() { + assert!(is_dispatchable_key_kind(KeyEventKind::Press)); + assert!(is_dispatchable_key_kind(KeyEventKind::Repeat)); + assert!(!is_dispatchable_key_kind(KeyEventKind::Release)); + } + #[test] fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() { let temporary = tempfile::tempdir().expect("temporary editor store"); diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 5f95b40..551b1da 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use crate::{ - action::available_actions, + action::{context_actions, help_actions}, app::{App, Mode, PaneFocus}, editor::EntryEditor, viewer::EntryViewer, @@ -78,25 +78,31 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { } if app.mode() == Mode::Help { - let available_rows = usize::from(area.height.saturating_sub(2)).max(1); - let lines = (0..available_rows).map(|row| { - let mut spans = Vec::new(); - for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) { - let spec = &crate::action::ACTIONS[index]; + let lines = help_actions(app.help_context_mode()).map(|spec| { + let bindings = spec + .bindings + .iter() + .map(|binding| binding.display) + .collect::>() + .join(", "); + let mut spans = vec![ + Span::styled(format!("{bindings:>14}"), Style::default().fg(Color::Cyan)), + Span::raw(format!(" {:<24} :{:<18}", spec.label, spec.command)), + ]; + if !spec.help().is_empty() { spans.push(Span::styled( - format!("{:>6}", spec.bindings[0].display), - Style::default().fg(Color::Cyan), + spec.help(), + Style::default().fg(Color::Yellow), )); - spans.push(Span::raw(format!( - " {:<16} :{:<16}", - spec.label, spec.command - ))); } Line::from(spans) }); frame.render_widget( Paragraph::new(lines.collect::>()) - .block(Block::bordered().title("Contextual help")) + .block(Block::bordered().title(format!( + "Contextual help — {}", + mode_title(app.help_context_mode()) + ))) .wrap(Wrap { trim: false }), area, ); @@ -403,7 +409,7 @@ fn status_line(app: &App) -> Paragraph<'_> { } fn context_line(app: &App) -> Paragraph<'static> { - let text = available_actions(app.mode()) + let text = context_actions(app.mode()) .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label)) .collect::>() .join(" "); @@ -489,10 +495,22 @@ mod tests { let mut app = App::new(); assert!(app.transition(Transition::OpenHelp)); let output = render(140, 35, &app); - for spec in crate::action::ACTIONS { + for spec in crate::action::help_actions(Mode::Browser) { assert!(output.contains(spec.label)); assert!(output.contains(spec.command)); } + assert!(output.contains("d d")); + assert!(output.contains("confirmation")); + assert!(output.contains("g p")); + assert!(!output.contains("edit field")); + + app.dispatch(crate::action::Action::Cancel); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(crate::action::Action::Help); + let viewer_help = render(140, 35, &app); + assert!(viewer_help.contains("edit entry")); + assert!(viewer_help.contains("copy field")); + assert!(!viewer_help.contains("insert entry")); } #[test]