git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit78f4da6a36308b130d6b3879805c801b6b04203e
parentf1dec7f46b
authorLucas Galante <[email protected]>
date2026-07-16 09:45
feat: input.kdl — domain-scoped keybindings

New input.rs module: ~/.config/cce/input.kdl holds keybindings in
domain blocks (cce-window-manager for compositor actions, cce-ui for
toolkit-wide widget defaults, cce-<app> for per-app bindings), with
<app>.<name> → cce-ui.<name> resolution, a cached process-wide loader,
and upsert_domain/write_domain as the editor API (replaces the unused
write_keybindings_to_kdl).

The widget chord getters (tree/list open_search, list close_search)
now resolve app domain → legacy config.kdl prop → cce-ui domain →
compiled default, so every existing consumer gets domain resolution
without call-site changes.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PXSsCppeDFmSRRM5NAug5a

 src/color.rs  |  24 ++---
 src/config.rs |  57 +----------
 src/input.rs  | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs    |   1 +
 4 files changed, 319 insertions(+), 74 deletions(-)

diff --git a/src/color.rs b/src/color.rs
index 84db8c5..452478f 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -1538,12 +1538,8 @@ pub fn set_scrollbar_thumb_color(c: [f32; 4]) { if let Ok(mut lock) = SCROLLBAR_
 
 pub fn tree_open_search_key() -> String {
     load_colors_once();
-    let val = TREE_OPEN_SEARCH_KEY.read().unwrap().clone();
-    if val.is_empty() {
-        "ctrl+f".to_string()
-    } else {
-        val
-    }
+    let legacy = TREE_OPEN_SEARCH_KEY.read().unwrap().clone();
+    crate::input::widget_chord("open_search", &legacy, "ctrl+f")
 }
 pub fn set_tree_open_search_key(k: String) {
     if let Ok(mut lock) = TREE_OPEN_SEARCH_KEY.write() {
@@ -1553,12 +1549,8 @@ pub fn set_tree_open_search_key(k: String) {
 
 pub fn list_open_search_key() -> String {
     load_colors_once();
-    let val = LIST_OPEN_SEARCH_KEY.read().unwrap().clone();
-    if val.is_empty() {
-        "ctrl+f".to_string()
-    } else {
-        val
-    }
+    let legacy = LIST_OPEN_SEARCH_KEY.read().unwrap().clone();
+    crate::input::widget_chord("open_search", &legacy, "ctrl+f")
 }
 pub fn set_list_open_search_key(k: String) {
     if let Ok(mut lock) = LIST_OPEN_SEARCH_KEY.write() {
@@ -1568,12 +1560,8 @@ pub fn set_list_open_search_key(k: String) {
 
 pub fn list_close_search_key() -> String {
     load_colors_once();
-    let val = LIST_CLOSE_SEARCH_KEY.read().unwrap().clone();
-    if val.is_empty() {
-        "escape".to_string()
-    } else {
-        val
-    }
+    let legacy = LIST_CLOSE_SEARCH_KEY.read().unwrap().clone();
+    crate::input::widget_chord("close_search", &legacy, "escape")
 }
 pub fn set_list_close_search_key(k: String) {
     if let Ok(mut lock) = LIST_CLOSE_SEARCH_KEY.write() {
diff --git a/src/config.rs b/src/config.rs
index b7cc9ab..2c9b7d3 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -593,7 +593,7 @@ fn perform_rolling_backup(path: &str) {
     let _ = fs::copy(path, dst);
 }
 
-fn safe_write(path: &str, content: &str) -> bool {
+pub(crate) fn safe_write(path: &str, content: &str) -> bool {
     perform_rolling_backup(path);
     if let Some(parent) = std::path::Path::new(path).parent() {
         let _ = fs::create_dir_all(parent);
@@ -622,61 +622,6 @@ pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &
     false
 }
 
-pub fn write_keybindings_to_kdl(path: &str, keybinds: &[serde_json::Value]) -> bool {
-    let content = fs::read_to_string(path).unwrap_or_default();
-    let mut doc = match content.parse::<kdl::KdlDocument>() {
-        Ok(d) => d,
-        Err(_) => kdl::KdlDocument::new(),
-    };
-
-    // Remove all existing key_bindings nodes (root-level)
-    doc.nodes_mut().retain(|n| n.name().value() != "key_bindings");
-
-    // Construct nested key_bindings block
-    let mut block_str = "key_bindings {\n".to_string();
-    for v in keybinds {
-        if let Some(obj) = v.as_object() {
-            let mods = obj.get("mods").and_then(|m| m.as_str()).unwrap_or("");
-            let key = obj.get("key").and_then(|k| k.as_str()).unwrap_or("");
-            let action = obj.get("action").and_then(|a| a.as_str()).unwrap_or("");
-            let command = obj.get("command").and_then(|c| c.as_str()).unwrap_or("");
-
-            let full_key = if mods.is_empty() {
-                key.to_string()
-            } else {
-                format!("{}+{}", mods, key)
-            };
-
-            block_str.push_str("    bind");
-            if !action.is_empty() {
-                block_str.push_str(&format!(" action={:?}", action));
-            }
-            if !command.is_empty() {
-                block_str.push_str(&format!(" command={:?}", command));
-            }
-            if !full_key.is_empty() {
-                block_str.push_str(&format!(" key=(keybind){:?}", full_key));
-            }
-            block_str.push('\n');
-        }
-    }
-    block_str.push_str("}\n");
-
-    if let Ok(node) = block_str.parse::<kdl::KdlNode>() {
-        if let Some(input_idx) = doc.nodes().iter().position(|n| n.name().value() == "input") {
-            let input_node = &mut doc.nodes_mut()[input_idx];
-            let children = input_node.ensure_children();
-            children.nodes_mut().retain(|n| n.name().value() != "key_bindings");
-            children.nodes_mut().push(node);
-        } else {
-            doc.nodes_mut().push(node);
-        }
-    }
-
-    let updated_str = doc.to_string();
-    safe_write(path, &updated_str)
-}
-
 pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<String> {
     let doc: kdl::KdlDocument = kdl_content.parse().ok()?;
     let parts: Vec<&str> = key_path.split('.').collect();
diff --git a/src/input.rs b/src/input.rs
new file mode 100644
index 0000000..b457c8f
--- /dev/null
+++ b/src/input.rs
@@ -0,0 +1,311 @@
+// ~/.config/cce/input.kdl — domain-scoped keybindings for the whole desktop.
+//
+// Top-level nodes are DOMAINS; their children are bindings:
+//
+//     cce-window-manager {
+//         close_window "super+q"
+//         spawn "super+d" command="cce-cloud --apps"
+//     }
+//     cce-ui {
+//         open_search "ctrl+f"      // toolkit-wide widget defaults
+//     }
+//     cce-files {
+//         open_search "/"           // per-app override of the cce-ui default
+//     }
+//
+// The chord is the first string argument (a `(keybind)` type annotation is
+// accepted and ignored); a `key="..."` property works too. Extra properties
+// (e.g. `command=` for spawn) ride along on the entry.
+//
+// Resolution order for an app is `<app>.<name>` → `cce-ui.<name>`; widgets
+// match the resolved chord string with `widget::match_key_shortcut`. The
+// `cce-window-manager` domain is consumed by the compositor, which maps
+// names to policy `Action`s — chords never get interpreted here.
+
+use std::collections::BTreeMap;
+
+/// Domain holding toolkit-wide default widget bindings.
+pub const UI_DOMAIN: &str = "cce-ui";
+/// Domain holding compositor / window-management bindings.
+pub const WINDOW_MANAGER_DOMAIN: &str = "cce-window-manager";
+
+/// `~/.config/cce/input.kdl` (honoring `XDG_CONFIG_HOME`).
+pub fn get_input_path() -> std::path::PathBuf {
+    crate::config::cce_config_dir().join("input.kdl")
+}
+
+/// One binding line inside a domain block.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BindingEntry {
+    /// Node name, e.g. `open_search`. Names may repeat (several `spawn`s).
+    pub name: String,
+    /// The chord string, e.g. `"super+shift+h"`.
+    pub chord: String,
+    /// `command="..."` property, for entries that launch something.
+    pub command: Option<String>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct InputConfig {
+    domains: BTreeMap<String, Vec<BindingEntry>>,
+}
+
+impl InputConfig {
+    /// Parse the file content. Domain blocks with no children are ignored;
+    /// a child with no chord (no string argument and no `key=`) is skipped.
+    pub fn parse(content: &str) -> Result<InputConfig, String> {
+        let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("{}", e))?;
+        let mut domains: BTreeMap<String, Vec<BindingEntry>> = BTreeMap::new();
+        for domain_node in doc.nodes() {
+            let Some(children) = domain_node.children() else { continue };
+            let entries = domains.entry(domain_node.name().value().to_string()).or_default();
+            for node in children.nodes() {
+                let mut chord: Option<String> = None;
+                let mut command: Option<String> = None;
+                for entry in node.entries() {
+                    let value = match entry.value() {
+                        kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => s.clone(),
+                        _ => continue,
+                    };
+                    match entry.name().map(|n| n.value()) {
+                        None | Some("key") => {
+                            if chord.is_none() {
+                                chord = Some(value);
+                            }
+                        }
+                        Some("command") => command = Some(value),
+                        Some(_) => {}
+                    }
+                }
+                if let Some(chord) = chord {
+                    entries.push(BindingEntry {
+                        name: node.name().value().to_string(),
+                        chord,
+                        command,
+                    });
+                }
+            }
+        }
+        Ok(InputConfig { domains })
+    }
+
+    /// Load `input.kdl`. Missing file → empty config; a parse error is
+    /// logged and also yields an empty config, so callers fall back to
+    /// their defaults instead of losing all input.
+    pub fn load() -> InputConfig {
+        let path = get_input_path();
+        let Ok(content) = std::fs::read_to_string(&path) else {
+            return InputConfig::default();
+        };
+        match InputConfig::parse(&content) {
+            Ok(config) => config,
+            Err(e) => {
+                eprintln!("[cce-ui] failed to parse {}: {}", path.display(), e);
+                InputConfig::default()
+            }
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.domains.values().all(|v| v.is_empty())
+    }
+
+    /// All entries of one domain, in file order.
+    pub fn domain(&self, domain: &str) -> &[BindingEntry] {
+        self.domains.get(domain).map(Vec::as_slice).unwrap_or(&[])
+    }
+
+    /// First entry named `name` in `domain`, no fallback.
+    pub fn get(&self, domain: &str, name: &str) -> Option<&BindingEntry> {
+        self.domain(domain).iter().find(|e| e.name == name)
+    }
+
+    /// Domain resolution for apps: `<app>.<name>`, falling back to
+    /// `cce-ui.<name>`.
+    pub fn resolve(&self, app: &str, name: &str) -> Option<&BindingEntry> {
+        self.get(app, name).or_else(|| self.get(UI_DOMAIN, name))
+    }
+
+    /// Resolved chord string for widgets, with a compiled-in default as the
+    /// last resort.
+    pub fn resolve_chord(&self, app: &str, name: &str, default: &str) -> String {
+        self.resolve(app, name).map(|e| e.chord.clone()).unwrap_or_else(|| default.to_string())
+    }
+}
+
+/// Replace (or append) one domain block in `input.kdl` content, leaving all
+/// other domains and their formatting untouched. `entries` becomes the whole
+/// new block, in order; an empty slice removes the domain. Pure — the I/O
+/// wrapper is `write_domain`.
+pub fn upsert_domain(content: &str, domain: &str, entries: &[BindingEntry]) -> Result<String, String> {
+    let mut doc: kdl::KdlDocument = if content.trim().is_empty() {
+        kdl::KdlDocument::new()
+    } else {
+        content.parse().map_err(|e| format!("{}", e))?
+    };
+
+    let mut block = format!("{} {{\n", kdl_ident(domain));
+    for entry in entries {
+        block.push_str(&format!("    {} (keybind){:?}", kdl_ident(&entry.name), entry.chord));
+        if let Some(ref command) = entry.command {
+            block.push_str(&format!(" command={:?}", command));
+        }
+        block.push('\n');
+    }
+    block.push_str("}\n");
+
+    doc.nodes_mut().retain(|n| n.name().value() != domain);
+    if !entries.is_empty() {
+        let node: kdl::KdlNode = block.parse().map_err(|e| format!("{}", e))?;
+        doc.nodes_mut().push(node);
+    }
+    let mut out = doc.to_string();
+    if !out.ends_with('\n') {
+        out.push('\n');
+    }
+    Ok(out)
+}
+
+/// Quote a node name if it isn't a bare KDL identifier.
+fn kdl_ident(name: &str) -> String {
+    let bare = !name.is_empty()
+        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
+        && !name.starts_with(|c: char| c.is_ascii_digit());
+    if bare { name.to_string() } else { format!("{:?}", name) }
+}
+
+/// Rewrite one domain of the file at `path` (created if missing). This is
+/// the editor API for settings UIs and migration tools.
+pub fn write_domain(path: &std::path::Path, domain: &str, entries: &[BindingEntry]) -> Result<(), String> {
+    let content = std::fs::read_to_string(path).unwrap_or_default();
+    let updated = upsert_domain(&content, domain, entries)?;
+    let path_str = path.to_string_lossy();
+    if crate::config::safe_write(&path_str, &updated) {
+        Ok(())
+    } else {
+        Err(format!("failed to write {}", path_str))
+    }
+}
+
+static CACHED: std::sync::OnceLock<InputConfig> = std::sync::OnceLock::new();
+
+/// Process-wide cached `input.kdl`, loaded on first use. Widget-default
+/// getters go through this so the file is read once per app.
+pub fn cached() -> &'static InputConfig {
+    CACHED.get_or_init(InputConfig::load)
+}
+
+/// Chord for a widget binding, resolved by specificity: the app's own
+/// `input.kdl` domain, then the caller-supplied legacy value (per-widget
+/// `config.kdl` props), then the toolkit-wide `cce-ui` domain, then the
+/// compiled-in default.
+pub fn widget_chord(name: &str, legacy: &str, default: &str) -> String {
+    let input = cached();
+    if let Some(app) = crate::config::get_app_name() {
+        if let Some(e) = input.get(&app, name) {
+            return e.chord.clone();
+        }
+    }
+    if !legacy.is_empty() {
+        return legacy.to_string();
+    }
+    if let Some(e) = input.get(UI_DOMAIN, name) {
+        return e.chord.clone();
+    }
+    default.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const SAMPLE: &str = r#"
+cce-window-manager {
+    close_window "super+q"
+    toggle_fullscreen (keybind)"super+f"
+    spawn "super+d" command="cce-cloud --apps"
+    spawn "super+t" command="foot"
+}
+cce-ui {
+    open_search "ctrl+f"
+    close_search "escape"
+}
+cce-files {
+    open_file key="enter"
+    open_search "/"
+}
+"#;
+
+    #[test]
+    fn parses_domains_and_entries() {
+        let c = InputConfig::parse(SAMPLE).unwrap();
+        assert_eq!(c.domain(WINDOW_MANAGER_DOMAIN).len(), 4);
+        assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "close_window").unwrap().chord, "super+q");
+        // Type annotations are transparent.
+        assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "toggle_fullscreen").unwrap().chord, "super+f");
+        // Repeated names keep every entry, in order, with their commands.
+        let spawns: Vec<_> =
+            c.domain(WINDOW_MANAGER_DOMAIN).iter().filter(|e| e.name == "spawn").collect();
+        assert_eq!(spawns.len(), 2);
+        assert_eq!(spawns[0].command.as_deref(), Some("cce-cloud --apps"));
+        assert_eq!(spawns[1].chord, "super+t");
+        // key= property form.
+        assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
+    }
+
+    #[test]
+    fn resolution_prefers_app_over_ui_domain() {
+        let c = InputConfig::parse(SAMPLE).unwrap();
+        // Overridden in cce-files.
+        assert_eq!(c.resolve("cce-files", "open_search").unwrap().chord, "/");
+        // Not overridden: falls back to cce-ui.
+        assert_eq!(c.resolve("cce-files", "close_search").unwrap().chord, "escape");
+        // Unknown app: pure cce-ui fallback.
+        assert_eq!(c.resolve("cce-email", "open_search").unwrap().chord, "ctrl+f");
+        // Nowhere: compiled-in default.
+        assert_eq!(c.resolve_chord("cce-email", "save", "ctrl+s"), "ctrl+s");
+    }
+
+    #[test]
+    fn upsert_domain_round_trips() {
+        let entries = vec![
+            BindingEntry { name: "close_window".into(), chord: "super+q".into(), command: None },
+            BindingEntry {
+                name: "spawn".into(),
+                chord: "super+d".into(),
+                command: Some("cce-cloud --apps".into()),
+            },
+        ];
+        // Insert into empty content, then read back.
+        let out = upsert_domain("", WINDOW_MANAGER_DOMAIN, &entries).unwrap();
+        let parsed = InputConfig::parse(&out).unwrap();
+        assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).to_vec(), entries);
+
+        // Replace the domain without touching other domains.
+        let combined = format!("{}\n{}", SAMPLE, ""); // SAMPLE already has the domain
+        let replaced = upsert_domain(
+            &combined,
+            WINDOW_MANAGER_DOMAIN,
+            &entries[..1],
+        )
+        .unwrap();
+        let parsed = InputConfig::parse(&replaced).unwrap();
+        assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).len(), 1);
+        assert_eq!(parsed.get("cce-files", "open_search").unwrap().chord, "/");
+
+        // Empty entries removes the block entirely.
+        let removed = upsert_domain(&replaced, WINDOW_MANAGER_DOMAIN, &[]).unwrap();
+        let parsed = InputConfig::parse(&removed).unwrap();
+        assert!(parsed.domain(WINDOW_MANAGER_DOMAIN).is_empty());
+        assert_eq!(parsed.resolve("cce-files", "close_search").unwrap().chord, "escape");
+    }
+
+    #[test]
+    fn empty_and_invalid_input() {
+        assert!(InputConfig::parse("").unwrap().is_empty());
+        // Chord-less entries are skipped, childless nodes ignored.
+        let c = InputConfig::parse("cce-ui {\n    broken\n}\nstray-node\n").unwrap();
+        assert!(c.is_empty());
+        assert!(InputConfig::parse("cce-ui {").is_err());
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 4e5a9cb..1e44d3a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
 pub mod color;
 pub mod widget;
 pub mod config;
+pub mod input;
 pub mod layout;
 pub mod wayland;
 pub mod protocol;