Skip to main content

halley_config/layout/
update.rs

1use std::mem;
2
3use crate::layout::{RuntimeTuning, ViewportOutputConfig};
4use crate::parse::keybinds::{apply_explicit_keybind_overrides_entries, parse_inline_keybinds};
5
6#[derive(Clone, Debug)]
7struct ParsedScope {
8    items: Vec<ScopeItem>,
9    suffix: String,
10}
11
12#[derive(Clone, Debug)]
13struct ScopeItem {
14    leading: String,
15    kind: ScopeItemKind,
16}
17
18#[derive(Clone, Debug)]
19enum ScopeItemKind {
20    Scalar(ScalarItem),
21    Section(SectionItem),
22}
23
24#[derive(Clone, Debug)]
25struct ScalarItem {
26    key: String,
27    raw_line: String,
28}
29
30#[derive(Clone, Debug)]
31struct SectionItem {
32    name: String,
33    header_line: String,
34    body: ParsedScope,
35    end_line: String,
36}
37
38impl ParsedScope {
39    fn render(&self) -> String {
40        let mut out = String::new();
41        for item in &self.items {
42            out.push_str(item.leading.as_str());
43            out.push_str(item.kind.render().as_str());
44        }
45        out.push_str(self.suffix.as_str());
46        out
47    }
48}
49
50impl ScopeItemKind {
51    fn render(&self) -> String {
52        match self {
53            ScopeItemKind::Scalar(item) => format!("{}\n", item.raw_line),
54            ScopeItemKind::Section(item) => {
55                let mut out = String::new();
56                out.push_str(item.header_line.as_str());
57                out.push('\n');
58                out.push_str(item.body.render().as_str());
59                out.push_str(item.end_line.as_str());
60                out.push('\n');
61                out
62            }
63        }
64    }
65}
66
67impl RuntimeTuning {
68    pub fn update_user_config_text(
69        raw: &str,
70        tty_viewports: &[ViewportOutputConfig],
71    ) -> Result<Option<String>, String> {
72        if Self::from_rune_str(raw).is_none() {
73            return Err("config parse failed; leaving file unchanged".to_string());
74        }
75
76        let template = Self::render_fresh_config(tty_viewports);
77        let mut existing_doc = parse_scope(raw);
78        let template_doc = parse_scope(template.as_str());
79        let mut changed = merge_non_keybind_sections(&mut existing_doc, &template_doc);
80        changed |= merge_keybinds(&mut existing_doc, &template_doc, raw)?;
81
82        if !changed {
83            return Ok(None);
84        }
85
86        Ok(Some(existing_doc.render()))
87    }
88}
89
90fn merge_non_keybind_sections(existing: &mut ParsedScope, template: &ParsedScope) -> bool {
91    let mut changed = false;
92
93    for template_item in &template.items {
94        let ScopeItemKind::Section(template_section) = &template_item.kind else {
95            continue;
96        };
97
98        if template_section.name == "keybinds" {
99            continue;
100        }
101        if !should_merge_top_level_section(template_section.name.as_str()) {
102            continue;
103        }
104
105        if let Some(existing_section) = find_section_mut(existing, template_section.name.as_str()) {
106            changed |= merge_section_body(existing_section, template_section);
107            continue;
108        }
109
110        existing.items.push(template_item.clone());
111        changed = true;
112    }
113
114    changed
115}
116
117fn merge_section_body(existing: &mut SectionItem, template: &SectionItem) -> bool {
118    let mut changed = false;
119
120    for template_item in &template.body.items {
121        match &template_item.kind {
122            ScopeItemKind::Scalar(template_scalar) => {
123                if has_scalar_key(&existing.body, template_scalar.key.as_str()) {
124                    continue;
125                }
126                existing.body.items.push(template_item.clone());
127                changed = true;
128            }
129            ScopeItemKind::Section(template_section) => {
130                if let Some(existing_section) =
131                    find_section_mut(&mut existing.body, template_section.name.as_str())
132                {
133                    changed |= merge_section_body(existing_section, template_section);
134                    continue;
135                }
136                existing.body.items.push(template_item.clone());
137                changed = true;
138            }
139        }
140    }
141
142    changed
143}
144
145fn merge_keybinds(
146    existing: &mut ParsedScope,
147    template: &ParsedScope,
148    raw: &str,
149) -> Result<bool, String> {
150    let Some(template_keybinds) = find_section(template, "keybinds") else {
151        return Ok(false);
152    };
153
154    let Some(existing_keybinds) = find_section_mut(existing, "keybinds") else {
155        existing.items.push(ScopeItem {
156            leading: if existing.items.is_empty() && existing.suffix.is_empty() {
157                String::new()
158            } else {
159                String::from("\n")
160            },
161            kind: ScopeItemKind::Section(template_keybinds.clone()),
162        });
163        return Ok(true);
164    };
165
166    let existing_entries = parse_inline_keybinds(raw)
167        .map_err(|err| format!("config keybind parse failed; leaving file unchanged: {err}"))?;
168    let mut resolved = resolve_explicit_keybinds(&existing_entries)?;
169    let mod_token = existing_entries
170        .iter()
171        .rev()
172        .find_map(|entry| entry.0.eq_ignore_ascii_case("mod").then(|| entry.1.clone()))
173        .unwrap_or_else(|| resolved.keybinds.modifier_name());
174
175    let mut additions = Vec::new();
176    for candidate in keybind_candidates() {
177        let candidate_entries = candidate_entries(*candidate, mod_token.as_str());
178        let candidate_tuning = resolve_explicit_keybinds(&candidate_entries)?;
179        if compositor_or_launch_conflict(&resolved, &candidate_tuning) {
180            continue;
181        }
182        merge_resolved_bindings(&mut resolved, candidate_tuning);
183        additions.push(make_keybind_item(
184            *candidate,
185            additions.is_empty() && !existing_keybinds.body.items.is_empty(),
186        ));
187    }
188
189    if additions.is_empty() {
190        return Ok(false);
191    }
192
193    existing_keybinds.body.items.extend(additions);
194    Ok(true)
195}
196
197fn find_section<'a>(scope: &'a ParsedScope, name: &str) -> Option<&'a SectionItem> {
198    scope.items.iter().find_map(|item| match &item.kind {
199        ScopeItemKind::Section(section) if section.name == name => Some(section),
200        _ => None,
201    })
202}
203
204fn find_section_mut<'a>(scope: &'a mut ParsedScope, name: &str) -> Option<&'a mut SectionItem> {
205    scope
206        .items
207        .iter_mut()
208        .find_map(|item| match &mut item.kind {
209            ScopeItemKind::Section(section) if section.name == name => Some(section),
210            _ => None,
211        })
212}
213
214fn has_scalar_key(scope: &ParsedScope, key: &str) -> bool {
215    scope.items.iter().any(|item| match &item.kind {
216        ScopeItemKind::Scalar(scalar) => scalar.key == key,
217        ScopeItemKind::Section(_) => false,
218    })
219}
220
221fn parse_scope(raw: &str) -> ParsedScope {
222    let lines = raw.lines().map(str::to_string).collect::<Vec<_>>();
223    let mut idx = 0usize;
224    parse_scope_lines(&lines, &mut idx, false, 0)
225}
226
227fn parse_scope_lines(
228    lines: &[String],
229    idx: &mut usize,
230    stop_at_end: bool,
231    depth: usize,
232) -> ParsedScope {
233    let mut items = Vec::new();
234    let mut pending = String::new();
235
236    while *idx < lines.len() {
237        let raw = lines[*idx].as_str();
238        let trimmed = raw.trim();
239
240        if stop_at_end && trimmed.eq_ignore_ascii_case("end") {
241            break;
242        }
243
244        if trimmed.is_empty() || trimmed.starts_with('#') {
245            pending.push_str(raw);
246            pending.push('\n');
247            *idx += 1;
248            continue;
249        }
250
251        if trimmed.ends_with(':') {
252            let header_line = raw.to_string();
253            let name = normalize_section_name(trimmed.trim_end_matches(':').trim(), depth);
254            *idx += 1;
255            let body = parse_scope_lines(lines, idx, true, depth + 1);
256            let end_line = if *idx < lines.len() && lines[*idx].trim().eq_ignore_ascii_case("end") {
257                let line = lines[*idx].clone();
258                *idx += 1;
259                line
260            } else {
261                String::from("end")
262            };
263            items.push(ScopeItem {
264                leading: mem::take(&mut pending),
265                kind: ScopeItemKind::Section(SectionItem {
266                    name,
267                    header_line,
268                    body,
269                    end_line,
270                }),
271            });
272            continue;
273        }
274
275        items.push(ScopeItem {
276            leading: mem::take(&mut pending),
277            kind: ScopeItemKind::Scalar(ScalarItem {
278                key: scalar_key(trimmed),
279                raw_line: raw.to_string(),
280            }),
281        });
282        *idx += 1;
283    }
284
285    ParsedScope {
286        items,
287        suffix: pending,
288    }
289}
290
291fn scalar_key(line: &str) -> String {
292    line.split_whitespace()
293        .next()
294        .map(normalize_token)
295        .unwrap_or_default()
296}
297
298fn normalize_token(token: &str) -> String {
299    token.trim().to_ascii_lowercase().replace('_', "-")
300}
301
302fn normalize_section_name(name: &str, depth: usize) -> String {
303    let normalized = normalize_token(name);
304    if depth > 0 {
305        return normalized;
306    }
307
308    canonical_top_level_section_name(normalized.as_str()).to_string()
309}
310
311fn canonical_top_level_section_name(name: &str) -> &str {
312    match name {
313        "animation" | "animations" => "animations",
314        "node" | "nodes" => "nodes",
315        "overlay" | "overlays" => "overlays",
316        "screenshot" | "screenshots" => "screenshot",
317        _ => name,
318    }
319}
320
321fn should_merge_top_level_section(name: &str) -> bool {
322    !matches!(name, "autostart" | "env" | "rules")
323}
324
325fn resolve_explicit_keybinds(entries: &[(String, String)]) -> Result<RuntimeTuning, String> {
326    let mut tuning = RuntimeTuning::default();
327    tuning.compositor_bindings.clear();
328    tuning.launch_bindings.clear();
329    tuning.pointer_bindings.clear();
330    apply_explicit_keybind_overrides_entries(entries, &mut tuning)?;
331    Ok(tuning)
332}
333
334fn compositor_or_launch_conflict(existing: &RuntimeTuning, candidate: &RuntimeTuning) -> bool {
335    candidate.compositor_bindings.iter().any(|binding| {
336        existing.compositor_bindings.iter().any(|existing_binding| {
337            existing_binding.modifiers == binding.modifiers && existing_binding.key == binding.key
338        }) || existing.launch_bindings.iter().any(|existing_binding| {
339            existing_binding.modifiers == binding.modifiers && existing_binding.key == binding.key
340        })
341    })
342}
343
344fn merge_resolved_bindings(existing: &mut RuntimeTuning, candidate: RuntimeTuning) {
345    existing
346        .compositor_bindings
347        .extend(candidate.compositor_bindings);
348    existing.launch_bindings.extend(candidate.launch_bindings);
349    existing.pointer_bindings.extend(candidate.pointer_bindings);
350}
351
352fn keybind_candidates() -> &'static [(&'static str, &'static str)] {
353    &[
354        ("alt+tab", "cycle-focus"),
355        ("alt+shift+tab", "cycle-focus-backward"),
356        ("$var.mod+m", "maximize-focused"),
357        ("$var.mod+p", "toggle-focused-pin"),
358        ("$var.mod+1", "cluster slot 1"),
359        ("$var.mod+2", "cluster slot 2"),
360        ("$var.mod+3", "cluster slot 3"),
361        ("$var.mod+4", "cluster slot 4"),
362        ("$var.mod+5", "cluster slot 5"),
363        ("$var.mod+6", "cluster slot 6"),
364        ("$var.mod+7", "cluster slot 7"),
365        ("$var.mod+8", "cluster slot 8"),
366        ("$var.mod+9", "cluster slot 9"),
367        ("$var.mod+0", "cluster slot 10"),
368    ]
369}
370
371fn candidate_entries(candidate: (&str, &str), mod_token: &str) -> Vec<(String, String)> {
372    let mut out = Vec::new();
373    if candidate.0.contains("$var.mod") {
374        out.push(("mod".to_string(), mod_token.to_string()));
375    }
376    out.push((candidate.0.to_string(), candidate.1.to_string()));
377    out
378}
379
380fn make_keybind_item(candidate: (&str, &str), needs_blank_line: bool) -> ScopeItem {
381    ScopeItem {
382        leading: if needs_blank_line {
383            String::from("\n")
384        } else {
385            String::new()
386        },
387        kind: ScopeItemKind::Scalar(ScalarItem {
388            key: normalize_token(candidate.0),
389            raw_line: format!("  \"{}\" \"{}\"", candidate.0, candidate.1),
390        }),
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn updater_adds_missing_animation_block() {
400        let raw = r#"
401animations:
402  enabled true
403  smooth-resize:
404    enabled true
405    duration-ms 90
406  end
407end
408"#;
409
410        let updated = RuntimeTuning::update_user_config_text(raw, &[])
411            .expect("config should update")
412            .expect("config should change");
413
414        assert!(updated.contains("  maximize:\n    enabled true"));
415        assert!(updated.contains("    duration-ms 240"));
416        assert!(updated.contains("  raise:\n    enabled true\n    duration-ms 140"));
417        assert!(updated.contains("smooth-resize:\n    enabled true\n    duration-ms 90"));
418    }
419
420    #[test]
421    fn updater_adds_missing_input_keyboard_block() {
422        let raw = r#"
423input:
424  repeat-rate 30
425  repeat-delay 500
426  focus-mode "click"
427end
428"#;
429
430        let updated = RuntimeTuning::update_user_config_text(raw, &[])
431            .expect("config should update")
432            .expect("config should change");
433
434        assert!(
435            updated
436                .contains("input:\n  repeat-rate 30\n  repeat-delay 500\n  focus-mode \"click\"")
437        );
438        assert!(updated.contains("  raise-on-click true"));
439        assert!(
440            updated.contains(
441                "  keyboard:\n    layout \"us\"\n    variant \"\"\n    options \"\"\n  end"
442            )
443        );
444    }
445
446    #[test]
447    fn updater_adds_missing_pin_defaults() {
448        let raw = r#"
449field:
450  pins:
451    corner "top-right"
452    colour "auto"
453  end
454end
455"#;
456
457        let updated = RuntimeTuning::update_user_config_text(raw, &[])
458            .expect("config should update")
459            .expect("config should change");
460
461        assert!(updated.contains("  pins:\n    corner \"top-right\"\n    colour \"auto\""));
462        assert!(updated.contains("    background-colour \"auto\""));
463        assert!(updated.contains("    size 1.0"));
464    }
465
466    #[test]
467    fn updater_adds_missing_decoration_shadow_defaults() {
468        let raw = r##"
469decorations:
470  border:
471    size 3
472    radius 0
473    colour-focused "#d65d26"
474    colour-unfocused "#333333"
475  end
476
477  resize-using-border true
478end
479"##;
480
481        let updated = RuntimeTuning::update_user_config_text(raw, &[])
482            .expect("config should update")
483            .expect("config should change");
484
485        assert!(updated.contains("  shadows:\n    window:"));
486        assert!(updated.contains("      blur-radius 8"));
487        assert!(updated.contains("      colour \"#05030530\""));
488        assert!(updated.contains("    node:\n      enabled true\n      blur-radius 14"));
489        assert!(updated.contains("    overlay:\n      enabled true\n      blur-radius 24"));
490        assert!(updated.contains("      colour \"#05030538\""));
491    }
492
493    #[test]
494    fn updater_respects_node_section_aliases() {
495        let raw = r#"
496node:
497  show-labels "always"
498end
499"#;
500
501        let updated = RuntimeTuning::update_user_config_text(raw, &[])
502            .expect("config should update")
503            .expect("config should change");
504
505        assert!(updated.contains("node:\n  show-labels \"always\""));
506        assert!(!updated.contains("\nnodes:\n"));
507        assert!(updated.contains("  shape \"square\""));
508    }
509
510    #[test]
511    fn updater_respects_animation_section_aliases() {
512        let raw = r#"
513animation:
514  enabled true
515end
516"#;
517
518        let updated = RuntimeTuning::update_user_config_text(raw, &[])
519            .expect("config should update")
520            .expect("config should change");
521
522        assert!(updated.contains("animation:\n  enabled true"));
523        assert!(!updated.contains("\nanimations:\n"));
524        assert!(updated.contains("  maximize:\n    enabled true"));
525        assert!(updated.contains("    duration-ms 240"));
526    }
527
528    #[test]
529    fn updater_adds_missing_keybind_candidates_without_conflicts() {
530        let raw = r#"
531keybinds:
532  mod "super"
533  "$var.mod+shift+r" "reload"
534end
535"#;
536
537        let updated = RuntimeTuning::update_user_config_text(raw, &[])
538            .expect("config should update")
539            .expect("config should change");
540
541        assert!(updated.contains("  \"alt+tab\" \"cycle-focus\""));
542        assert!(updated.contains("  \"alt+shift+tab\" \"cycle-focus-backward\""));
543        assert!(updated.contains("  \"$var.mod+m\" \"maximize-focused\""));
544        assert!(updated.contains("  \"$var.mod+0\" \"cluster slot 10\""));
545    }
546
547    #[test]
548    fn updater_skips_conflicting_keybind_candidates() {
549        let raw = r#"
550keybinds:
551  mod "super"
552  "alt+tab" "open-terminal"
553  "$var.mod+m" "fuzzel"
554  "$var.mod+1" "cluster slot 1"
555end
556"#;
557
558        let updated = RuntimeTuning::update_user_config_text(raw, &[])
559            .expect("config should update")
560            .expect("config should change");
561
562        assert!(!updated.contains("\"alt+tab\" \"cycle-focus\""));
563        assert!(!updated.contains("\"$var.mod+m\" \"maximize-focused\""));
564        assert_eq!(
565            updated.matches("\"$var.mod+1\" \"cluster slot 1\"").count(),
566            1
567        );
568        assert!(updated.contains("\"$var.mod+2\" \"cluster slot 2\""));
569    }
570
571    #[test]
572    fn updater_is_idempotent() {
573        let raw = r#"
574animations:
575  enabled true
576end
577
578keybinds:
579  mod "super"
580end
581"#;
582
583        let updated = RuntimeTuning::update_user_config_text(raw, &[])
584            .expect("config should update")
585            .expect("config should change");
586
587        assert!(
588            RuntimeTuning::update_user_config_text(updated.as_str(), &[])
589                .expect("second pass should succeed")
590                .is_none()
591        );
592    }
593
594    #[test]
595    fn updater_rejects_invalid_config_text() {
596        let raw = "keybinds:\n  \"mod+return\"\n";
597
598        let err = RuntimeTuning::update_user_config_text(raw, &[])
599            .expect_err("invalid config should fail");
600
601        assert!(err.contains("leaving file unchanged"));
602    }
603}