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

src/input.rs (21.8K)

  1 // ~/.config/cce/input.kdl — domain-scoped keybindings and pointer input
  2 // settings for the whole desktop.
  3 //
  4 // Top-level nodes are DOMAINS; their children are bindings. One top-level
  5 // node is special: `input { }` holds the global hardware pointer defaults
  6 // (accel, scroll factors per device class), consumed by the compositor.
  7 // Inside a domain, an `input { }` child holds that app's behavior
  8 // overrides, consumed client-side by this module:
  9 //
 10 //     input {                          // global hardware defaults (compositor)
 11 //         accel_profile "flat"
 12 //         accel_speed 1.0
 13 //         mouse { scroll_factor 1.0 }
 14 //         trackpad { tap_to_click true; natural_scroll true; scroll_factor 1.5 }
 15 //         trackpoint { accel_speed 0.5 }
 16 //     }
 17 //     cce-window-manager {
 18 //         close_window "super+q"
 19 //         spawn "super+d" command="cce-cloud --apps"
 20 //         focus_left "swipe3_left"     // a touchpad gesture chord: swipe|pinch,
 21 //                                      // optional finger count, direction
 22 //     }
 23 //     cce-ui {
 24 //         open_search "ctrl+f"         // toolkit-wide widget defaults
 25 //         input {
 26 //             scroll_factor 1.0        // toolkit-wide scroll default
 27 //             smooth_scroll true       // wheel notches glide (widget::scroll_motion)
 28 //             scroll_ease 12.0         // glide rate, 1/s
 29 //             kinetic_scroll true      // trackpad flicks coast after the lift
 30 //             scroll_friction 6.0      // coast decay, 1/s
 31 //         }
 32 //     }
 33 //     cce-files {
 34 //         open_search "/"              // per-app override of the cce-ui default
 35 //         input {
 36 //             scroll_factor 0.8        // both device kinds
 37 //             trackpad { scroll_factor 0.6 }
 38 //         }
 39 //     }
 40 //
 41 // The chord is the first string argument (a `(keybind)` or `(gesture)` type
 42 // annotation is accepted and ignored); a `key="..."` property works too.
 43 // Extra properties (e.g. `command=` for spawn) ride along on the entry.
 44 //
 45 // Resolution order for an app is `<app>.<name>` → `cce-ui.<name>`; widgets
 46 // match the resolved chord string with `widget::match_key_shortcut`. The
 47 // `cce-window-manager` domain is consumed by the compositor, which maps
 48 // names to policy `Action`s — chords never get interpreted here.
 49 //
 50 // Per-app scroll factors compose with the compositor's device scaling: the
 51 // compositor applies the global `input` block at the event source; a client
 52 // then scales its own wheel deltas by the resolved app factor (pixel deltas
 53 // count as `trackpad`, discrete wheel clicks as `mouse`).
 54 
 55 use std::collections::BTreeMap;
 56 
 57 /// Domain holding toolkit-wide default widget bindings.
 58 pub const UI_DOMAIN: &str = "cce-ui";
 59 /// Domain holding compositor / window-management bindings.
 60 pub const WINDOW_MANAGER_DOMAIN: &str = "cce-window-manager";
 61 
 62 /// `~/.config/cce/input.kdl` (honoring `XDG_CONFIG_HOME`).
 63 pub fn get_input_path() -> std::path::PathBuf {
 64     crate::config::cce_config_dir().join("input.kdl")
 65 }
 66 
 67 /// One binding line inside a domain block.
 68 #[derive(Debug, Clone, PartialEq, Eq)]
 69 pub struct BindingEntry {
 70     /// Node name, e.g. `open_search`. Names may repeat (several `spawn`s).
 71     pub name: String,
 72     /// The chord string, e.g. `"super+shift+h"`.
 73     pub chord: String,
 74     /// `command="..."` property, for entries that launch something.
 75     pub command: Option<String>,
 76 }
 77 
 78 /// A typed value inside an `input { }` settings block.
 79 #[derive(Debug, Clone, PartialEq)]
 80 pub enum SettingValue {
 81     Float(f64),
 82     Bool(bool),
 83     Str(String),
 84 }
 85 
 86 impl SettingValue {
 87     pub fn as_f64(&self) -> Option<f64> {
 88         match self {
 89             SettingValue::Float(f) => Some(*f),
 90             _ => None,
 91         }
 92     }
 93 
 94     pub fn as_bool(&self) -> Option<bool> {
 95         match self {
 96             SettingValue::Bool(b) => Some(*b),
 97             _ => None,
 98         }
 99     }
100 
101     pub fn as_str(&self) -> Option<&str> {
102         match self {
103             SettingValue::Str(s) => Some(s),
104             _ => None,
105         }
106     }
107 }
108 
109 /// Device classes an `input { }` block may scope settings to.
110 pub const DEVICE_CLASSES: [&str; 3] = ["mouse", "trackpad", "trackpoint"];
111 
112 /// One `input { }` block: generic `name value` settings plus per-device-class
113 /// sub-blocks (`mouse` / `trackpad` / `trackpoint`).
114 #[derive(Debug, Clone, Default, PartialEq)]
115 pub struct InputSettings {
116     values: BTreeMap<String, SettingValue>,
117     classes: BTreeMap<String, BTreeMap<String, SettingValue>>,
118 }
119 
120 impl InputSettings {
121     fn parse(node: &kdl::KdlNode) -> InputSettings {
122         let mut settings = InputSettings::default();
123         let Some(children) = node.children() else { return settings };
124         for child in children.nodes() {
125             let name = child.name().value();
126             if DEVICE_CLASSES.contains(&name) {
127                 let class = settings.classes.entry(name.to_string()).or_default();
128                 if let Some(class_children) = child.children() {
129                     for leaf in class_children.nodes() {
130                         if let Some(v) = setting_value(leaf) {
131                             class.insert(leaf.name().value().to_string(), v);
132                         }
133                     }
134                 }
135             } else if let Some(v) = setting_value(child) {
136                 settings.values.insert(name.to_string(), v);
137             }
138         }
139         settings
140     }
141 
142     pub fn is_empty(&self) -> bool {
143         self.values.is_empty() && self.classes.values().all(|c| c.is_empty())
144     }
145 
146     /// A generic (class-independent) setting.
147     pub fn get(&self, key: &str) -> Option<&SettingValue> {
148         self.values.get(key)
149     }
150 
151     /// A setting for one device class, falling back to the generic value.
152     pub fn get_class(&self, class: &str, key: &str) -> Option<&SettingValue> {
153         self.classes.get(class).and_then(|c| c.get(key)).or_else(|| self.get(key))
154     }
155 }
156 
157 /// First-argument value of a settings leaf node, if it is a scalar.
158 fn setting_value(node: &kdl::KdlNode) -> Option<SettingValue> {
159     let entry = node.entries().iter().find(|e| e.name().is_none())?;
160     match entry.value() {
161         kdl::KdlValue::Base10Float(f) => Some(SettingValue::Float(*f)),
162         kdl::KdlValue::Base2(i) | kdl::KdlValue::Base8(i) | kdl::KdlValue::Base10(i) | kdl::KdlValue::Base16(i) => {
163             Some(SettingValue::Float(*i as f64))
164         }
165         kdl::KdlValue::Bool(b) => Some(SettingValue::Bool(*b)),
166         kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => Some(SettingValue::Str(s.clone())),
167         kdl::KdlValue::Null => None,
168     }
169 }
170 
171 #[derive(Debug, Clone, Default)]
172 pub struct InputConfig {
173     domains: BTreeMap<String, Vec<BindingEntry>>,
174     /// Per-domain `input { }` behavior overrides.
175     settings: BTreeMap<String, InputSettings>,
176     /// The top-level `input { }` block: global hardware defaults, consumed
177     /// by the compositor.
178     global: InputSettings,
179 }
180 
181 impl InputConfig {
182     /// Parse the file content. Domain blocks with no children are ignored;
183     /// a child with no chord (no string argument and no `key=`) is skipped.
184     pub fn parse(content: &str) -> Result<InputConfig, String> {
185         let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("{}", e))?;
186         let mut domains: BTreeMap<String, Vec<BindingEntry>> = BTreeMap::new();
187         let mut settings: BTreeMap<String, InputSettings> = BTreeMap::new();
188         let mut global = InputSettings::default();
189         for domain_node in doc.nodes() {
190             // The top-level `input { }` block is global hardware defaults,
191             // not a domain.
192             if domain_node.name().value() == "input" {
193                 global = InputSettings::parse(domain_node);
194                 continue;
195             }
196             let Some(children) = domain_node.children() else { continue };
197             let domain = domain_node.name().value().to_string();
198             let entries = domains.entry(domain.clone()).or_default();
199             for node in children.nodes() {
200                 // A domain's `input { }` child is its settings block.
201                 if node.name().value() == "input" {
202                     settings.insert(domain.clone(), InputSettings::parse(node));
203                     continue;
204                 }
205                 let mut chord: Option<String> = None;
206                 let mut command: Option<String> = None;
207                 for entry in node.entries() {
208                     let value = match entry.value() {
209                         kdl::KdlValue::String(s) | kdl::KdlValue::RawString(s) => s.clone(),
210                         _ => continue,
211                     };
212                     match entry.name().map(|n| n.value()) {
213                         None | Some("key") => {
214                             if chord.is_none() {
215                                 chord = Some(value);
216                             }
217                         }
218                         Some("command") => command = Some(value),
219                         Some(_) => {}
220                     }
221                 }
222                 if let Some(chord) = chord {
223                     entries.push(BindingEntry {
224                         name: node.name().value().to_string(),
225                         chord,
226                         command,
227                     });
228                 }
229             }
230         }
231         Ok(InputConfig { domains, settings, global })
232     }
233 
234     /// Load `input.kdl`. Missing file → empty config; a parse error is
235     /// logged and also yields an empty config, so callers fall back to
236     /// their defaults instead of losing all input.
237     pub fn load() -> InputConfig {
238         let path = get_input_path();
239         let Ok(content) = std::fs::read_to_string(&path) else {
240             return InputConfig::default();
241         };
242         match InputConfig::parse(&content) {
243             Ok(config) => config,
244             Err(e) => {
245                 eprintln!("[cce-ui] failed to parse {}: {}", path.display(), e);
246                 InputConfig::default()
247             }
248         }
249     }
250 
251     pub fn is_empty(&self) -> bool {
252         self.domains.values().all(|v| v.is_empty())
253     }
254 
255     /// All entries of one domain, in file order.
256     pub fn domain(&self, domain: &str) -> &[BindingEntry] {
257         self.domains.get(domain).map(Vec::as_slice).unwrap_or(&[])
258     }
259 
260     /// First entry named `name` in `domain`, no fallback.
261     pub fn get(&self, domain: &str, name: &str) -> Option<&BindingEntry> {
262         self.domain(domain).iter().find(|e| e.name == name)
263     }
264 
265     /// Domain resolution for apps: `<app>.<name>`, falling back to
266     /// `cce-ui.<name>`.
267     pub fn resolve(&self, app: &str, name: &str) -> Option<&BindingEntry> {
268         self.get(app, name).or_else(|| self.get(UI_DOMAIN, name))
269     }
270 
271     /// The top-level `input { }` block (global hardware defaults).
272     pub fn global_input(&self) -> &InputSettings {
273         &self.global
274     }
275 
276     /// One domain's `input { }` behavior overrides.
277     pub fn domain_input(&self, domain: &str) -> Option<&InputSettings> {
278         self.settings.get(domain)
279     }
280 
281     /// Setting resolution for apps, most specific first: the app domain's
282     /// class value → its generic value → the cce-ui domain's class value →
283     /// its generic value. The global `input` block is deliberately NOT in
284     /// the chain — the compositor already applies it at the event source.
285     pub fn resolve_setting(&self, app: &str, class: &str, key: &str) -> Option<&SettingValue> {
286         self.domain_input(app)
287             .and_then(|s| s.get_class(class, key))
288             .or_else(|| self.domain_input(UI_DOMAIN).and_then(|s| s.get_class(class, key)))
289     }
290 
291     /// Resolved chord string for widgets, with a compiled-in default as the
292     /// last resort.
293     pub fn resolve_chord(&self, app: &str, name: &str, default: &str) -> String {
294         self.resolve(app, name).map(|e| e.chord.clone()).unwrap_or_else(|| default.to_string())
295     }
296 }
297 
298 /// Replace (or append) one domain block in `input.kdl` content, leaving all
299 /// other domains and their formatting untouched. `entries` becomes the whole
300 /// new block, in order; an empty slice removes the domain. Pure — the I/O
301 /// wrapper is `write_domain`.
302 pub fn upsert_domain(content: &str, domain: &str, entries: &[BindingEntry]) -> Result<String, String> {
303     let mut doc: kdl::KdlDocument = if content.trim().is_empty() {
304         kdl::KdlDocument::new()
305     } else {
306         content.parse().map_err(|e| format!("{}", e))?
307     };
308 
309     let mut block = format!("{} {{\n", kdl_ident(domain));
310     for entry in entries {
311         block.push_str(&format!("    {} (keybind){:?}", kdl_ident(&entry.name), entry.chord));
312         if let Some(ref command) = entry.command {
313             block.push_str(&format!(" command={:?}", command));
314         }
315         block.push('\n');
316     }
317     block.push_str("}\n");
318 
319     doc.nodes_mut().retain(|n| n.name().value() != domain);
320     if !entries.is_empty() {
321         let node: kdl::KdlNode = block.parse().map_err(|e| format!("{}", e))?;
322         doc.nodes_mut().push(node);
323     }
324     let mut out = doc.to_string();
325     if !out.ends_with('\n') {
326         out.push('\n');
327     }
328     Ok(out)
329 }
330 
331 /// Quote a node name if it isn't a bare KDL identifier.
332 fn kdl_ident(name: &str) -> String {
333     let bare = !name.is_empty()
334         && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
335         && !name.starts_with(|c: char| c.is_ascii_digit());
336     if bare { name.to_string() } else { format!("{:?}", name) }
337 }
338 
339 /// Rewrite one domain of the file at `path` (created if missing). This is
340 /// the editor API for settings UIs and migration tools.
341 pub fn write_domain(path: &std::path::Path, domain: &str, entries: &[BindingEntry]) -> Result<(), String> {
342     let content = std::fs::read_to_string(path).unwrap_or_default();
343     let updated = upsert_domain(&content, domain, entries)?;
344     let path_str = path.to_string_lossy();
345     if crate::config::safe_write(&path_str, &updated) {
346         Ok(())
347     } else {
348         Err(format!("failed to write {}", path_str))
349     }
350 }
351 
352 static CACHED: std::sync::OnceLock<InputConfig> = std::sync::OnceLock::new();
353 
354 /// Process-wide cached `input.kdl`, loaded on first use. Widget-default
355 /// getters go through this so the file is read once per app.
356 pub fn cached() -> &'static InputConfig {
357     CACHED.get_or_init(InputConfig::load)
358 }
359 
360 /// Chord for one of this app's bindings: `<app>.<name>` → `cce-ui.<name>` →
361 /// the compiled-in default. The app domain is the binary name. This is the
362 /// standard way for a client to resolve its shortcuts at startup:
363 ///
364 /// ```text
365 /// let open = cce_ui::input::app_chord("open_file", "enter");
366 /// ... cce_ui::widget::match_key_shortcut(event, &open) ...
367 /// ```
368 pub fn app_chord(name: &str, default: &str) -> String {
369     let app = crate::config::get_app_name().unwrap_or_default();
370     cached().resolve_chord(&app, name, default)
371 }
372 
373 /// This app's effective wheel-delta multipliers, resolved once per process.
374 /// Pixel (smooth) deltas scale by `trackpad`, discrete clicks by `mouse`.
375 #[derive(Debug, Clone, Copy, PartialEq)]
376 pub struct ScrollFactors {
377     pub mouse: f64,
378     pub trackpad: f64,
379 }
380 
381 static SCROLL_FACTORS: std::sync::OnceLock<ScrollFactors> = std::sync::OnceLock::new();
382 
383 pub fn scroll_factors() -> ScrollFactors {
384     *SCROLL_FACTORS.get_or_init(|| {
385         let input = cached();
386         let app = crate::config::get_app_name().unwrap_or_default();
387         let factor = |class: &str| {
388             input
389                 .resolve_setting(&app, class, "scroll_factor")
390                 .and_then(SettingValue::as_f64)
391                 .filter(|f| f.is_finite() && *f > 0.0)
392                 .unwrap_or(1.0)
393         };
394         ScrollFactors { mouse: factor("mouse"), trackpad: factor("trackpad") }
395     })
396 }
397 
398 /// Chord for a widget binding, resolved by specificity: the app's own
399 /// `input.kdl` domain, then the caller-supplied legacy value (per-widget
400 /// `config.kdl` props), then the toolkit-wide `cce-ui` domain, then the
401 /// compiled-in default.
402 pub fn widget_chord(name: &str, legacy: &str, default: &str) -> String {
403     let input = cached();
404     if let Some(app) = crate::config::get_app_name() {
405         if let Some(e) = input.get(&app, name) {
406             return e.chord.clone();
407         }
408     }
409     if !legacy.is_empty() {
410         return legacy.to_string();
411     }
412     if let Some(e) = input.get(UI_DOMAIN, name) {
413         return e.chord.clone();
414     }
415     default.to_string()
416 }
417 
418 #[cfg(test)]
419 mod tests {
420     use super::*;
421 
422     const SAMPLE: &str = r#"
423 cce-window-manager {
424     close_window "super+q"
425     toggle_fullscreen (keybind)"super+f"
426     spawn "super+d" command="cce-cloud --apps"
427     spawn "super+t" command="foot"
428 }
429 cce-ui {
430     open_search "ctrl+f"
431     close_search "escape"
432 }
433 cce-files {
434     open_file key="enter"
435     open_search "/"
436 }
437 "#;
438 
439     #[test]
440     fn parses_domains_and_entries() {
441         let c = InputConfig::parse(SAMPLE).unwrap();
442         assert_eq!(c.domain(WINDOW_MANAGER_DOMAIN).len(), 4);
443         assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "close_window").unwrap().chord, "super+q");
444         // Type annotations are transparent.
445         assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "toggle_fullscreen").unwrap().chord, "super+f");
446         // Repeated names keep every entry, in order, with their commands.
447         let spawns: Vec<_> =
448             c.domain(WINDOW_MANAGER_DOMAIN).iter().filter(|e| e.name == "spawn").collect();
449         assert_eq!(spawns.len(), 2);
450         assert_eq!(spawns[0].command.as_deref(), Some("cce-cloud --apps"));
451         assert_eq!(spawns[1].chord, "super+t");
452         // key= property form.
453         assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
454     }
455 
456     #[test]
457     fn resolution_prefers_app_over_ui_domain() {
458         let c = InputConfig::parse(SAMPLE).unwrap();
459         // Overridden in cce-files.
460         assert_eq!(c.resolve("cce-files", "open_search").unwrap().chord, "/");
461         // Not overridden: falls back to cce-ui.
462         assert_eq!(c.resolve("cce-files", "close_search").unwrap().chord, "escape");
463         // Unknown app: pure cce-ui fallback.
464         assert_eq!(c.resolve("cce-mail", "open_search").unwrap().chord, "ctrl+f");
465         // Nowhere: compiled-in default.
466         assert_eq!(c.resolve_chord("cce-mail", "save", "ctrl+s"), "ctrl+s");
467     }
468 
469     const SETTINGS_SAMPLE: &str = r#"
470 input {
471     accel_profile "flat"
472     accel_speed 1.0
473     mouse {
474         accel_speed 0.5
475         scroll_factor 2.0
476     }
477     trackpad {
478         tap_to_click true
479         scroll_factor 1.5
480     }
481 }
482 cce-ui {
483     open_search "ctrl+f"
484     input {
485         scroll_factor 1.25
486     }
487 }
488 cce-files {
489     open_file "enter"
490     input {
491         scroll_factor 0.8
492         trackpad {
493             scroll_factor 0.6
494         }
495     }
496 }
497 "#;
498 
499     #[test]
500     fn parses_settings_blocks() {
501         let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
502         // The top-level input block is global, not a domain.
503         assert!(c.domain("input").is_empty());
504         let g = c.global_input();
505         assert_eq!(g.get("accel_profile").and_then(SettingValue::as_str), Some("flat"));
506         assert_eq!(g.get("accel_speed").and_then(SettingValue::as_f64), Some(1.0));
507         // Class value wins over generic; missing class value falls back.
508         assert_eq!(g.get_class("mouse", "accel_speed").and_then(SettingValue::as_f64), Some(0.5));
509         assert_eq!(g.get_class("trackpad", "accel_speed").and_then(SettingValue::as_f64), Some(1.0));
510         assert_eq!(g.get_class("trackpad", "tap_to_click").and_then(SettingValue::as_bool), Some(true));
511         // Settings blocks don't pollute the binding lists.
512         assert_eq!(c.domain("cce-files").len(), 1);
513         assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
514     }
515 
516     #[test]
517     fn setting_resolution_prefers_app_then_ui_domain() {
518         let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
519         // App class value → app generic → cce-ui.
520         let f = |app: &str, class: &str| {
521             c.resolve_setting(app, class, "scroll_factor").and_then(SettingValue::as_f64)
522         };
523         assert_eq!(f("cce-files", "trackpad"), Some(0.6));
524         assert_eq!(f("cce-files", "mouse"), Some(0.8)); // generic app value
525         assert_eq!(f("cce-mail", "trackpad"), Some(1.25)); // cce-ui fallback
526         // The global input block is not in the client chain.
527         assert_eq!(c.resolve_setting("cce-mail", "mouse", "accel_speed"), None);
528     }
529 
530     #[test]
531     fn upsert_domain_round_trips() {
532         let entries = vec![
533             BindingEntry { name: "close_window".into(), chord: "super+q".into(), command: None },
534             BindingEntry {
535                 name: "spawn".into(),
536                 chord: "super+d".into(),
537                 command: Some("cce-cloud --apps".into()),
538             },
539         ];
540         // Insert into empty content, then read back.
541         let out = upsert_domain("", WINDOW_MANAGER_DOMAIN, &entries).unwrap();
542         let parsed = InputConfig::parse(&out).unwrap();
543         assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).to_vec(), entries);
544 
545         // Replace the domain without touching other domains.
546         let combined = format!("{}\n{}", SAMPLE, ""); // SAMPLE already has the domain
547         let replaced = upsert_domain(
548             &combined,
549             WINDOW_MANAGER_DOMAIN,
550             &entries[..1],
551         )
552         .unwrap();
553         let parsed = InputConfig::parse(&replaced).unwrap();
554         assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).len(), 1);
555         assert_eq!(parsed.get("cce-files", "open_search").unwrap().chord, "/");
556 
557         // Empty entries removes the block entirely.
558         let removed = upsert_domain(&replaced, WINDOW_MANAGER_DOMAIN, &[]).unwrap();
559         let parsed = InputConfig::parse(&removed).unwrap();
560         assert!(parsed.domain(WINDOW_MANAGER_DOMAIN).is_empty());
561         assert_eq!(parsed.resolve("cce-files", "close_search").unwrap().chord, "escape");
562     }
563 
564     #[test]
565     fn empty_and_invalid_input() {
566         assert!(InputConfig::parse("").unwrap().is_empty());
567         // Chord-less entries are skipped, childless nodes ignored.
568         let c = InputConfig::parse("cce-ui {\n    broken\n}\nstray-node\n").unwrap();
569         assert!(c.is_empty());
570         assert!(InputConfig::parse("cce-ui {").is_err());
571     }
572 }