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 }