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("  fullscreen:\n    enabled true"));
416        assert!(updated.contains("    duration-ms 240"));
417        assert!(updated.contains("  raise:\n    enabled true\n    duration-ms 140"));
418        assert!(updated.contains("smooth-resize:\n    enabled true\n    duration-ms 90"));
419    }
420
421    #[test]
422    fn updater_adds_missing_input_keyboard_block() {
423        let raw = r#"
424input:
425  repeat-rate 30
426  repeat-delay 500
427  focus-mode "click"
428end
429"#;
430
431        let updated = RuntimeTuning::update_user_config_text(raw, &[])
432            .expect("config should update")
433            .expect("config should change");
434
435        assert!(
436            updated
437                .contains("input:\n  repeat-rate 30\n  repeat-delay 500\n  focus-mode \"click\"")
438        );
439        assert!(updated.contains("  raise-on-click true"));
440        assert!(
441            updated.contains(
442                "  keyboard:\n    layout \"us\"\n    variant \"\"\n    options \"\"\n  end"
443            )
444        );
445    }
446
447    #[test]
448    fn updater_adds_missing_debug_section() {
449        let raw = r#"
450input:
451  repeat-rate 30
452end
453"#;
454
455        let updated = RuntimeTuning::update_user_config_text(raw, &[])
456            .expect("config should update")
457            .expect("config should change");
458
459        assert!(
460            updated.contains("debug:\n  overlay-fps false\n  show-ring-when-resizing true\nend")
461        );
462    }
463
464    #[test]
465    fn updater_adds_missing_pin_defaults() {
466        let raw = r#"
467field:
468  pins:
469    corner "top-right"
470    colour "auto"
471  end
472end
473"#;
474
475        let updated = RuntimeTuning::update_user_config_text(raw, &[])
476            .expect("config should update")
477            .expect("config should change");
478
479        assert!(updated.contains("  pins:\n    corner \"top-right\"\n    colour \"auto\""));
480        assert!(updated.contains("    background-colour \"auto\""));
481        assert!(updated.contains("    size 1.0"));
482    }
483
484    #[test]
485    fn updater_adds_missing_decoration_shadow_defaults() {
486        let raw = r##"
487decorations:
488  border:
489    size 3
490    radius 0
491    colour-focused "#d65d26"
492    colour-unfocused "#333333"
493  end
494
495  resize-using-border true
496end
497"##;
498
499        let updated = RuntimeTuning::update_user_config_text(raw, &[])
500            .expect("config should update")
501            .expect("config should change");
502
503        assert!(updated.contains("  shadows:\n    window:"));
504        assert!(updated.contains("      blur-radius 8"));
505        assert!(updated.contains("      colour \"#05030530\""));
506        assert!(updated.contains("    node:\n      enabled true\n      blur-radius 14"));
507        assert!(updated.contains("    overlay:\n      enabled true\n      blur-radius 24"));
508        assert!(updated.contains("      colour \"#05030538\""));
509    }
510
511    #[test]
512    fn updater_respects_node_section_aliases() {
513        let raw = r#"
514node:
515  show-labels "always"
516end
517"#;
518
519        let updated = RuntimeTuning::update_user_config_text(raw, &[])
520            .expect("config should update")
521            .expect("config should change");
522
523        assert!(updated.contains("node:\n  show-labels \"always\""));
524        assert!(!updated.contains("\nnodes:\n"));
525        assert!(updated.contains("  shape \"square\""));
526    }
527
528    #[test]
529    fn updater_respects_animation_section_aliases() {
530        let raw = r#"
531animation:
532  enabled true
533end
534"#;
535
536        let updated = RuntimeTuning::update_user_config_text(raw, &[])
537            .expect("config should update")
538            .expect("config should change");
539
540        assert!(updated.contains("animation:\n  enabled true"));
541        assert!(!updated.contains("\nanimations:\n"));
542        assert!(updated.contains("  maximize:\n    enabled true"));
543        assert!(updated.contains("  fullscreen:\n    enabled true"));
544        assert!(updated.contains("    duration-ms 240"));
545    }
546
547    #[test]
548    fn updater_adds_missing_keybind_candidates_without_conflicts() {
549        let raw = r#"
550keybinds:
551  mod "super"
552  "$var.mod+shift+r" "reload"
553end
554"#;
555
556        let updated = RuntimeTuning::update_user_config_text(raw, &[])
557            .expect("config should update")
558            .expect("config should change");
559
560        assert!(updated.contains("  \"alt+tab\" \"cycle-focus\""));
561        assert!(updated.contains("  \"alt+shift+tab\" \"cycle-focus-backward\""));
562        assert!(updated.contains("  \"$var.mod+m\" \"maximize-focused\""));
563        assert!(updated.contains("  \"$var.mod+0\" \"cluster slot 10\""));
564    }
565
566    #[test]
567    fn updater_skips_conflicting_keybind_candidates() {
568        let raw = r#"
569keybinds:
570  mod "super"
571  "alt+tab" "open-terminal"
572  "$var.mod+m" "fuzzel"
573  "$var.mod+1" "cluster slot 1"
574end
575"#;
576
577        let updated = RuntimeTuning::update_user_config_text(raw, &[])
578            .expect("config should update")
579            .expect("config should change");
580
581        assert!(!updated.contains("\"alt+tab\" \"cycle-focus\""));
582        assert!(!updated.contains("\"$var.mod+m\" \"maximize-focused\""));
583        assert_eq!(
584            updated.matches("\"$var.mod+1\" \"cluster slot 1\"").count(),
585            1
586        );
587        assert!(updated.contains("\"$var.mod+2\" \"cluster slot 2\""));
588    }
589
590    #[test]
591    fn updater_is_idempotent() {
592        let raw = r#"
593animations:
594  enabled true
595end
596
597keybinds:
598  mod "super"
599end
600"#;
601
602        let updated = RuntimeTuning::update_user_config_text(raw, &[])
603            .expect("config should update")
604            .expect("config should change");
605
606        assert!(
607            RuntimeTuning::update_user_config_text(updated.as_str(), &[])
608                .expect("second pass should succeed")
609                .is_none()
610        );
611    }
612
613    #[test]
614    fn updater_rejects_invalid_config_text() {
615        let raw = "keybinds:\n  \"mod+return\"\n";
616
617        let err = RuntimeTuning::update_user_config_text(raw, &[])
618            .expect_err("invalid config should fail");
619
620        assert!(err.contains("leaving file unchanged"));
621    }
622}