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 }