Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
src/migrate_input.rs (13.4K)
1 // `ccectl migrate-input` — one-time extraction of keybindings from
2 // config.kdl into the domain-scoped input.kdl.
3 //
4 // Reads only; config.kdl is never rewritten. Extracted bindings are merged
5 // into input.kdl (existing entries win), the previous input.kdl is backed up
6 // under ~/.config/cce/backups/, and a summary tells the user which config.kdl
7 // entries are now redundant and can be deleted by hand.
8 //
9 // What gets extracted:
10 // - root-level / block-form `key_bindings` nodes → `cce-window-manager`
11 // - the `window_manager` section → `cce-window-manager`
12 // - `style.data.list/tree` search-key props → `cce-ui`
13 //
14 // The brightness spawn binds synthesized from the `display` section are NOT
15 // migrated — they stay derived from the display config at load time.
16
17 use cce_ui::input::{BindingEntry, InputConfig, UI_DOMAIN, WINDOW_MANAGER_DOMAIN};
18 use cce_window_manager::api::Action;
19 use cce_window_manager::bindings::parse_chord;
20
21 pub struct Extracted {
22 pub wm: Vec<BindingEntry>,
23 pub ui: Vec<BindingEntry>,
24 pub warnings: Vec<String>,
25 }
26
27 fn prop_string(node: &kdl::KdlNode, key: &str) -> Option<String> {
28 node.entries()
29 .iter()
30 .find(|e| e.name().map(|n| n.value()) == Some(key))
31 .and_then(|e| e.value().as_string().map(str::to_string))
32 }
33
34 fn first_arg_string(node: &kdl::KdlNode) -> Option<String> {
35 node.entries()
36 .iter()
37 .find(|e| e.name().is_none())
38 .and_then(|e| e.value().as_string().map(str::to_string))
39 }
40
41 fn child<'a>(doc: &'a kdl::KdlDocument, name: &str) -> Option<&'a kdl::KdlNode> {
42 doc.nodes().iter().find(|n| n.name().value() == name)
43 }
44
45 /// One legacy key_bindings entry (flat node or block child) → a wm-domain
46 /// BindingEntry, validated against the policy crate's vocabulary.
47 fn convert_key_binding(node: &kdl::KdlNode, out: &mut Extracted) {
48 let mods = prop_string(node, "mods").unwrap_or_default();
49 let key = prop_string(node, "key").unwrap_or_default();
50 if key.is_empty() {
51 out.warnings.push(format!("key_bindings entry without key= skipped: {}", node));
52 return;
53 }
54 let chord = if mods.is_empty() { key } else { format!("{}+{}", mods, key) };
55 let action = prop_string(node, "action").unwrap_or_default();
56
57 if Action::from_name(&action).is_none() {
58 out.warnings.push(format!("unknown action {:?} skipped (chord {:?})", action, chord));
59 return;
60 }
61 if !valid_chord(&chord) {
62 out.warnings.push(format!("invalid chord {:?} skipped (action {:?})", chord, action));
63 return;
64 }
65 out.wm.push(BindingEntry { name: action, chord, command: prop_string(node, "command") });
66 }
67
68 /// A chord is migratable when its modifiers parse AND its key is a real XKB
69 /// keysym — this rejects gesture names like "swipe_down" that ride in
70 /// keybind-typed config slots.
71 fn valid_chord(chord: &str) -> bool {
72 match parse_chord(chord) {
73 Some(c) => crate::config::parse_keysym(&c.key) != 0,
74 None => false,
75 }
76 }
77
78 /// Pure extraction pass over config.kdl content.
79 pub fn extract_from_config(content: &str) -> Result<Extracted, String> {
80 let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("{}", e))?;
81 let mut out = Extracted { wm: Vec::new(), ui: Vec::new(), warnings: Vec::new() };
82
83 // key_bindings nodes live at the root or nested one level down (the
84 // `input` section) — mirror parse_kdl_config and scan both.
85 let mut kb_nodes: Vec<&kdl::KdlNode> = Vec::new();
86 for node in doc.nodes() {
87 if node.name().value() == "key_bindings" {
88 kb_nodes.push(node);
89 } else if let Some(children) = node.children() {
90 kb_nodes.extend(children.nodes().iter().filter(|n| n.name().value() == "key_bindings"));
91 }
92 }
93 for node in kb_nodes {
94 match node.children() {
95 Some(children) => {
96 for c in children.nodes() {
97 convert_key_binding(c, &mut out);
98 }
99 }
100 None => convert_key_binding(node, &mut out),
101 }
102 }
103
104 if let Some(wm_node) = child(&doc, "window_manager") {
105 if let Some(children) = wm_node.children() {
106 for (prop, name) in [
107 ("close_window", "close_window"),
108 ("toggle_fullscreen", "toggle_fullscreen"),
109 ("window_switcher", "window_switcher"),
110 ("toggle_overview", "overview"),
111 ] {
112 let Some(c) = child(children, prop) else { continue };
113 let Some(chord) = first_arg_string(c) else { continue };
114 let normalized = chord.to_lowercase().replace('-', "_");
115 if normalized.starts_with("swipe") || normalized.starts_with("pinch") {
116 // A gesture binding (e.g. toggle_overview "swipe_down"),
117 // consumed by the compositor's gesture path — not a
118 // keybind, nothing to migrate.
119 continue;
120 }
121 if !valid_chord(&chord) {
122 out.warnings.push(format!("window_manager.{}: invalid chord {:?} skipped", prop, chord));
123 continue;
124 }
125 out.wm.push(BindingEntry { name: name.to_string(), chord, command: None });
126 }
127 }
128 }
129
130 // Widget search keys from style.data.{list,tree} props → cce-ui domain.
131 let data = child(&doc, "style").and_then(|n| n.children()).and_then(|c| child(c, "data"));
132 if let Some(data) = data {
133 let data_children = data.children();
134 let list = data_children.and_then(|c| child(c, "list"));
135 let tree = data_children.and_then(|c| child(c, "tree"));
136 let list_open = list.and_then(|n| prop_string(n, "open_search"));
137 let tree_open = tree.and_then(|n| prop_string(n, "open_search"));
138 let close = list.and_then(|n| prop_string(n, "close_search"));
139
140 match (&list_open, &tree_open) {
141 (Some(l), Some(t)) if l != t => out.warnings.push(format!(
142 "list.open_search ({:?}) and tree.open_search ({:?}) differ; migrating the list value — the tree keeps its config.kdl prop",
143 l, t
144 )),
145 _ => {}
146 }
147 if let Some(chord) = list_open.or(tree_open) {
148 out.ui.push(BindingEntry { name: "open_search".into(), chord, command: None });
149 }
150 if let Some(chord) = close {
151 out.ui.push(BindingEntry { name: "close_search".into(), chord, command: None });
152 }
153 }
154
155 Ok(out)
156 }
157
158 /// Merge extracted entries into a domain's existing list. Existing entries
159 /// always win: an extracted entry is dropped when its name is already
160 /// configured (or, for repeatable spawn/toggle, when the same chord is).
161 pub fn merge_into(existing: &[BindingEntry], extracted: Vec<BindingEntry>) -> (Vec<BindingEntry>, usize) {
162 let mut merged = existing.to_vec();
163 let mut added = 0;
164 for e in extracted {
165 let repeatable = e.name == "spawn" || e.name == "toggle";
166 let taken = merged.iter().any(|m| {
167 if repeatable {
168 m.chord == e.chord
169 } else {
170 m.name == e.name
171 }
172 });
173 if !taken {
174 merged.push(e);
175 added += 1;
176 }
177 }
178 (merged, added)
179 }
180
181 fn backup(path: &std::path::Path) -> Option<std::path::PathBuf> {
182 if !path.exists() {
183 return None;
184 }
185 let backups = cce_ui::config::cce_config_dir().join("backups");
186 let _ = std::fs::create_dir_all(&backups);
187 for n in 1..1000 {
188 let candidate = backups.join(format!("input.kdl.{}.bak", n));
189 if !candidate.exists() {
190 return std::fs::copy(path, &candidate).ok().map(|_| candidate);
191 }
192 }
193 None
194 }
195
196 pub fn run() {
197 let config_path = cce_ui::config::get_config_path();
198 let content = match std::fs::read_to_string(&config_path) {
199 Ok(c) => c,
200 Err(e) => {
201 eprintln!("cannot read {}: {}", config_path.display(), e);
202 std::process::exit(1);
203 }
204 };
205 let extracted = match extract_from_config(&content) {
206 Ok(x) => x,
207 Err(e) => {
208 eprintln!("cannot parse {}: {}", config_path.display(), e);
209 std::process::exit(1);
210 }
211 };
212 for w in &extracted.warnings {
213 eprintln!("warning: {}", w);
214 }
215 if extracted.wm.is_empty() && extracted.ui.is_empty() {
216 println!("nothing to migrate: no keybindings found in {}", config_path.display());
217 return;
218 }
219
220 let input_path = cce_ui::input::get_input_path();
221 let existing_content = std::fs::read_to_string(&input_path).unwrap_or_default();
222 let existing = match InputConfig::parse(&existing_content) {
223 Ok(c) => c,
224 Err(e) => {
225 eprintln!("cannot parse existing {}: {} — fix or remove it first", input_path.display(), e);
226 std::process::exit(1);
227 }
228 };
229
230 let (wm_merged, wm_added) = merge_into(existing.domain(WINDOW_MANAGER_DOMAIN), extracted.wm);
231 let (ui_merged, ui_added) = merge_into(existing.domain(UI_DOMAIN), extracted.ui);
232 if wm_added == 0 && ui_added == 0 {
233 println!("nothing to migrate: input.kdl already covers every config.kdl binding");
234 return;
235 }
236
237 if let Some(bak) = backup(&input_path) {
238 println!("backed up {} -> {}", input_path.display(), bak.display());
239 }
240 let write = |domain: &str, entries: &[BindingEntry], added: usize| {
241 if added == 0 {
242 return;
243 }
244 match cce_ui::input::write_domain(&input_path, domain, entries) {
245 Ok(()) => println!("{}: migrated {} binding(s)", domain, added),
246 Err(e) => {
247 eprintln!("failed to write {}: {}", input_path.display(), e);
248 std::process::exit(1);
249 }
250 }
251 };
252 write(WINDOW_MANAGER_DOMAIN, &wm_merged, wm_added);
253 write(UI_DOMAIN, &ui_merged, ui_added);
254
255 println!();
256 println!("wrote {}", input_path.display());
257 println!("config.kdl was NOT modified. The migrated `key_bindings` nodes and the");
258 println!("`window_manager` section are now shadowed by input.kdl and can be deleted.");
259 if ui_added > 0 {
260 println!("Migrated widget search keys only take effect once the corresponding");
261 println!("style.data.list/tree props are removed from config.kdl (per-widget");
262 println!("props stay more specific than cce-ui domain defaults).");
263 }
264 }
265
266 #[cfg(test)]
267 mod tests {
268 use super::*;
269
270 const CONFIG: &str = r#"
271 input {
272 accel_speed (f64)1.0
273 key_bindings action="spawn" command="cce-cloud --apps" key=(keybind)"super+d"
274 key_bindings action="spawn" command="cce control keypress 69" key=(keybind)"super+slash"
275 }
276 key_bindings {
277 bind action="toggle" command="foot" key=(keybind)"super+t"
278 bind action="bogus" key=(keybind)"super+b"
279 bind action="spawn" command="x" key=(keybind)"hyper+x"
280 }
281 style {
282 data {
283 list close_search=(keybind)"escape" open_search=(keybind)"/"
284 tree open_search=(keybind)"ctrl+f"
285 }
286 }
287 window_manager {
288 close_window (keybind)"super+q"
289 toggle_fullscreen (keybind)"super+f"
290 window_switcher (keybind)"super+tab"
291 toggle_overview (keybind)"swipe_down"
292 }
293 "#;
294
295 #[test]
296 fn extracts_all_legacy_sources() {
297 let x = extract_from_config(CONFIG).unwrap();
298 // 2 nested spawns + 1 block toggle + 3 window_manager entries; the
299 // unknown action and the bad modifier warn, the gesture value
300 // (toggle_overview "swipe_down") is silently left to the gesture path.
301 assert_eq!(x.wm.len(), 6);
302 assert!(!x.wm.iter().any(|e| e.chord == "swipe_down"));
303 assert_eq!(x.warnings.len(), 3); // bogus action, hyper chord, list/tree mismatch
304 let spawn = x.wm.iter().find(|e| e.chord == "super+d").unwrap();
305 assert_eq!(spawn.name, "spawn");
306 assert_eq!(spawn.command.as_deref(), Some("cce-cloud --apps"));
307 let toggle = x.wm.iter().find(|e| e.name == "toggle").unwrap();
308 assert_eq!(toggle.chord, "super+t");
309 assert!(x.wm.iter().any(|e| e.name == "close_window" && e.chord == "super+q"));
310 assert!(x.wm.iter().any(|e| e.name == "window_switcher" && e.chord == "super+tab"));
311 // list wins the open_search mismatch; close_search comes along.
312 assert!(x.ui.iter().any(|e| e.name == "open_search" && e.chord == "/"));
313 assert!(x.ui.iter().any(|e| e.name == "close_search" && e.chord == "escape"));
314 }
315
316 #[test]
317 fn merge_never_overrides_existing() {
318 let existing = vec![
319 BindingEntry { name: "close_window".into(), chord: "super+w".into(), command: None },
320 BindingEntry { name: "spawn".into(), chord: "super+d".into(), command: Some("a".into()) },
321 ];
322 let extracted = vec![
323 // Same name, different chord: dropped (name already configured).
324 BindingEntry { name: "close_window".into(), chord: "super+q".into(), command: None },
325 // Repeatable, same chord: dropped.
326 BindingEntry { name: "spawn".into(), chord: "super+d".into(), command: Some("b".into()) },
327 // Repeatable, new chord: added.
328 BindingEntry { name: "spawn".into(), chord: "super+t".into(), command: Some("c".into()) },
329 ];
330 let (merged, added) = merge_into(&existing, extracted);
331 assert_eq!(added, 1);
332 assert_eq!(merged.len(), 3);
333 assert_eq!(merged[0].chord, "super+w");
334 assert!(merged.iter().any(|e| e.chord == "super+t"));
335 }
336 }