srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-13 19:47:00 +0200
committersrdusr <[email protected]>2026-05-13 19:47:00 +0200
commit670c9845129fc0c4b6e0192d657eea05e1a64b49 (patch)
tree273573d7ed109af3d39e268a6d43787b831c9d47 /crates
parent646b37e7e3aa5931079c6b9e804f420bb43c74d5 (diff)
downloadsrdwm-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.rs8
-rw-r--r--crates/config/src/engine/mod.rs22
-rw-r--r--crates/config/src/engine/support.rs4
-rw-r--r--crates/core/src/manager/mod.rs12
-rw-r--r--crates/ctl/src/main.rs8
-rw-r--r--crates/platform/src/ipc/dispatch.rs11
-rw-r--r--crates/platform/src/ipc/types.rs14
-rw-r--r--crates/srdwm/src/main.rs14
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}'");