git.lucas.co / cce-compositor
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 }