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

commit3e8c367adda86b66b79de82bb1cf481b6af372f9
parent78f4da6a36
authorLucas Galante <[email protected]>
date2026-07-16 10:25
feat(input): pointer input settings in input.kdl with per-app scroll overrides

input.kdl grows `input { }` settings blocks: a top-level block holds
global pointer hardware defaults (consumed by the compositor), and an
`input { }` child inside an app domain holds that app's behavior
overrides, with mouse/trackpad/trackpoint class sub-blocks. New typed
InputSettings with class→generic fallback, resolve_setting (app class →
app generic → cce-ui domain), and cached per-app ScrollFactors.

The window runner scales wheel deltas by the resolved factors: pixel
(smooth) deltas as trackpad, discrete wheel clicks as mouse. The global
block is excluded from the client chain — the compositor already
applies it at the event source.

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

 src/backend/window_runner.rs |  10 +-
 src/input.rs                 | 252 +++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 253 insertions(+), 9 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index facdd0f..4c84515 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2427,17 +2427,21 @@ impl<A: Application> PointerHandler for EngineState<A> {
         }
 
         if has_scroll {
+            // Per-app scroll factors from input.kdl (`<app>`/`cce-ui` domain
+            // `input { }` blocks); the compositor's global device scaling has
+            // already been applied at the source.
+            let factors = crate::input::scroll_factors();
             let delta = if discrete_h == 0 && discrete_v == 0 {
                 // Pixel scroll event from touchpad / smooth mouse
                 MouseScrollDelta::PixelDelta(Position {
-                    x: -coalesced_h,
-                    y: -coalesced_v,
+                    x: -coalesced_h * factors.trackpad,
+                    y: -coalesced_v * factors.trackpad,
                 })
             } else {
                 // Discrete scroll event (e.g. wheel clicks)
                 let h_lines = if discrete_h != 0 { discrete_h as f32 } else { coalesced_h as f32 / 10.0 };
                 let v_lines = if discrete_v != 0 { discrete_v as f32 } else { coalesced_v as f32 / 10.0 };
-                MouseScrollDelta::LineDelta(-h_lines, -v_lines)
+                MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
             };
             let mut rebuild = false;
             if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
diff --git a/src/input.rs b/src/input.rs
index b457c8f..c35aaf0 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -1,16 +1,33 @@
-// ~/.config/cce/input.kdl — domain-scoped keybindings for the whole desktop.
+// ~/.config/cce/input.kdl — domain-scoped keybindings and pointer input
+// settings for the whole desktop.
 //
-// Top-level nodes are DOMAINS; their children are bindings:
+// Top-level nodes are DOMAINS; their children are bindings. One top-level
+// node is special: `input { }` holds the global hardware pointer defaults
+// (accel, scroll factors per device class), consumed by the compositor.
+// Inside a domain, an `input { }` child holds that app's behavior
+// overrides, consumed client-side by this module:
 //
+//     input {                          // global hardware defaults (compositor)
+//         accel_profile "flat"
+//         accel_speed 1.0
+//         mouse { scroll_factor 1.0 }
+//         trackpad { tap_to_click true; natural_scroll true; scroll_factor 1.5 }
+//         trackpoint { accel_speed 0.5 }
+//     }
 //     cce-window-manager {
 //         close_window "super+q"
 //         spawn "super+d" command="cce-cloud --apps"
 //     }
 //     cce-ui {
-//         open_search "ctrl+f"      // toolkit-wide widget defaults
+//         open_search "ctrl+f"         // toolkit-wide widget defaults
+//         input { scroll_factor 1.0 }  // toolkit-wide scroll default
 //     }
 //     cce-files {
-//         open_search "/"           // per-app override of the cce-ui default
+//         open_search "/"              // per-app override of the cce-ui default
+//         input {
+//             scroll_factor 0.8        // both device kinds
+//             trackpad { scroll_factor 0.6 }
+//         }
 //     }
 //
 // The chord is the first string argument (a `(keybind)` type annotation is
@@ -21,6 +38,11 @@
 // 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.
+//
+// Per-app scroll factors compose with the compositor's device scaling: the
+// compositor applies the global `input` block at the event source; a client
+// then scales its own wheel deltas by the resolved app factor (pixel deltas
+// count as `trackpad`, discrete wheel clicks as `mouse`).
 
 use std::collections::BTreeMap;
 
@@ -45,9 +67,107 @@ pub struct BindingEntry {
     pub command: Option<String>,
 }
 
+/// A typed value inside an `input { }` settings block.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SettingValue {
+    Float(f64),
+    Bool(bool),
+    Str(String),
+}
+
+impl SettingValue {
+    pub fn as_f64(&self) -> Option<f64> {
+        match self {
+            SettingValue::Float(f) => Some(*f),
+            _ => None,
+        }
+    }
+
+    pub fn as_bool(&self) -> Option<bool> {
+        match self {
+            SettingValue::Bool(b) => Some(*b),
+            _ => None,
+        }
+    }
+
+    pub fn as_str(&self) -> Option<&str> {
+        match self {
+            SettingValue::Str(s) => Some(s),
+            _ => None,
+        }
+    }
+}
+
+/// Device classes an `input { }` block may scope settings to.
+pub const DEVICE_CLASSES: [&str; 3] = ["mouse", "trackpad", "trackpoint"];
+
+/// One `input { }` block: generic `name value` settings plus per-device-class
+/// sub-blocks (`mouse` / `trackpad` / `trackpoint`).
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct InputSettings {
+    values: BTreeMap<String, SettingValue>,
+    classes: BTreeMap<String, BTreeMap<String, SettingValue>>,
+}
+
+impl InputSettings {
+    fn parse(node: &kdl::KdlNode) -> InputSettings {
+        let mut settings = InputSettings::default();
+        let Some(children) = node.children() else { return settings };
+        for child in children.nodes() {
+            let name = child.name().value();
+            if DEVICE_CLASSES.contains(&name) {
+                let class = settings.classes.entry(name.to_string()).or_default();
+                if let Some(class_children) = child.children() {
+                    for leaf in class_children.nodes() {
+                        if let Some(v) = setting_value(leaf) {
+                            class.insert(leaf.name().value().to_string(), v);
+                        }
+                    }
+                }
+            } else if let Some(v) = setting_value(child) {
+                settings.values.insert(name.to_string(), v);
+            }
+        }
+        settings
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.values.is_empty() && self.classes.values().all(|c| c.is_empty())
+    }
+
+    /// A generic (class-independent) setting.
+    pub fn get(&self, key: &str) -> Option<&SettingValue> {
+        self.values.get(key)
+    }
+
+    /// A setting for one device class, falling back to the generic value.
+    pub fn get_class(&self, class: &str, key: &str) -> Option<&SettingValue> {
+        self.classes.get(class).and_then(|c| c.get(key)).or_else(|| self.get(key))
+    }
+}
+
+/// First-argument value of a settings leaf node, if it is a scalar.
+fn setting_value(node: &kdl::KdlNode) -> Option<SettingValue> {
+    let entry = node.entries().iter().find(|e| e.name().is_none())?;
+    match entry.value() {
+        kdl::KdlValue::Base10Float(f) => Some(SettingValue::Float(*f)),
+        kdl::KdlValue::Base2(i) | kdl::KdlValue::Base8(i) | kdl::KdlValue::Base10(i) | kdl::KdlValue::Base16(i) => {
+            Some(SettingValue::Float(*i as f64))
+        }
+        kdl::KdlValue::Bool(b) => Some(SettingValue::Bool(*b)),
+        kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => Some(SettingValue::Str(s.clone())),
+        kdl::KdlValue::Null => None,
+    }
+}
+
 #[derive(Debug, Clone, Default)]
 pub struct InputConfig {
     domains: BTreeMap<String, Vec<BindingEntry>>,
+    /// Per-domain `input { }` behavior overrides.
+    settings: BTreeMap<String, InputSettings>,
+    /// The top-level `input { }` block: global hardware defaults, consumed
+    /// by the compositor.
+    global: InputSettings,
 }
 
 impl InputConfig {
@@ -56,10 +176,24 @@ impl InputConfig {
     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();
+        let mut settings: BTreeMap<String, InputSettings> = BTreeMap::new();
+        let mut global = InputSettings::default();
         for domain_node in doc.nodes() {
+            // The top-level `input { }` block is global hardware defaults,
+            // not a domain.
+            if domain_node.name().value() == "input" {
+                global = InputSettings::parse(domain_node);
+                continue;
+            }
             let Some(children) = domain_node.children() else { continue };
-            let entries = domains.entry(domain_node.name().value().to_string()).or_default();
+            let domain = domain_node.name().value().to_string();
+            let entries = domains.entry(domain.clone()).or_default();
             for node in children.nodes() {
+                // A domain's `input { }` child is its settings block.
+                if node.name().value() == "input" {
+                    settings.insert(domain.clone(), InputSettings::parse(node));
+                    continue;
+                }
                 let mut chord: Option<String> = None;
                 let mut command: Option<String> = None;
                 for entry in node.entries() {
@@ -86,7 +220,7 @@ impl InputConfig {
                 }
             }
         }
-        Ok(InputConfig { domains })
+        Ok(InputConfig { domains, settings, global })
     }
 
     /// Load `input.kdl`. Missing file → empty config; a parse error is
@@ -126,6 +260,26 @@ impl InputConfig {
         self.get(app, name).or_else(|| self.get(UI_DOMAIN, name))
     }
 
+    /// The top-level `input { }` block (global hardware defaults).
+    pub fn global_input(&self) -> &InputSettings {
+        &self.global
+    }
+
+    /// One domain's `input { }` behavior overrides.
+    pub fn domain_input(&self, domain: &str) -> Option<&InputSettings> {
+        self.settings.get(domain)
+    }
+
+    /// Setting resolution for apps, most specific first: the app domain's
+    /// class value → its generic value → the cce-ui domain's class value →
+    /// its generic value. The global `input` block is deliberately NOT in
+    /// the chain — the compositor already applies it at the event source.
+    pub fn resolve_setting(&self, app: &str, class: &str, key: &str) -> Option<&SettingValue> {
+        self.domain_input(app)
+            .and_then(|s| s.get_class(class, key))
+            .or_else(|| self.domain_input(UI_DOMAIN).and_then(|s| s.get_class(class, key)))
+    }
+
     /// 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 {
@@ -195,6 +349,31 @@ pub fn cached() -> &'static InputConfig {
     CACHED.get_or_init(InputConfig::load)
 }
 
+/// This app's effective wheel-delta multipliers, resolved once per process.
+/// Pixel (smooth) deltas scale by `trackpad`, discrete clicks by `mouse`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ScrollFactors {
+    pub mouse: f64,
+    pub trackpad: f64,
+}
+
+static SCROLL_FACTORS: std::sync::OnceLock<ScrollFactors> = std::sync::OnceLock::new();
+
+pub fn scroll_factors() -> ScrollFactors {
+    *SCROLL_FACTORS.get_or_init(|| {
+        let input = cached();
+        let app = crate::config::get_app_name().unwrap_or_default();
+        let factor = |class: &str| {
+            input
+                .resolve_setting(&app, class, "scroll_factor")
+                .and_then(SettingValue::as_f64)
+                .filter(|f| f.is_finite() && *f > 0.0)
+                .unwrap_or(1.0)
+        };
+        ScrollFactors { mouse: factor("mouse"), trackpad: factor("trackpad") }
+    })
+}
+
 /// 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
@@ -266,6 +445,67 @@ cce-files {
         assert_eq!(c.resolve_chord("cce-email", "save", "ctrl+s"), "ctrl+s");
     }
 
+    const SETTINGS_SAMPLE: &str = r#"
+input {
+    accel_profile "flat"
+    accel_speed 1.0
+    mouse {
+        accel_speed 0.5
+        scroll_factor 2.0
+    }
+    trackpad {
+        tap_to_click true
+        scroll_factor 1.5
+    }
+}
+cce-ui {
+    open_search "ctrl+f"
+    input {
+        scroll_factor 1.25
+    }
+}
+cce-files {
+    open_file "enter"
+    input {
+        scroll_factor 0.8
+        trackpad {
+            scroll_factor 0.6
+        }
+    }
+}
+"#;
+
+    #[test]
+    fn parses_settings_blocks() {
+        let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
+        // The top-level input block is global, not a domain.
+        assert!(c.domain("input").is_empty());
+        let g = c.global_input();
+        assert_eq!(g.get("accel_profile").and_then(SettingValue::as_str), Some("flat"));
+        assert_eq!(g.get("accel_speed").and_then(SettingValue::as_f64), Some(1.0));
+        // Class value wins over generic; missing class value falls back.
+        assert_eq!(g.get_class("mouse", "accel_speed").and_then(SettingValue::as_f64), Some(0.5));
+        assert_eq!(g.get_class("trackpad", "accel_speed").and_then(SettingValue::as_f64), Some(1.0));
+        assert_eq!(g.get_class("trackpad", "tap_to_click").and_then(SettingValue::as_bool), Some(true));
+        // Settings blocks don't pollute the binding lists.
+        assert_eq!(c.domain("cce-files").len(), 1);
+        assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
+    }
+
+    #[test]
+    fn setting_resolution_prefers_app_then_ui_domain() {
+        let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
+        // App class value → app generic → cce-ui.
+        let f = |app: &str, class: &str| {
+            c.resolve_setting(app, class, "scroll_factor").and_then(SettingValue::as_f64)
+        };
+        assert_eq!(f("cce-files", "trackpad"), Some(0.6));
+        assert_eq!(f("cce-files", "mouse"), Some(0.8)); // generic app value
+        assert_eq!(f("cce-email", "trackpad"), Some(1.25)); // cce-ui fallback
+        // The global input block is not in the client chain.
+        assert_eq!(c.resolve_setting("cce-email", "mouse", "accel_speed"), None);
+    }
+
     #[test]
     fn upsert_domain_round_trips() {
         let entries = vec![