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}