use crossterm::event::{KeyCode, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogScope {
FilePicker,
DirectoryPicker,
RestartPending,
UpdatePrompt,
ThemePicker,
PlanOverlay,
Help,
Settings,
UsageDashboard,
UsageCacheBreakdown,
SkillsDialog,
ProfilesDialog,
MissionControl,
McDetailPopup,
McDetailRead,
McLogViewer,
ToolApproval,
ApprovePolicyMenu,
SshPassword,
}
impl DialogScope {
pub fn title(self) -> &'static str {
match self {
DialogScope::FilePicker => "File picker",
DialogScope::DirectoryPicker => "Directory picker",
DialogScope::RestartPending => "Restart",
DialogScope::UpdatePrompt => "Update",
DialogScope::ThemePicker => "Theme picker",
DialogScope::PlanOverlay => "Plan",
DialogScope::Help => "Help",
DialogScope::Settings => "Settings",
DialogScope::UsageDashboard => "Usage",
DialogScope::UsageCacheBreakdown => "Cache prices",
DialogScope::SkillsDialog => "Skills",
DialogScope::ProfilesDialog => "Profiles",
DialogScope::MissionControl => "Mission Control",
DialogScope::McDetailPopup => "Inbox detail",
DialogScope::McDetailRead => "Detail",
DialogScope::McLogViewer => "Log viewer",
DialogScope::ToolApproval => "Tool approval",
DialogScope::ApprovePolicyMenu => "Approval policy",
DialogScope::SshPassword => "SSH password",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DialogKey {
#[allow(dead_code)] pub events: &'static [(KeyCode, KeyModifiers)],
pub label: &'static str,
pub verb: &'static str,
}
const fn k(code: KeyCode) -> (KeyCode, KeyModifiers) {
(code, KeyModifiers::NONE)
}
const TABLE_1: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Up), k(KeyCode::Down)],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Enter), k(KeyCode::Tab)],
label: "Enter/Tab",
verb: "select",
},
DialogKey {
events: &[],
label: "type",
verb: "filter",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "cancel",
},
];
const TABLE_2: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Up), k(KeyCode::Down)],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "open",
},
DialogKey {
events: &[k(KeyCode::Char(' ')), k(KeyCode::Tab)],
label: "Space/Tab",
verb: "select here",
},
DialogKey {
events: &[k(KeyCode::Char('.')), k(KeyCode::Char('>'))],
label: ".",
verb: "hidden",
},
DialogKey {
events: &[],
label: "type",
verb: "filter",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "cancel",
},
];
const TABLE_3: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "confirm",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "cancel",
},
];
const TABLE_4: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::PageUp),
k(KeyCode::PageDown),
],
label: "↑↓",
verb: "preview",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "apply",
},
DialogKey {
events: &[k(KeyCode::Esc), k(KeyCode::Char('q'))],
label: "Esc/q",
verb: "cancel",
},
];
const TABLE_5: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Char('a'))],
label: "a",
verb: "approve",
},
DialogKey {
events: &[k(KeyCode::Char('d'))],
label: "d",
verb: "discard",
},
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::PageUp),
k(KeyCode::PageDown),
],
label: "↑↓",
verb: "scroll",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_6: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::PageUp),
k(KeyCode::PageDown),
],
label: "↑↓",
verb: "scroll",
},
DialogKey {
events: &[k(KeyCode::Char('/'))],
label: "/",
verb: "search",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "back",
},
];
const TABLE_7: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::PageUp),
k(KeyCode::PageDown),
],
label: "↑↓",
verb: "scroll",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "back",
},
];
const TABLE_8: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Tab), k(KeyCode::BackTab)],
label: "Tab",
verb: "switch",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "details",
},
DialogKey {
events: &[
k(KeyCode::Char('t')),
k(KeyCode::Char('w')),
k(KeyCode::Char('m')),
k(KeyCode::Char('a')),
],
label: "T/W/M/A",
verb: "period",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_9: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Tab), k(KeyCode::Up), k(KeyCode::Down)],
label: "Tab/↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "run",
},
DialogKey {
events: &[],
label: "type",
verb: "filter",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_10: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Char('n'))],
label: "n",
verb: "new",
},
DialogKey {
events: &[k(KeyCode::Char('d'))],
label: "d",
verb: "delete",
},
DialogKey {
events: &[k(KeyCode::Char('m'))],
label: "m",
verb: "migrate",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "switch",
},
DialogKey {
events: &[k(KeyCode::Tab), k(KeyCode::Up), k(KeyCode::Down)],
label: "Tab/↑↓",
verb: "navigate",
},
DialogKey {
events: &[],
label: "type",
verb: "filter",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_11: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Tab),
(KeyCode::BackTab, KeyModifiers::SHIFT),
k(KeyCode::Char('h')),
k(KeyCode::Char('l')),
],
label: "Tab/h/l",
verb: "switch panel",
},
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::Char('k')),
k(KeyCode::Char('j')),
],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "detail",
},
DialogKey {
events: &[k(KeyCode::Char('a'))],
label: "a",
verb: "apply",
},
DialogKey {
events: &[k(KeyCode::Char('r'))],
label: "r",
verb: "reject",
},
DialogKey {
events: &[
k(KeyCode::Char('d')),
k(KeyCode::Char('w')),
k(KeyCode::Char('m')),
k(KeyCode::Char('A')),
],
label: "D/W/M/A",
verb: "filter",
},
DialogKey {
events: &[k(KeyCode::Char('L'))],
label: "L",
verb: "logs",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_12: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::Char('k')),
k(KeyCode::Char('j')),
],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Char('a'))],
label: "a",
verb: "apply",
},
DialogKey {
events: &[k(KeyCode::Char('r'))],
label: "r",
verb: "reject",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_13: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::Char('k')),
k(KeyCode::Char('j')),
],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_14: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Char('/'))],
label: "/",
verb: "search",
},
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::PageUp),
k(KeyCode::PageDown),
],
label: "↑↓",
verb: "scroll",
},
DialogKey {
events: &[
k(KeyCode::Home),
k(KeyCode::Char('g')),
k(KeyCode::End),
k(KeyCode::Char('G')),
],
label: "g/G",
verb: "top/bottom",
},
DialogKey {
events: &[k(KeyCode::Char('[')), k(KeyCode::Char(']'))],
label: "[ ]",
verb: "file",
},
DialogKey {
events: &[
k(KeyCode::Char('1')),
k(KeyCode::Char('2')),
k(KeyCode::Char('3')),
k(KeyCode::Char('4')),
k(KeyCode::Char('5')),
k(KeyCode::Char('e')),
k(KeyCode::Char('w')),
k(KeyCode::Char('i')),
k(KeyCode::Char('d')),
k(KeyCode::Char('t')),
],
label: "1-5/e/w/i/d",
verb: "level",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
},
];
const TABLE_15: &[DialogKey] = &[
DialogKey {
events: &[
k(KeyCode::Up),
k(KeyCode::Down),
k(KeyCode::Left),
k(KeyCode::Right),
],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Char('a'))],
label: "a",
verb: "approve",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "confirm",
},
DialogKey {
events: &[
k(KeyCode::Char('D')),
k(KeyCode::Char('r')),
k(KeyCode::Esc),
],
label: "D/r/Esc",
verb: "deny",
},
DialogKey {
events: &[k(KeyCode::Char('V'))],
label: "V",
verb: "details",
},
];
const TABLE_16: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Up), k(KeyCode::Down)],
label: "↑↓",
verb: "navigate",
},
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "confirm",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "cancel",
},
];
const TABLE_17: &[DialogKey] = &[
DialogKey {
events: &[k(KeyCode::Enter)],
label: "Enter",
verb: "submit",
},
DialogKey {
events: &[],
label: "type",
verb: "password",
},
DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "cancel",
},
];
const TABLE_18: &[DialogKey] = &[DialogKey {
events: &[k(KeyCode::Esc)],
label: "Esc",
verb: "close",
}];
pub fn dialog_keys(scope: DialogScope) -> &'static [DialogKey] {
match scope {
DialogScope::FilePicker => TABLE_1,
DialogScope::DirectoryPicker => TABLE_2,
DialogScope::RestartPending | DialogScope::UpdatePrompt => TABLE_3,
DialogScope::ThemePicker => TABLE_4,
DialogScope::PlanOverlay => TABLE_5,
DialogScope::Help => TABLE_6,
DialogScope::Settings => TABLE_7,
DialogScope::UsageDashboard => TABLE_8,
DialogScope::UsageCacheBreakdown => TABLE_18,
DialogScope::SkillsDialog => TABLE_9,
DialogScope::ProfilesDialog => TABLE_10,
DialogScope::MissionControl => TABLE_11,
DialogScope::McDetailPopup => TABLE_12,
DialogScope::McDetailRead => TABLE_13,
DialogScope::McLogViewer => TABLE_14,
DialogScope::ToolApproval => TABLE_15,
DialogScope::ApprovePolicyMenu => TABLE_16,
DialogScope::SshPassword => TABLE_17,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::mission_control::McPanel;
use crate::tui::app::mission_control::input::{KeyOutcome, decide};
use crate::tui::app::mission_control::state::McState;
#[test]
fn every_scope_has_a_unique_table() {
for scope in all_scopes() {
let keys = dialog_keys(scope);
assert!(!keys.is_empty(), "{scope:?} has no keys");
let mut labels: Vec<&str> = keys.iter().map(|k| k.label).collect();
labels.sort_unstable();
let n = labels.len();
labels.dedup();
assert_eq!(labels.len(), n, "{scope:?} has duplicate labels");
for key in keys {
assert!(!key.verb.is_empty(), "{scope:?} empty verb");
}
}
}
fn all_scopes() -> Vec<DialogScope> {
vec![
DialogScope::FilePicker,
DialogScope::DirectoryPicker,
DialogScope::RestartPending,
DialogScope::UpdatePrompt,
DialogScope::ThemePicker,
DialogScope::PlanOverlay,
DialogScope::Help,
DialogScope::Settings,
DialogScope::UsageDashboard,
DialogScope::UsageCacheBreakdown,
DialogScope::SkillsDialog,
DialogScope::ProfilesDialog,
DialogScope::MissionControl,
DialogScope::McDetailPopup,
DialogScope::McDetailRead,
DialogScope::McLogViewer,
DialogScope::ToolApproval,
DialogScope::ApprovePolicyMenu,
DialogScope::SshPassword,
]
}
#[test]
fn mission_control_footer_keys_are_all_consumed() {
let base = McState::default();
let count = 3;
for key in dialog_keys(DialogScope::MissionControl) {
for (code, mods) in key.events {
let mut state = base.clone();
let event = crossterm::event::KeyEvent::new(*code, *mods);
let outcome = decide(&mut state, count, event);
assert_ne!(
outcome,
KeyOutcome::NotConsumed,
"footer advertises {:?} ({}) but decide() does not consume it",
code,
key.label
);
}
}
}
#[test]
fn detail_popup_footer_keys_are_all_consumed() {
let make = |focused| McState {
detail_open: true,
focused_panel: focused,
..McState::default()
};
let count = 2;
for scope in [DialogScope::McDetailPopup, DialogScope::McDetailRead] {
for key in dialog_keys(scope) {
for (code, mods) in key.events {
let mut state = make(McPanel::Inbox);
let event = crossterm::event::KeyEvent::new(*code, *mods);
let outcome = decide(&mut state, count, event);
assert_ne!(
outcome,
KeyOutcome::NotConsumed,
"{scope:?}: footer advertises {:?} ({}) but decide_with_popup does not consume it",
code,
key.label
);
}
}
}
}
#[test]
fn detail_popup_apply_and_reject_fire() {
use crossterm::event::KeyCode;
let mut state = McState {
detail_open: true,
..Default::default()
};
let count = 2;
let apply = decide(
&mut state,
count,
crossterm::event::KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert_eq!(apply, KeyOutcome::ApplySelected);
let reject = decide(
&mut state,
count,
crossterm::event::KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
);
assert_eq!(reject, KeyOutcome::RejectSelected);
}
#[test]
fn log_viewer_footer_keys_are_all_consumed() {
let make = || McState {
log_viewer: Some(crate::tui::app::mission_control::log_viewer::LogViewerState::open()),
..McState::default()
};
for key in dialog_keys(DialogScope::McLogViewer) {
for (code, mods) in key.events {
let mut state = make();
let event = crossterm::event::KeyEvent::new(*code, *mods);
let outcome = decide(&mut state, 5, event);
assert_ne!(
outcome,
KeyOutcome::NotConsumed,
"log viewer advertises {:?} ({}) but decide_in_log_viewer does not consume it",
code,
key.label
);
}
}
}
#[test]
fn plan_overlay_state_slices_are_suffixes() {
let keys = dialog_keys(DialogScope::PlanOverlay);
let from_d = keys.iter().position(|k| k.label == "d").unwrap();
let from_esc = keys.iter().position(|k| k.label == "Esc").unwrap();
assert!(from_d < from_esc);
}
#[test]
fn cache_breakdown_footer_key_closes_the_breakdown() {
use crate::usage::dashboard::{CACHE_CARD, DashboardState, UsageKeyOutcome, decide};
for key in dialog_keys(DialogScope::UsageCacheBreakdown) {
for (code, mods) in key.events {
let mut state = DashboardState {
focused_card: CACHE_CARD,
cache_detail_open: true,
..DashboardState::default()
};
let event = crossterm::event::KeyEvent::new(*code, *mods);
assert_eq!(
decide(&mut state, event),
UsageKeyOutcome::Consumed,
"cache breakdown advertises {code:?} ({}) but decide() does not consume it",
key.label
);
assert!(
!state.cache_detail_open,
"{code:?} is advertised as close but leaves the breakdown open"
);
}
}
}
}