diff options
| author | srdusr <[email protected]> | 2026-05-13 19:47:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-13 19:47:00 +0200 |
| commit | 670c9845129fc0c4b6e0192d657eea05e1a64b49 (patch) | |
| tree | 273573d7ed109af3d39e268a6d43787b831c9d47 /crates | |
| parent | 646b37e7e3aa5931079c6b9e804f420bb43c74d5 (diff) | |
| download | srdwm-670c9845129fc0c4b6e0192d657eea05e1a64b49.tar.gz srdwm-670c9845129fc0c4b6e0192d657eea05e1a64b49.zip | |
Expose key bindings over IPC so a launcher can list them
Asked whether srdwm's bindings show in the AGS launcher. They could not:
srd.bind lives entirely in the Lua engine, and nothing published a binding
anywhere a client could read it. There was no IPC command, no field in any
response, and bound_keys() was used only by main.rs to register grabs.
srd.bind now takes an optional third argument, a description, and the loaded
set is copied into the WindowManager after the initial load and after every
reload. Core neither owns nor interprets them - it has no Lua state and
never dispatches a key - it holds them so the IPC layer, which is handed a
WindowManager and nothing else, can serve them. New `srd keybindings` returns
combo and description pairs, sorted so a UI listing them does not reshuffle
on every refresh.
Every binding in the shipped config now carries a description, so the feature
is useful without the user writing any.
Verified live in a nested instance: 46 bindings published, 0 without a
description, and editing the config file updated the list without a restart
(which also exercised reload-on-write again).
Also verified, for the separate report that windows cannot be moved to
another workspace from the AGS workspace pills: the compositor side works.
`srd dispatch move workspace <id> 2` moved a window from workspace 1 to 2 and
correctly hid it, since workspace 1 was active. Nothing to fix here; the
missing piece is on the shell side.
515 tests pass, clippy clean.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/config/src/engine/general.rs | 8 | ||||
| -rw-r--r-- | crates/config/src/engine/mod.rs | 22 | ||||
| -rw-r--r-- | crates/config/src/engine/support.rs | 4 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 12 | ||||
| -rw-r--r-- | crates/ctl/src/main.rs | 8 | ||||
| -rw-r--r-- | crates/platform/src/ipc/dispatch.rs | 11 | ||||
| -rw-r--r-- | crates/platform/src/ipc/types.rs | 14 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 14 |
8 files changed, 89 insertions, 4 deletions
diff --git a/crates/config/src/engine/general.rs b/crates/config/src/engine/general.rs index c8a136f..68b441e 100644 --- a/crates/config/src/engine/general.rs +++ b/crates/config/src/engine/general.rs @@ -109,7 +109,7 @@ impl Engine { pub(super) fn fn_bind(&self) -> Result<mlua::Function<'_>> { let state = self.state.clone(); - Ok(self.lua.create_function(move |lua, (combo, f): (String, mlua::Function)| { + Ok(self.lua.create_function(move |lua, (combo, f, description): (String, mlua::Function, Option<String>)| { // `key_bindings` is keyed by whatever string dispatch builds // from a real keypress (`srdwm_core::key_combo_string`, fixed // Ctrl/Shift/Alt/Mod4 order) - storing the config's own @@ -119,7 +119,11 @@ impl Engine { // grabbed/intercepted. See `parse_key_combo`'s doc comment. let combo = srdwm_core::canonicalize_key_combo(&combo); let key = lua.create_registry_value(f)?; - state.borrow_mut().key_bindings.insert(combo, key); + let mut s = state.borrow_mut(); + if let Some(description) = description.filter(|d| !d.trim().is_empty()) { + s.key_descriptions.insert(combo.clone(), description); + } + s.key_bindings.insert(combo, key); Ok(()) })?) } diff --git a/crates/config/src/engine/mod.rs b/crates/config/src/engine/mod.rs index ac7bdb8..8facd56 100644 --- a/crates/config/src/engine/mod.rs +++ b/crates/config/src/engine/mod.rs @@ -26,6 +26,15 @@ struct SharedState { wm: Rc<RefCell<WindowManager>>, values: HashMap<String, ConfigValue>, key_bindings: HashMap<String, RegistryKey>, + /// Human-readable description for a binding, from `srd.bind`'s optional + /// third argument. Separate from `key_bindings` so a binding without one + /// costs nothing, and cleared alongside it on reload. + /// + /// Exists so a launcher or cheat-sheet can list what the keys actually + /// do - a bare combo is close to useless in a UI. Published to + /// `WindowManager` by `main.rs` and served over IPC as + /// `srd keybindings`. + key_descriptions: HashMap<String, String>, /// Combos registered with `srd.bind_repeat`, which fire repeatedly while /// held (Hyprland's `binde`). A subset of `key_bindings`. repeat_keys: std::collections::HashSet<String>, @@ -67,6 +76,7 @@ impl Engine { wm, values: default_config(), key_bindings: HashMap::new(), + key_descriptions: HashMap::new(), repeat_keys: std::collections::HashSet::new(), event_handlers: HashMap::new(), config_dir: config_dir.into(), @@ -189,6 +199,18 @@ impl Engine { self.state.borrow().key_bindings.keys().cloned().collect() } + /// Every binding as `(combo, description)`, sorted by combo so the list + /// is stable between calls - a UI listing them should not reshuffle on + /// every refresh. `description` is empty when the binding did not give + /// one. + pub fn bound_keys_with_descriptions(&self) -> Vec<(String, String)> { + let state = self.state.borrow(); + let mut out: Vec<(String, String)> = + state.key_bindings.keys().map(|combo| (combo.clone(), state.key_descriptions.get(combo).cloned().unwrap_or_default())).collect(); + out.sort(); + out + } + /// Combos that should auto-repeat while held. pub fn repeat_keys(&self) -> Vec<String> { self.state.borrow().repeat_keys.iter().cloned().collect() diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs index 76bcf5e..9d81035 100644 --- a/crates/config/src/engine/support.rs +++ b/crates/config/src/engine/support.rs @@ -33,16 +33,18 @@ pub(super) fn do_reload(lua: &Lua, state: &Rc<RefCell<SharedState>>) -> Result<( std::mem::take(&mut s.key_bindings), std::mem::take(&mut s.event_handlers), std::mem::take(&mut s.repeat_keys), + std::mem::take(&mut s.key_descriptions), ); (s.config_dir.clone(), previous) }; - let restore = |state: &Rc<RefCell<SharedState>>, previous: (_, _, _)| { + let restore = |state: &Rc<RefCell<SharedState>>, previous: (_, _, _, _)| { let mut s = state.borrow_mut(); // Whatever the failed run managed to register before erroring is // discarded, not merged: half of a broken config is not a config. s.key_bindings = previous.0; s.event_handlers = previous.1; s.repeat_keys = previous.2; + s.key_descriptions = previous.3; }; let path = config_dir.join("init.lua"); let src = match std::fs::read_to_string(&path) { diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs index 2ca2bf9..a225220 100644 --- a/crates/core/src/manager/mod.rs +++ b/crates/core/src/manager/mod.rs @@ -439,6 +439,17 @@ pub struct WindowManager { /// above it. A top bar's own reservation is always honoured either /// way. Default `true` - see `input::layers::maximize_geometry_for`. pub maximize_covers_dock: bool, + /// Every key binding as `(combo, description)`, published by the main + /// loop after the config loads and after every reload. + /// + /// Core neither owns nor interprets these - it has no Lua state and + /// never dispatches a key itself. It holds them so the IPC layer, which + /// is handed a `WindowManager` and nothing else, can serve them to a + /// panel or launcher that wants to show the user what their keys do. + /// Asked for as whether "our bindings show in ags's launcher": they + /// could not, because nothing published them anywhere a client could + /// read. + pub keybindings: Vec<(String, String)>, /// Set by `request_refresh`, drained by the main loop. Same /// cross-boundary queued-request shape as `lock_requested`. refresh_requested: bool, @@ -628,6 +639,7 @@ impl WindowManager { auto_raise: false, theme: ThemeConfig::default(), lock: LockConfig::default(), + keybindings: Vec::new(), maximize_covers_dock: true, refresh_requested: false, live_settings: std::collections::BTreeMap::new(), diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs index 4689660..1153611 100644 --- a/crates/ctl/src/main.rs +++ b/crates/ctl/src/main.rs @@ -7,6 +7,7 @@ //! Usage: //! srd clients list windows, one JSON object //! srd workspaces list workspaces, one JSON object +//! srd keybindings list key bindings and descriptions //! srd settings current shadows/rounded_corners/ //! animations/night_light/reading_mode //! state, one JSON object @@ -144,6 +145,10 @@ fn build_request(args: &[String]) -> Result<String, String> { Some("clients") => Ok(r#"{"cmd":"clients"}"#.to_string()), Some("monitors") => Ok(r#"{"cmd":"monitors"}"#.to_string()), Some("workspaces") => Ok(r#"{"cmd":"workspaces"}"#.to_string()), + // Every binding the loaded config registered, with its description + // if it gave one - what a launcher or cheat-sheet needs to show + // the user their own keys. + Some("keybindings") => Ok(r#"{"cmd":"keybindings"}"#.to_string()), Some("settings") => Ok(r#"{"cmd":"settings"}"#.to_string()), Some("pinned") if args.get(1).map(String::as_str) == Some("inputs") => Ok(r#"{"cmd":"pinned_inputs"}"#.to_string()), Some("pinned") => Err("did you mean 'srd pinned inputs'?".to_string()), @@ -221,7 +226,7 @@ fn build_request(args: &[String]) -> Result<String, String> { Ok(format!(r#"{{"cmd":"set","key":"{key}","value":{value}}}"#)) } _ => Err( - "expected 'clients', 'monitors', 'workspaces', 'settings', 'keyboard layout', 'subscribe', 'dispatch <action> <id>', 'capture workspace <id> <path>', or 'set <key> <value>'" + "expected 'clients', 'monitors', 'workspaces', 'keybindings', 'settings', 'keyboard layout', 'subscribe', 'dispatch <action> <id>', 'capture workspace <id> <path>', or 'set <key> <value>'" .to_string(), ), } @@ -409,6 +414,7 @@ fn print_usage() { eprintln!(" srd clients"); eprintln!(" srd monitors"); eprintln!(" srd workspaces"); + eprintln!(" srd keybindings"); eprintln!(" srd settings"); eprintln!(" srd pinned inputs"); eprintln!(" srd keyboard layout"); diff --git a/crates/platform/src/ipc/dispatch.rs b/crates/platform/src/ipc/dispatch.rs index 749b2aa..ad79b98 100644 --- a/crates/platform/src/ipc/dispatch.rs +++ b/crates/platform/src/ipc/dispatch.rs @@ -417,6 +417,17 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<Wi // `redraw_decoration`/`apply_geometry` for every visible window // unconditionally - this only has to mutate the right field and // let that existing machinery do the rest. + // `{"cmd":"keybindings"}` - every binding the loaded config + // registered, as `(combo, description)`. Read-only, and the one + // way a panel or launcher can show the user what their own keys + // do: `srd.bind` lives entirely in the Lua engine, so nothing + // outside this process could see a binding at all before this. + "keybindings" => { + let wm = wm.borrow(); + let list: Vec<KeybindingInfo> = + wm.keybindings.iter().map(|(combo, description)| KeybindingInfo { combo: combo.clone(), description: description.clone() }).collect(); + (serde_json::to_vec(&KeybindingsResponse { keybindings: list }).unwrap_or_default(), false) + } "set" => handle_set(&req, wm), _ => (err("unknown command"), false), } diff --git a/crates/platform/src/ipc/types.rs b/crates/platform/src/ipc/types.rs index f9af26c..286df20 100644 --- a/crates/platform/src/ipc/types.rs +++ b/crates/platform/src/ipc/types.rs @@ -122,6 +122,20 @@ pub(crate) struct MonitorsResponse { pub(crate) monitors: Vec<MonitorInfo>, } +/// One key binding, for `srd keybindings`. `description` is empty when the +/// binding did not give one - an empty string rather than `null`, so a +/// consumer can render it without a branch. +#[derive(Serialize)] +pub(crate) struct KeybindingInfo { + pub(crate) combo: String, + pub(crate) description: String, +} + +#[derive(Serialize)] +pub(crate) struct KeybindingsResponse { + pub(crate) keybindings: Vec<KeybindingInfo>, +} + #[derive(Serialize)] pub(crate) struct WorkspacesResponse { pub(crate) workspaces: Vec<WorkspaceInfo>, diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index 5de7ace..85e5427 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -104,6 +104,16 @@ const RELOAD_COMBO_LITERAL: &str = "Mod4+Ctrl+r"; /// `general.config_reload_on_write` is on. See `config_mtime`. const CONFIG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); +/// Copies the loaded config's key bindings into the `WindowManager`, where +/// the IPC layer can serve them (`srd keybindings`). +/// +/// Called after the initial load and after every reload, so a rebound key +/// shows up without a restart - unlike the *grab*, which `main.rs` only +/// registers once at startup (see `Engine::reload`'s own doc comment). +fn publish_keybindings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { + wm.borrow_mut().keybindings = engine.bound_keys_with_descriptions(); +} + /// Puts a config error in front of the user instead of only in a log they /// have no reason to be reading. /// @@ -698,6 +708,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { report_config_error(&format!("Config failed to load, using built-in defaults.\n{e}")); } } + publish_keybindings(&engine, &wm); apply_workspace_count(&engine, &wm); apply_general_settings(&engine, &wm); apply_default_layout(&engine, &wm); @@ -803,6 +814,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { Err(e) => report_config_error(&format!("Config edit not applied, keeping the last working one.\n{e}")), } apply_general_settings(&engine, &wm); + publish_keybindings(&engine, &wm); // After `apply_general_settings`, which rebuilds the // theme from the config file - see // `WindowManager::live_settings` for why a hand-made @@ -825,6 +837,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { Err(e) => report_config_error(&format!("Config reload failed, keeping the last working one.\n{e}")), } apply_general_settings(&engine, &wm); + publish_keybindings(&engine, &wm); srdwm_platform::replay_live_settings(&wm); // After the reload, so a handler edited in the config since // startup is the one that runs. @@ -842,6 +855,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { Err(e) => report_config_error(&format!("Config reload failed, keeping the last working one.\n{e}")), } apply_general_settings(&engine, &wm); + publish_keybindings(&engine, &wm); srdwm_platform::replay_live_settings(&wm); } else if !engine.dispatch_keybinding(&combo) { log::debug!("no binding for '{combo}'"); |