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+1", "cluster slot 1"),
358        ("$var.mod+2", "cluster slot 2"),
359        ("$var.mod+3", "cluster slot 3"),
360        ("$var.mod+4", "cluster slot 4"),
361        ("$var.mod+5", "cluster slot 5"),
362        ("$var.mod+6", "cluster slot 6"),
363        ("$var.mod+7", "cluster slot 7"),
364        ("$var.mod+8", "cluster slot 8"),
365        ("$var.mod+9", "cluster slot 9"),
366        ("$var.mod+0", "cluster slot 10"),
367    ]
368}
369
370fn candidate_entries(candidate: (&str, &str), mod_token: &str) -> Vec<(String, String)> {
371    let mut out = Vec::new();
372    if candidate.0.contains("$var.mod") {
373        out.push(("mod".to_string(), mod_token.to_string()));
374    }
375    out.push((candidate.0.to_string(), candidate.1.to_string()));
376    out
377}
378
379fn make_keybind_item(candidate: (&str, &str), needs_blank_line: bool) -> ScopeItem {
380    ScopeItem {
381        leading: if needs_blank_line {
382            String::from("\n")
383        } else {
384            String::new()
385        },
386        kind: ScopeItemKind::Scalar(ScalarItem {
387            key: normalize_token(candidate.0),
388            raw_line: format!("  \"{}\" \"{}\"", candidate.0, candidate.1),
389        }),
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn updater_adds_missing_animation_block() {
399        let raw = r#"
400animations:
401  enabled true
402  smooth-resize:
403    enabled true
404    duration-ms 90
405  end
406end
407"#;
408
409        let updated = RuntimeTuning::update_user_config_text(raw, &[])
410            .expect("config should update")
411            .expect("config should change");
412
413        assert!(updated.contains("  maximize:\n    enabled true\n    duration-ms 240\n  end"));
414        assert!(updated.contains("smooth-resize:\n    enabled true\n    duration-ms 90"));
415    }
416
417    #[test]
418    fn updater_adds_missing_input_keyboard_block() {
419        let raw = r#"
420input:
421  repeat-rate 30
422  repeat-delay 500
423  focus-mode "click"
424end
425"#;
426
427        let updated = RuntimeTuning::update_user_config_text(raw, &[])
428            .expect("config should update")
429            .expect("config should change");
430
431        assert!(
432            updated
433                .contains("input:\n  repeat-rate 30\n  repeat-delay 500\n  focus-mode \"click\"")
434        );
435        assert!(
436            updated.contains(
437                "  keyboard:\n    layout \"us\"\n    variant \"\"\n    options \"\"\n  end"
438            )
439        );
440    }
441
442    #[test]
443    fn updater_adds_missing_decoration_shadow_defaults() {
444        let raw = r##"
445decorations:
446  border:
447    size 3
448    radius 0
449    colour-focused "#d65d26"
450    colour-unfocused "#333333"
451  end
452
453  resize-using-border true
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("  shadows:\n    window:"));
462        assert!(updated.contains("      blur-radius 8"));
463        assert!(updated.contains("      colour \"#05030530\""));
464        assert!(updated.contains("    node:\n      enabled true\n      blur-radius 14"));
465        assert!(updated.contains("    overlay:\n      enabled true\n      blur-radius 24"));
466        assert!(updated.contains("      colour \"#05030538\""));
467    }
468
469    #[test]
470    fn updater_respects_node_section_aliases() {
471        let raw = r#"
472node:
473  show-labels "always"
474end
475"#;
476
477        let updated = RuntimeTuning::update_user_config_text(raw, &[])
478            .expect("config should update")
479            .expect("config should change");
480
481        assert!(updated.contains("node:\n  show-labels \"always\""));
482        assert!(!updated.contains("\nnodes:\n"));
483        assert!(updated.contains("  shape \"square\""));
484    }
485
486    #[test]
487    fn updater_respects_animation_section_aliases() {
488        let raw = r#"
489animation:
490  enabled true
491end
492"#;
493
494        let updated = RuntimeTuning::update_user_config_text(raw, &[])
495            .expect("config should update")
496            .expect("config should change");
497
498        assert!(updated.contains("animation:\n  enabled true"));
499        assert!(!updated.contains("\nanimations:\n"));
500        assert!(updated.contains("  maximize:\n    enabled true\n    duration-ms 240\n  end"));
501    }
502
503    #[test]
504    fn updater_adds_missing_keybind_candidates_without_conflicts() {
505        let raw = r#"
506keybinds:
507  mod "super"
508  "$var.mod+shift+r" "reload"
509end
510"#;
511
512        let updated = RuntimeTuning::update_user_config_text(raw, &[])
513            .expect("config should update")
514            .expect("config should change");
515
516        assert!(updated.contains("  \"alt+tab\" \"cycle-focus\""));
517        assert!(updated.contains("  \"alt+shift+tab\" \"cycle-focus-backward\""));
518        assert!(updated.contains("  \"$var.mod+m\" \"maximize-focused\""));
519        assert!(updated.contains("  \"$var.mod+0\" \"cluster slot 10\""));
520    }
521
522    #[test]
523    fn updater_skips_conflicting_keybind_candidates() {
524        let raw = r#"
525keybinds:
526  mod "super"
527  "alt+tab" "open-terminal"
528  "$var.mod+m" "fuzzel"
529  "$var.mod+1" "cluster slot 1"
530end
531"#;
532
533        let updated = RuntimeTuning::update_user_config_text(raw, &[])
534            .expect("config should update")
535            .expect("config should change");
536
537        assert!(!updated.contains("\"alt+tab\" \"cycle-focus\""));
538        assert!(!updated.contains("\"$var.mod+m\" \"maximize-focused\""));
539        assert_eq!(
540            updated.matches("\"$var.mod+1\" \"cluster slot 1\"").count(),
541            1
542        );
543        assert!(updated.contains("\"$var.mod+2\" \"cluster slot 2\""));
544    }
545
546    #[test]
547    fn updater_is_idempotent() {
548        let raw = r#"
549animations:
550  enabled true
551end
552
553keybinds:
554  mod "super"
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!(
563            RuntimeTuning::update_user_config_text(updated.as_str(), &[])
564                .expect("second pass should succeed")
565                .is_none()
566        );
567    }
568
569    #[test]
570    fn updater_rejects_invalid_config_text() {
571        let raw = "keybinds:\n  \"mod+return\"\n";
572
573        let err = RuntimeTuning::update_user_config_text(raw, &[])
574            .expect_err("invalid config should fail");
575
576        assert!(err.contains("leaving file unchanged"));
577    }
578}