Define the Mutt-like TUI keymap
This commit is contained in:
@@ -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<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)]
|
||||
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<Action> {
|
||||
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<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> {
|
||||
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)]
|
||||
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 {
|
||||
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!(
|
||||
resolve_key(*mode, binding.code, binding.modifiers),
|
||||
Some(spec.action)
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EntryEditor>,
|
||||
},
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +106,17 @@ 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) {
|
||||
} 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();
|
||||
@@ -175,8 +179,9 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
editor,
|
||||
} => {
|
||||
let token = app.begin_request();
|
||||
executor
|
||||
.submit(token, move || Ok(save_document(&config, entry, editor)));
|
||||
executor.submit(token, move || {
|
||||
Ok(save_document(&config, entry, editor))
|
||||
});
|
||||
}
|
||||
AppEffect::ManualLock => {
|
||||
if let Some(coordinator) = authentication.as_mut()
|
||||
@@ -188,7 +193,15 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
));
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<StartupData, String> {
|
||||
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");
|
||||
|
||||
@@ -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::<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(
|
||||
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::<Vec<_>>())
|
||||
.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::<Vec<_>>()
|
||||
.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]
|
||||
|
||||
Reference in New Issue
Block a user