Define the Mutt-like TUI keymap

This commit is contained in:
Hermes Agent
2026-08-10 08:03:20 +00:00
parent c82c792699
commit 639ae0d08e
4 changed files with 519 additions and 80 deletions

View File

@@ -43,15 +43,65 @@ pub enum Action {
Generate, Generate,
ConfirmDiscard, ConfirmDiscard,
KeepEditing, 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<WorkflowAction> {
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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct KeyBinding { pub struct KeyBinding {
pub code: KeyCode, pub code: KeyCode,
pub modifiers: KeyModifiers, pub modifiers: KeyModifiers,
pub then: Option<(KeyCode, KeyModifiers)>,
pub display: &'static str, 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionSpec { pub struct ActionSpec {
pub action: Action, pub action: Action,
@@ -65,10 +115,46 @@ impl ActionSpec {
pub fn is_available(self, mode: Mode) -> bool { pub fn is_available(self, mode: Mode) -> bool {
self.modes.contains(&mode) 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 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 UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor];
const QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked];
const LOCKABLE: &[Mode] = &[ const LOCKABLE: &[Mode] = &[
Mode::Browser, Mode::Browser,
Mode::Viewer, Mode::Viewer,
@@ -91,7 +177,23 @@ const ALL: &[Mode] = &[
macro_rules! keys { macro_rules! keys {
($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => { ($(($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", label: "quit",
command: "quit", command: "quit",
bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")), bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")),
modes: BROWSER_LIKE, modes: QUITTABLE,
}, },
ActionSpec { ActionSpec {
action: Action::Help, action: Action::Help,
@@ -378,9 +480,91 @@ pub static ACTIONS: &[ActionSpec] = &[
bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")),
modes: &[Mode::Dialog], 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<Action> { pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
let modifiers = normalize_modifiers(code, modifiers);
ACTIONS ACTIONS
.iter() .iter()
.find(|spec| { .find(|spec| {
@@ -388,15 +572,94 @@ pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option
&& spec && spec
.bindings .bindings
.iter() .iter()
.any(|binding| binding.code == code && binding.modifiers == modifiers) .any(|binding| binding.is_single() && binding.starts_with(code, modifiers))
}) })
.map(|spec| spec.action) .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<Mode>,
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<Item = &'static ActionSpec> { pub fn available_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
ACTIONS.iter().filter(move |spec| spec.is_available(mode)) ACTIONS.iter().filter(move |spec| spec.is_available(mode))
} }
pub fn context_actions(mode: Mode) -> impl Iterator<Item = &'static ActionSpec> {
available_actions(mode).filter(|spec| spec.is_compact())
}
pub fn help_actions(context: Mode) -> impl Iterator<Item = &'static ActionSpec> {
ACTIONS
.iter()
.filter(move |spec| spec.is_available(context) || spec.is_available(Mode::Help))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -414,6 +677,12 @@ mod tests {
for binding in action.bindings { for binding in action.bindings {
for other_binding in other.bindings { for other_binding in other.bindings {
assert_ne!(binding, other_binding); 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 mode in ALL {
for spec in available_actions(*mode) { for spec in available_actions(*mode) {
for binding in spec.bindings { for binding in spec.bindings {
assert_eq!( let mut resolver = KeyResolver::default();
resolve_key(*mode, binding.code, binding.modifiers), let first = resolver.feed(*mode, binding.code, binding.modifiers);
Some(spec.action) 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 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));
}
} }

View File

@@ -13,7 +13,7 @@ use ironstorage::{
}; };
use crate::{ use crate::{
action::Action, action::{Action, WorkflowAction},
editor::EntryEditor, editor::EntryEditor,
sidebar::{Sidebar, SidebarIntent}, sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer, viewer::EntryViewer,
@@ -137,6 +137,7 @@ pub enum AppEffect {
entry: String, entry: String,
editor: Box<EntryEditor>, editor: Box<EntryEditor>,
}, },
OpenWorkflow(WorkflowAction),
ManualLock, ManualLock,
} }
@@ -200,6 +201,14 @@ impl App {
self.mode 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 { pub fn focus(&self) -> PaneFocus {
self.focus self.focus
} }
@@ -434,7 +443,7 @@ impl App {
return AppEffect::None; return AppEffect::None;
} }
match action { 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; self.should_quit = true;
} }
Action::Help => { Action::Help => {
@@ -618,6 +627,19 @@ impl App {
} }
Action::ConfirmDiscard => self.discard_editor(), Action::ConfirmDiscard => self.discard_editor(),
Action::KeepEditing => self.keep_editing(), 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 => {} Action::Quit => {}
} }
AppEffect::None 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1135,4 +1171,20 @@ mod tests {
b"generated-under-help" 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"));
}
} }

View File

@@ -22,7 +22,7 @@ use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal; use ratatui::DefaultTerminal;
use crate::{ use crate::{
action::resolve_key, action::{KeyResolution, KeyResolver},
app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData}, app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData},
runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent}, runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent},
}; };
@@ -54,6 +54,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut clipboard_cancellations = ClipboardCancellations::default(); let mut clipboard_cancellations = ClipboardCancellations::default();
let mut authentication = None; let mut authentication = None;
let mut authentication_initialized = false; let mut authentication_initialized = false;
let mut key_resolver = KeyResolver::default();
let startup = app.begin_latest_request(); let startup = app.begin_latest_request();
executor.submit(startup, || { executor.submit(startup, || {
load_startup().map(|startup| AsyncPayload::Startup(Box::new(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()? { 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() if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.touch_user_activity() && let Some(event) = coordinator.touch_user_activity()
{ {
@@ -105,37 +106,40 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
| crossterm::event::KeyModifiers::SUPER, | crossterm::event::KeyModifiers::SUPER,
) && app.handle_editor_input(key.code) ) && app.handle_editor_input(key.code)
{ {
key_resolver.reset();
continue; continue;
} }
if app.sidebar().is_editing_filter() { if app.sidebar().is_editing_filter() {
key_resolver.reset();
if handle_filter_key(&mut app, key.code) { if handle_filter_key(&mut app, key.code) {
submit_filter(&mut app, &executor); submit_filter(&mut app, &executor);
} }
} else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) { } else {
match app.dispatch(action) { match key_resolver.feed(app.mode(), key.code, key.modifiers) {
AppEffect::RefreshTree => { KeyResolution::Action(action) => match app.dispatch(action) {
if let Some(config) = app.config().cloned() { AppEffect::RefreshTree => {
let token = app.begin_latest_request(); if let Some(config) = app.config().cloned() {
executor.submit(token, move || { let token = app.begin_latest_request();
load_tree(&config).map(AsyncPayload::Refreshed) executor.submit(token, move || {
}); load_tree(&config).map(AsyncPayload::Refreshed)
});
}
} }
} AppEffect::AuthenticateEntry(entry) => {
AppEffect::AuthenticateEntry(entry) => { if let Some(coordinator) = authentication.as_mut() {
if let Some(coordinator) = authentication.as_mut() { coordinator.request(entry);
coordinator.request(entry); } else {
} else { app.authentication_failed(
app.authentication_failed( "operating-system secure storage is unavailable".to_owned(),
"operating-system secure storage is unavailable".to_owned(), );
); }
} }
} AppEffect::CopyFocused(value) => {
AppEffect::CopyFocused(value) => { if let Some(config) = app.config().cloned() {
if let Some(config) = app.config().cloned() { let (cancel, cancellation) = mpsc::channel();
let (cancel, cancellation) = mpsc::channel(); clipboard_cancellations.register(cancel);
clipboard_cancellations.register(cancel); let token = app.begin_request();
let token = app.begin_request(); executor.submit(token, move || {
executor.submit(token, move || {
let mut clipboard = let mut clipboard =
ironstorage::presentation::NativeClipboardManager::system( ironstorage::presentation::NativeClipboardManager::system(
config.clipboard_timeout(), config.clipboard_timeout(),
@@ -155,40 +159,49 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
.map(AsyncPayload::ClipboardFinished) .map(AsyncPayload::ClipboardFinished)
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
}); });
}
} }
} AppEffect::GenerateField(target) => {
AppEffect::GenerateField(target) => { let token = app.begin_request();
let token = app.begin_request(); executor.submit(token, move || {
executor.submit(token, move || { ironstorage::generate::GeneratorConfig::pass_defaults()
ironstorage::generate::GeneratorConfig::pass_defaults() .generate_secret(None, false)
.generate_secret(None, false) .map(|password| AsyncPayload::GeneratedField {
.map(|password| AsyncPayload::GeneratedField { target,
target, password,
password, })
}) .map_err(|error| error.to_string())
.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::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(()) Ok(())
} }
fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
}
fn load_startup() -> Result<StartupData, String> { fn load_startup() -> Result<StartupData, String> {
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
let repository = ironstorage::repository::Repository::open(config.vault()) let repository = ironstorage::repository::Repository::open(config.vault())
@@ -381,6 +398,13 @@ mod tests {
use super::*; use super::*;
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from}; 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] #[test]
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() { fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
let temporary = tempfile::tempdir().expect("temporary editor store"); let temporary = tempfile::tempdir().expect("temporary editor store");

View File

@@ -9,7 +9,7 @@ use ratatui::{
}; };
use crate::{ use crate::{
action::available_actions, action::{context_actions, help_actions},
app::{App, Mode, PaneFocus}, app::{App, Mode, PaneFocus},
editor::EntryEditor, editor::EntryEditor,
viewer::EntryViewer, viewer::EntryViewer,
@@ -78,25 +78,31 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
} }
if app.mode() == Mode::Help { if app.mode() == Mode::Help {
let available_rows = usize::from(area.height.saturating_sub(2)).max(1); let lines = help_actions(app.help_context_mode()).map(|spec| {
let lines = (0..available_rows).map(|row| { let bindings = spec
let mut spans = Vec::new(); .bindings
for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) { .iter()
let spec = &crate::action::ACTIONS[index]; .map(|binding| binding.display)
.collect::<Vec<_>>()
.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( spans.push(Span::styled(
format!("{:>6}", spec.bindings[0].display), spec.help(),
Style::default().fg(Color::Cyan), Style::default().fg(Color::Yellow),
)); ));
spans.push(Span::raw(format!(
" {:<16} :{:<16}",
spec.label, spec.command
)));
} }
Line::from(spans) Line::from(spans)
}); });
frame.render_widget( frame.render_widget(
Paragraph::new(lines.collect::<Vec<_>>()) Paragraph::new(lines.collect::<Vec<_>>())
.block(Block::bordered().title("Contextual help")) .block(Block::bordered().title(format!(
"Contextual help — {}",
mode_title(app.help_context_mode())
)))
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
area, area,
); );
@@ -403,7 +409,7 @@ fn status_line(app: &App) -> Paragraph<'_> {
} }
fn context_line(app: &App) -> Paragraph<'static> { 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)) .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" "); .join(" ");
@@ -489,10 +495,22 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
assert!(app.transition(Transition::OpenHelp)); assert!(app.transition(Transition::OpenHelp));
let output = render(140, 35, &app); 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.label));
assert!(output.contains(spec.command)); 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] #[test]