feat(maestro-tui): TuiConfig with shipped defaults + override merge (NAP-277)
ci / rust (push) Successful in 15m8s
ci / rust (push) Successful in 15m8s
`TuiConfig::load(&TuiSection)` builds the runtime config:
1. Apply shipped default Normal-mode bindings (cover every v1 action).
2. Insert mode binds the leader → `LeaveInsert` (built from the leader spec
so rebinding leader moves the Insert escape automatically).
3. Apply user overrides from `[tui.keymap.normal]` / `[tui.keymap.insert]`
over the defaults, with the same validation path — bad TOML entries
surface as `BadKeymap { mode, input, action, source }`.
Also adjusts the existing `MaestroConfigFile` round-trip test to include the
new `tui` field.
8 new unit tests passing (49 total for tui + config).
This commit is contained in:
@@ -395,6 +395,12 @@ mod tests {
|
||||
daemon_url: Some("http://127.0.0.1:7070".into()),
|
||||
dev_proxy_target: Some("http://127.0.0.1:5173".into()),
|
||||
},
|
||||
tui: TuiSection {
|
||||
leader: Some("ctrl-b".into()),
|
||||
chord_timeout_ms: Some(750),
|
||||
keymap_normal: BTreeMap::from([("i".into(), "ShowHelp".into())]),
|
||||
keymap_insert: BTreeMap::new(),
|
||||
},
|
||||
};
|
||||
cfg.save(&path).unwrap();
|
||||
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
|
||||
|
||||
@@ -168,7 +168,9 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_bad_direction_arg() {
|
||||
let err = Action::parse("FocusPane(Sideways)").unwrap_err();
|
||||
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane"));
|
||||
assert!(
|
||||
matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! `TuiConfig` — runtime configuration for the TUI, built from the on-disk
|
||||
//! [`maestro_config::TuiSection`].
|
||||
//!
|
||||
//! The TUI ships with a default keymap that covers every action; entries in
|
||||
//! `[tui.keymap.normal]` / `[tui.keymap.insert]` are applied **over** those
|
||||
//! defaults. The shipped defaults are not contractual — they will be tuned
|
||||
//! during dogfooding; users override what they don't like.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_config::TuiSection;
|
||||
|
||||
use crate::input::InputKey;
|
||||
use crate::keymap::{Keymap, KeymapError, Mode};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TuiConfig {
|
||||
pub leader: InputKey,
|
||||
pub chord_timeout: Duration,
|
||||
pub keymap: Keymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TuiConfigError {
|
||||
#[error("invalid leader key {0:?}")]
|
||||
BadLeader(String),
|
||||
#[error("invalid keymap entry [{mode:?}] {input:?} = {action:?}: {source}")]
|
||||
BadKeymap {
|
||||
mode: Mode,
|
||||
input: String,
|
||||
action: String,
|
||||
source: KeymapError,
|
||||
},
|
||||
}
|
||||
|
||||
/// Default Normal-mode bindings. Tunable during dogfooding — not contract.
|
||||
/// Every variant of [`crate::action::Action`] reachable in v1 is covered.
|
||||
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// Mode
|
||||
("i", "EnterInsert"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "OpenSpawnModal"),
|
||||
("x", "KillHighlighted"),
|
||||
// Tabs
|
||||
("ctrl-t", "NewTab"),
|
||||
("g t", "NextTab"),
|
||||
("g T", "PrevTab"),
|
||||
// Focus
|
||||
("ctrl-w h", "FocusSidebar"),
|
||||
// Chrome
|
||||
("ctrl-w b", "ToggleSidebar"),
|
||||
("?", "ShowHelp"),
|
||||
("ctrl-w q", "Quit"),
|
||||
// Mouse
|
||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||
("mouse:click(tab)", "NextTab"),
|
||||
];
|
||||
|
||||
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
||||
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so
|
||||
/// rebinding the leader rebinds its Insert escape automatically.
|
||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
||||
|
||||
impl TuiConfig {
|
||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
||||
/// applied first, then user overrides on top.
|
||||
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||
let leader = InputKey::parse(leader_str)
|
||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||
let chord_timeout = Duration::from_millis(section.chord_timeout_ms.unwrap_or(1000));
|
||||
|
||||
let mut keymap = Keymap::default();
|
||||
|
||||
for (input, action) in DEFAULT_NORMAL {
|
||||
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
||||
TuiConfigError::BadKeymap {
|
||||
mode: Mode::Normal,
|
||||
input: (*input).to_string(),
|
||||
action: (*action).to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
for (input, action) in DEFAULT_INSERT {
|
||||
keymap.set(Mode::Insert, input, action).map_err(|source| {
|
||||
TuiConfigError::BadKeymap {
|
||||
mode: Mode::Insert,
|
||||
input: (*input).to_string(),
|
||||
action: (*action).to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
// Leader → LeaveInsert is built from the leader spec so rebinding
|
||||
// leader automatically rebinds the Insert escape.
|
||||
keymap
|
||||
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
|
||||
.expect("leader canonical form is valid by construction");
|
||||
|
||||
for (input, action) in §ion.keymap_normal {
|
||||
keymap.set(Mode::Normal, input, action).map_err(|source| {
|
||||
TuiConfigError::BadKeymap {
|
||||
mode: Mode::Normal,
|
||||
input: input.clone(),
|
||||
action: action.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
for (input, action) in §ion.keymap_insert {
|
||||
keymap.set(Mode::Insert, input, action).map_err(|source| {
|
||||
TuiConfigError::BadKeymap {
|
||||
mode: Mode::Insert,
|
||||
input: input.clone(),
|
||||
action: action.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(TuiConfig {
|
||||
leader,
|
||||
chord_timeout,
|
||||
keymap,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Action;
|
||||
|
||||
#[test]
|
||||
fn defaults_cover_core_actions() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "i"),
|
||||
Some(Action::EnterInsert),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "enter"),
|
||||
Some(Action::AttachHighlighted),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
||||
Some(Action::NextTab),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_leader_is_ctrl_a_and_escapes_insert() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(cfg.leader, InputKey::ctrl_char('a'));
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-a"),
|
||||
Some(Action::LeaveInsert),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_leader_moves_insert_escape() {
|
||||
let mut section = TuiSection::default();
|
||||
section.leader = Some("ctrl-b".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(cfg.leader, InputKey::ctrl_char('b'));
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-b"),
|
||||
Some(Action::LeaveInsert),
|
||||
);
|
||||
// The old leader's binding is gone (no Insert default for it).
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_normal_override_replaces_default() {
|
||||
let mut section = TuiSection::default();
|
||||
section.keymap_normal.insert("i".into(), "ShowHelp".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "i"),
|
||||
Some(Action::ShowHelp),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_unbind_via_noop() {
|
||||
let mut section = TuiSection::default();
|
||||
section.keymap_normal.insert("i".into(), "Noop".into());
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Normal, "i"), Some(Action::Noop));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_leader_surfaces_diagnostic() {
|
||||
let mut section = TuiSection::default();
|
||||
section.leader = Some("ctrl-glorb".into());
|
||||
let err = TuiConfig::load(§ion).unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("ctrl-glorb"),
|
||||
"diagnostic missing offending leader: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_user_binding_surfaces_diagnostic() {
|
||||
let mut section = TuiSection::default();
|
||||
section
|
||||
.keymap_normal
|
||||
.insert("ctrl-glorb".into(), "EnterInsert".into());
|
||||
let err = TuiConfig::load(§ion).unwrap_err();
|
||||
let s = format!("{err}");
|
||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chord_timeout_is_applied() {
|
||||
let mut section = TuiSection::default();
|
||||
section.chord_timeout_ms = Some(250);
|
||||
let cfg = TuiConfig::load(§ion).unwrap();
|
||||
assert_eq!(cfg.chord_timeout, Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_specials() {
|
||||
for s in ["esc", "enter", "tab", "backspace", "up", "down", "left", "right"] {
|
||||
for s in [
|
||||
"esc",
|
||||
"enter",
|
||||
"tab",
|
||||
"backspace",
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||
}
|
||||
|
||||
@@ -133,11 +133,10 @@ mod tests {
|
||||
#[test]
|
||||
fn reports_bad_input_at_load() {
|
||||
let mut k = Keymap::default();
|
||||
let err = k.set(Mode::Normal, "ctrl-glorb", "EnterInsert").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, KeymapError::BadInput(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
let err = k
|
||||
.set(Mode::Normal, "ctrl-glorb", "EnterInsert")
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, KeymapError::BadInput(_)), "got {err:?}");
|
||||
assert!(format!("{err}").contains("ctrl-glorb"));
|
||||
}
|
||||
|
||||
@@ -145,10 +144,7 @@ mod tests {
|
||||
fn reports_bad_action_at_load() {
|
||||
let mut k = Keymap::default();
|
||||
let err = k.set(Mode::Normal, "i", "Frobnicate").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, KeymapError::BadAction(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(matches!(err, KeymapError::BadAction(_)), "got {err:?}");
|
||||
assert!(format!("{err}").contains("Frobnicate"));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod action;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user