From 670c9845129fc0c4b6e0192d657eea05e1a64b49 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 13 May 2026 19:47:00 +0200 Subject: 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 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. --- config/srd/keybindings.lua | 56 ++++++++++++++++++------------------- crates/config/src/engine/general.rs | 8 ++++-- crates/config/src/engine/mod.rs | 22 +++++++++++++++ crates/config/src/engine/support.rs | 4 ++- crates/core/src/manager/mod.rs | 12 ++++++++ crates/ctl/src/main.rs | 8 +++++- crates/platform/src/ipc/dispatch.rs | 11 ++++++++ crates/platform/src/ipc/types.rs | 14 ++++++++++ crates/srdwm/src/main.rs | 14 ++++++++++ docs/DEFAULTS.md | 27 ++++++++++++++++++ 10 files changed, 144 insertions(+), 32 deletions(-) diff --git a/config/srd/keybindings.lua b/config/srd/keybindings.lua index aa3e425..dc315e9 100644 --- a/config/srd/keybindings.lua +++ b/config/srd/keybindings.lua @@ -21,25 +21,25 @@ srd.bind("Mod4+3", function() end) - Window management (act on the focused window) -srd.bind("Mod4+q", function() srd.window.close() end) -srd.bind("Mod4+m", function() srd.window.minimize() end) -srd.bind("Mod4+f", function() srd.window.maximize() end) -srd.bind("Mod4+Shift+space", function() srd.window.toggle_floating() end) +srd.bind("Mod4+q", function() srd.window.close() end, "Close the focused window") +srd.bind("Mod4+m", function() srd.window.minimize() end, "Minimize the focused window") +srd.bind("Mod4+f", function() srd.window.maximize() end, "Maximize or restore the focused window") +srd.bind("Mod4+Shift+space", function() srd.window.toggle_floating() end, "Float or unfloat the focused window") - Window navigation (vim-style directional focus) -srd.bind("Mod4+h", function() srd.window.focus("left") end) -srd.bind("Mod4+j", function() srd.window.focus("down") end) -srd.bind("Mod4+k", function() srd.window.focus("up") end) -srd.bind("Mod4+l", function() srd.window.focus("right") end) +srd.bind("Mod4+h", function() srd.window.focus("left") end, "Focus the window to the left") +srd.bind("Mod4+j", function() srd.window.focus("down") end, "Focus the window to the down") +srd.bind("Mod4+k", function() srd.window.focus("up") end, "Focus the window to the up") +srd.bind("Mod4+l", function() srd.window.focus("right") end, "Focus the window to the right") - Move the focused window in a direction, mirroring the focus keys above. - `srd.window.move` has existed for a long time and simply had no default - binding, so "move window to absolute directions" was unreachable without - writing your own config. -srd.bind("Mod4+Shift+h", function() srd.window.move("left") end) -srd.bind("Mod4+Shift+j", function() srd.window.move("down") end) -srd.bind("Mod4+Shift+k", function() srd.window.move("up") end) -srd.bind("Mod4+Shift+l", function() srd.window.move("right") end) +srd.bind("Mod4+Shift+h", function() srd.window.move("left") end, "Move the window left") +srd.bind("Mod4+Shift+j", function() srd.window.move("down") end, "Move the window down") +srd.bind("Mod4+Shift+k", function() srd.window.move("up") end, "Move the window up") +srd.bind("Mod4+Shift+l", function() srd.window.move("right") end, "Move the window right") - Layout switching. srdwm is dynamic-first: "dynamic" is free placement, - the default, and tiling is one opt-in layout among several. Mod4+s @@ -50,30 +50,30 @@ srd.bind("Mod4+s", function() else srd.layout.set("tiling") end -end) +end, "Toggle tiling and dynamic layout") - Lock the session using srdwm's own built-in lock screen. `srd.lock()` - talks to the compositor directly rather than shelling out to the control - CLI, so it still works when that binary is not on PATH. -srd.bind("Mod4+Ctrl+l", function() srd.lock() end) +srd.bind("Mod4+Ctrl+l", function() srd.lock() end, "Lock the session") - Workspace management -srd.bind("Mod4+Tab", function() srd.workspace.next() end) -srd.bind("Mod4+Shift+Tab", function() srd.workspace.prev() end) +srd.bind("Mod4+Tab", function() srd.workspace.next() end, "Next workspace") +srd.bind("Mod4+Shift+Tab", function() srd.workspace.prev() end, "Previous workspace") - Workspace switching with number keys (0 doubles as "workspace 10") for i = 0, 9 do - srd.bind("Mod4+" .. i, function() srd.workspace.switch(i) end) - srd.bind("Mod4+Shift+" .. i, function() srd.workspace.move_window(i) end) + srd.bind("Mod4+" .. i, function() srd.workspace.switch(i) end, "Switch to workspace " .. i) + srd.bind("Mod4+Shift+" .. i, function() srd.workspace.move_window(i) end, "Move window to workspace " .. i) end - Quick actions -srd.bind("Mod4+d", function() srd.spawn("rofi -show drun") end) -srd.bind("Mod4+Return", function() srd.spawn("alacritty") end) -srd.bind("Mod4+r", function() srd.spawn("rofi -show run") end) +srd.bind("Mod4+d", function() srd.spawn("rofi -show drun") end, "Application launcher") +srd.bind("Mod4+Return", function() srd.spawn("alacritty") end, "Open a terminal") +srd.bind("Mod4+r", function() srd.spawn("rofi -show run") end, "Run a command") - System -srd.bind("Mod4+Shift+q", function() srd.quit() end) +srd.bind("Mod4+Shift+q", function() srd.quit() end, "Quit srdwm") - Custom function example: toggle window gaps at runtime local function toggle_gaps() @@ -85,13 +85,13 @@ local function toggle_gaps() srd.notify("Gaps: On", "info") end end -srd.bind("Mod4+g", toggle_gaps) +srd.bind("Mod4+g", toggle_gaps, "Toggle window gaps") - Media keys -srd.bind("XF86AudioRaiseVolume", function() srd.spawn("pactl set-sink-volume @DEFAULT_SINK@ +5%") end) -srd.bind("XF86AudioLowerVolume", function() srd.spawn("pactl set-sink-volume @DEFAULT_SINK@ -5%") end) -srd.bind("XF86AudioMute", function() srd.spawn("pactl set-sink-mute @DEFAULT_SINK@ toggle") end) -srd.bind("XF86MonBrightnessUp", function() srd.spawn("brightnessctl set +5%") end) -srd.bind("XF86MonBrightnessDown", function() srd.spawn("brightnessctl set 5%-") end) +srd.bind("XF86AudioRaiseVolume", function() srd.spawn("pactl set-sink-volume @DEFAULT_SINK@ +5%") end, "Volume up") +srd.bind("XF86AudioLowerVolume", function() srd.spawn("pactl set-sink-volume @DEFAULT_SINK@ -5%") end, "Volume down") +srd.bind("XF86AudioMute", function() srd.spawn("pactl set-sink-mute @DEFAULT_SINK@ toggle") end, "Mute") +srd.bind("XF86MonBrightnessUp", function() srd.spawn("brightnessctl set +5%") end, "Brightness up") +srd.bind("XF86MonBrightnessDown", function() srd.spawn("brightnessctl set 5%-") end, "Brightness down") print("Key bindings configuration loaded") 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> { 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)| { // `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>, values: HashMap, key_bindings: HashMap, + /// 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, /// Combos registered with `srd.bind_repeat`, which fire repeatedly while /// held (Hyprland's `binde`). A subset of `key_bindings`. repeat_keys: std::collections::HashSet, @@ -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 { 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>) -> 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>, previous: (_, _, _)| { + let restore = |state: &Rc>, 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 { 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 { Ok(format!(r#"{{"cmd":"set","key":"{key}","value":{value}}}"#)) } _ => Err( - "expected 'clients', 'monitors', 'workspaces', 'settings', 'keyboard layout', 'subscribe', 'dispatch ', 'capture workspace ', or 'set '" + "expected 'clients', 'monitors', 'workspaces', 'keybindings', 'settings', 'keyboard layout', 'subscribe', 'dispatch ', 'capture workspace ', or 'set '" .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 { + let wm = wm.borrow(); + let list: Vec = + 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, } +/// 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, +} + #[derive(Serialize)] pub(crate) struct WorkspacesResponse { pub(crate) workspaces: Vec, 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>) { + 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> { 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> { 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> { 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> { 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}'"); diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md index 2cc1614..abcdad1 100644 --- a/docs/DEFAULTS.md +++ b/docs/DEFAULTS.md @@ -935,3 +935,30 @@ make an app forget where it lives. Dialogs are centred instead, and take neither a remembered position nor a remembered size - window memory is keyed by `app_id`, which a dialog shares with the window that spawned it. + +## Listing key bindings + +`srd keybindings` returns every binding the loaded config registered, with a +description if it gave one: + +``` +{"keybindings":[{"combo":"Mod4+Return","description":"Open a terminal"}, ...]} +``` + +`srd.bind` takes an optional third argument for that description: + +```lua +srd.bind("Mod4+Return", function() srd.spawn("alacritty") end, "Open a terminal") +``` + +Bindings live entirely inside the Lua engine, so before this nothing outside +the compositor could see that a binding existed at all - a launcher or +cheat-sheet had no way to show the user their own keys. The list is +republished after every config reload, so a rebound key appears without a +restart. Note that a brand new *combination* still needs a restart before the +compositor grabs that key at all; an existing combination picks up its new +action immediately. + +Combos are reported in canonical order (`Ctrl+Mod4+l`), which is what the +compositor matches a real keypress against - not necessarily how the combo +was written in the config. -- cgit v1.2.3