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}