From 31e1b731936dfbb256b25bb09ae227661e493d77 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Thu, 28 May 2026 19:52:41 +0000 Subject: [PATCH] feat(maestro-tui): TuiConfig with shipped defaults + override merge (NAP-277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- Cargo.lock | 142 ++++++++++++++++++- crates/maestro-config/src/lib.rs | 6 + crates/maestro-tui/src/action.rs | 4 +- crates/maestro-tui/src/config.rs | 227 +++++++++++++++++++++++++++++++ crates/maestro-tui/src/input.rs | 11 +- crates/maestro-tui/src/keymap.rs | 14 +- crates/maestro-tui/src/lib.rs | 1 + 7 files changed, 392 insertions(+), 13 deletions(-) create mode 100644 crates/maestro-tui/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 958559e..2afcb6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,32 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -909,6 +935,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -927,6 +959,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1086,6 +1127,8 @@ name = "maestro-tui" version = "0.1.0" dependencies = [ "clap", + "crossterm", + "maestro-config", "thiserror 1.0.69", ] @@ -1155,6 +1198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1213,6 +1257,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1419,6 +1486,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1544,6 +1620,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1553,7 +1642,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1638,6 +1727,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1756,6 +1851,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1846,7 +1962,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2511,6 +2627,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2520,6 +2652,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 322eb15..0b2e300 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -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); diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 8f334e0..5fa209f 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -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] diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs new file mode 100644 index 0000000..b5fd3db --- /dev/null +++ b/crates/maestro-tui/src/config.rs @@ -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 { + 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)); + } +} diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index e99ec44..f4879ae 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -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:?}"); } diff --git a/crates/maestro-tui/src/keymap.rs b/crates/maestro-tui/src/keymap.rs index 57cb1be..27b425b 100644 --- a/crates/maestro-tui/src/keymap.rs +++ b/crates/maestro-tui/src/keymap.rs @@ -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")); } diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 0c73992..36eb75f 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -10,6 +10,7 @@ pub mod action; pub mod cli; +pub mod config; pub mod input; pub mod keymap;