git.lucas.co / cce-core
GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git

src/input.rs (24.5K)

  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 /// Whether the trackpad scrolls NATURALLY — the content following the
374 /// fingers — per input.kdl's `trackpad { natural_scroll }`, as the
375 /// compositor applies it. Read once per process; a thread's override
376 /// (`force_natural_scroll`, for a test) wins over it, and under `cfg(test)`
377 /// with no override the answer is `false`, so the toolkit's own suite does
378 /// not read the machine.
379 ///
380 /// What it is for: a VALUE control — a slider, a spinbox, a menu's slider
381 /// row — takes the wheel as "up is more", and a finger under natural
382 /// scrolling as the same thing, which is the opposite sign of the pixel
383 /// delta the runner hands it (the delta is what a LIST scrolls by, and a
384 /// list under natural scrolling moves its content the way the fingers
385 /// went). See `MouseScrollDelta::value_notches_y`.
386 pub fn natural_scroll() -> bool {
387     if let Some(forced) = NATURAL_OVERRIDE.with(|f| f.get()) {
388         return forced;
389     }
390     #[cfg(any(test, feature = "test-isolation"))]
391     {
392         false
393     }
394     #[cfg(not(any(test, feature = "test-isolation")))]
395     {
396         *NATURAL_SCROLL.get_or_init(|| {
397             let input = cached();
398             let app = crate::config::get_app_name().unwrap_or_default();
399             input.resolve_setting(&app, "trackpad", "natural_scroll").and_then(SettingValue::as_bool).unwrap_or(false)
400         })
401     }
402 }
403 
404 #[cfg(not(any(test, feature = "test-isolation")))]
405 static NATURAL_SCROLL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
406 
407 thread_local! {
408     static NATURAL_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
409 }
410 
411 /// Force what [`natural_scroll`] answers on this thread, for a test that
412 /// drives a value control with a finger; `None` lifts it. Thread-local,
413 /// because a suite runs its tests in parallel and a process-wide override
414 /// set by one would race every other test's read. The macOS shell sets it
415 /// too, on the main thread its events arrive on, from each trackpad
416 /// event's `isDirectionInvertedFromDevice`: there the system's setting is
417 /// the one in force, not input.kdl's.
418 pub fn force_natural_scroll(natural: Option<bool>) {
419     NATURAL_OVERRIDE.with(|f| f.set(natural));
420 }
421 
422 /// Run `f` with [`natural_scroll`] answering `natural`, then put back
423 /// whatever override was in force. For an input whose direction is known
424 /// regardless of the trackpad setting: a touchscreen finger is always
425 /// natural (the runner's touch scroll, `backend/touch.rs`).
426 pub fn with_natural_scroll<R>(natural: bool, f: impl FnOnce() -> R) -> R {
427     let prev = NATURAL_OVERRIDE.with(|o| o.replace(Some(natural)));
428     let out = f();
429     NATURAL_OVERRIDE.with(|o| o.set(prev));
430     out
431 }
432 
433 /// This app's effective wheel-delta multipliers, resolved once per process.
434 /// Pixel (smooth) deltas scale by `trackpad`, discrete clicks by `mouse`.
435 #[derive(Debug, Clone, Copy, PartialEq)]
436 pub struct ScrollFactors {
437     pub mouse: f64,
438     pub trackpad: f64,
439 }
440 
441 static SCROLL_FACTORS: std::sync::OnceLock<ScrollFactors> = std::sync::OnceLock::new();
442 
443 pub fn scroll_factors() -> ScrollFactors {
444     *SCROLL_FACTORS.get_or_init(|| {
445         let input = cached();
446         let app = crate::config::get_app_name().unwrap_or_default();
447         let factor = |class: &str| {
448             input
449                 .resolve_setting(&app, class, "scroll_factor")
450                 .and_then(SettingValue::as_f64)
451                 .filter(|f| f.is_finite() && *f > 0.0)
452                 .unwrap_or(1.0)
453         };
454         ScrollFactors { mouse: factor("mouse"), trackpad: factor("trackpad") }
455     })
456 }
457 
458 /// Chord for a widget binding, resolved by specificity: the app's own
459 /// `input.kdl` domain, then the caller-supplied legacy value (per-widget
460 /// `config.kdl` props), then the toolkit-wide `cce-ui` domain, then the
461 /// compiled-in default.
462 pub fn widget_chord(name: &str, legacy: &str, default: &str) -> String {
463     let input = cached();
464     if let Some(app) = crate::config::get_app_name() {
465         if let Some(e) = input.get(&app, name) {
466             return e.chord.clone();
467         }
468     }
469     if !legacy.is_empty() {
470         return legacy.to_string();
471     }
472     if let Some(e) = input.get(UI_DOMAIN, name) {
473         return e.chord.clone();
474     }
475     default.to_string()
476 }
477 
478 #[cfg(test)]
479 mod tests {
480     use super::*;
481 
482     const SAMPLE: &str = r#"
483 cce-window-manager {
484     close_window "super+q"
485     toggle_fullscreen (keybind)"super+f"
486     spawn "super+d" command="cce-cloud --apps"
487     spawn "super+t" command="foot"
488 }
489 cce-ui {
490     open_search "ctrl+f"
491     close_search "escape"
492 }
493 cce-files {
494     open_file key="enter"
495     open_search "/"
496 }
497 "#;
498 
499     #[test]
500     fn parses_domains_and_entries() {
501         let c = InputConfig::parse(SAMPLE).unwrap();
502         assert_eq!(c.domain(WINDOW_MANAGER_DOMAIN).len(), 4);
503         assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "close_window").unwrap().chord, "super+q");
504         // Type annotations are transparent.
505         assert_eq!(c.get(WINDOW_MANAGER_DOMAIN, "toggle_fullscreen").unwrap().chord, "super+f");
506         // Repeated names keep every entry, in order, with their commands.
507         let spawns: Vec<_> =
508             c.domain(WINDOW_MANAGER_DOMAIN).iter().filter(|e| e.name == "spawn").collect();
509         assert_eq!(spawns.len(), 2);
510         assert_eq!(spawns[0].command.as_deref(), Some("cce-cloud --apps"));
511         assert_eq!(spawns[1].chord, "super+t");
512         // key= property form.
513         assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
514     }
515 
516     #[test]
517     fn resolution_prefers_app_over_ui_domain() {
518         let c = InputConfig::parse(SAMPLE).unwrap();
519         // Overridden in cce-files.
520         assert_eq!(c.resolve("cce-files", "open_search").unwrap().chord, "/");
521         // Not overridden: falls back to cce-ui.
522         assert_eq!(c.resolve("cce-files", "close_search").unwrap().chord, "escape");
523         // Unknown app: pure cce-ui fallback.
524         assert_eq!(c.resolve("cce-mail", "open_search").unwrap().chord, "ctrl+f");
525         // Nowhere: compiled-in default.
526         assert_eq!(c.resolve_chord("cce-mail", "save", "ctrl+s"), "ctrl+s");
527     }
528 
529     const SETTINGS_SAMPLE: &str = r#"
530 input {
531     accel_profile "flat"
532     accel_speed 1.0
533     mouse {
534         accel_speed 0.5
535         scroll_factor 2.0
536     }
537     trackpad {
538         tap_to_click true
539         scroll_factor 1.5
540     }
541 }
542 cce-ui {
543     open_search "ctrl+f"
544     input {
545         scroll_factor 1.25
546     }
547 }
548 cce-files {
549     open_file "enter"
550     input {
551         scroll_factor 0.8
552         trackpad {
553             scroll_factor 0.6
554         }
555     }
556 }
557 "#;
558 
559     #[test]
560     fn parses_settings_blocks() {
561         let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
562         // The top-level input block is global, not a domain.
563         assert!(c.domain("input").is_empty());
564         let g = c.global_input();
565         assert_eq!(g.get("accel_profile").and_then(SettingValue::as_str), Some("flat"));
566         assert_eq!(g.get("accel_speed").and_then(SettingValue::as_f64), Some(1.0));
567         // Class value wins over generic; missing class value falls back.
568         assert_eq!(g.get_class("mouse", "accel_speed").and_then(SettingValue::as_f64), Some(0.5));
569         assert_eq!(g.get_class("trackpad", "accel_speed").and_then(SettingValue::as_f64), Some(1.0));
570         assert_eq!(g.get_class("trackpad", "tap_to_click").and_then(SettingValue::as_bool), Some(true));
571         // Settings blocks don't pollute the binding lists.
572         assert_eq!(c.domain("cce-files").len(), 1);
573         assert_eq!(c.get("cce-files", "open_file").unwrap().chord, "enter");
574     }
575 
576     #[test]
577     fn setting_resolution_prefers_app_then_ui_domain() {
578         let c = InputConfig::parse(SETTINGS_SAMPLE).unwrap();
579         // App class value → app generic → cce-ui.
580         let f = |app: &str, class: &str| {
581             c.resolve_setting(app, class, "scroll_factor").and_then(SettingValue::as_f64)
582         };
583         assert_eq!(f("cce-files", "trackpad"), Some(0.6));
584         assert_eq!(f("cce-files", "mouse"), Some(0.8)); // generic app value
585         assert_eq!(f("cce-mail", "trackpad"), Some(1.25)); // cce-ui fallback
586         // The global input block is not in the client chain.
587         assert_eq!(c.resolve_setting("cce-mail", "mouse", "accel_speed"), None);
588     }
589 
590     #[test]
591     fn upsert_domain_round_trips() {
592         let entries = vec![
593             BindingEntry { name: "close_window".into(), chord: "super+q".into(), command: None },
594             BindingEntry {
595                 name: "spawn".into(),
596                 chord: "super+d".into(),
597                 command: Some("cce-cloud --apps".into()),
598             },
599         ];
600         // Insert into empty content, then read back.
601         let out = upsert_domain("", WINDOW_MANAGER_DOMAIN, &entries).unwrap();
602         let parsed = InputConfig::parse(&out).unwrap();
603         assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).to_vec(), entries);
604 
605         // Replace the domain without touching other domains.
606         let combined = format!("{}\n{}", SAMPLE, ""); // SAMPLE already has the domain
607         let replaced = upsert_domain(
608             &combined,
609             WINDOW_MANAGER_DOMAIN,
610             &entries[..1],
611         )
612         .unwrap();
613         let parsed = InputConfig::parse(&replaced).unwrap();
614         assert_eq!(parsed.domain(WINDOW_MANAGER_DOMAIN).len(), 1);
615         assert_eq!(parsed.get("cce-files", "open_search").unwrap().chord, "/");
616 
617         // Empty entries removes the block entirely.
618         let removed = upsert_domain(&replaced, WINDOW_MANAGER_DOMAIN, &[]).unwrap();
619         let parsed = InputConfig::parse(&removed).unwrap();
620         assert!(parsed.domain(WINDOW_MANAGER_DOMAIN).is_empty());
621         assert_eq!(parsed.resolve("cce-files", "close_search").unwrap().chord, "escape");
622     }
623 
624     #[test]
625     fn empty_and_invalid_input() {
626         assert!(InputConfig::parse("").unwrap().is_empty());
627         // Chord-less entries are skipped, childless nodes ignored.
628         let c = InputConfig::parse("cce-ui {\n    broken\n}\nstray-node\n").unwrap();
629         assert!(c.is_empty());
630         assert!(InputConfig::parse("cce-ui {").is_err());
631     }
632 }