use std::collections::HashMap;
use anyhow::{Result, bail};
use crate::config::{Config, Layout, LayoutRow};
pub fn apply(source: &str, desired: &Layout) -> Result<String> {
let current: Config = toml::from_str(source)?;
let lines: Vec<&str> = source.lines().collect();
let map = map_layout(&lines)?;
if map.rows.len() != current.layout.rows.len() {
bail!(
"found {} layout rows in the text but {} when parsed; the `[layout]` \
block is formatted in a way this cannot edit safely",
map.rows.len(),
current.layout.rows.len()
);
}
let blocks = panel_blocks(&lines, &map);
let pairing = pair_rows(&map, desired);
let mut out: Vec<String> = lines.iter().map(|line| (*line).to_string()).collect();
let mut edits: Vec<Edit> = Vec::new();
for (text_index, row) in map.rows.iter().enumerate() {
if !pairing.contains(&Some(text_index)) {
edits.push(Edit::Replace {
from: row.header_line,
to: row.closing_line + 1,
text: Vec::new(),
});
}
}
for (want_index, want) in desired.rows.iter().enumerate() {
let Some(text_index) = pairing[want_index] else {
let after = (0..want_index)
.rev()
.filter_map(|earlier| pairing[earlier])
.map(|text| map.rows[text].closing_line + 1)
.next()
.unwrap_or(map.rows[0].header_line);
edits.push(Edit::Replace {
from: after,
to: after,
text: row_block(&lines, &map, &blocks, want),
});
continue;
};
let row = &map.rows[text_index];
if current.layout.rows[text_index].height != want.height {
edits.push(Edit::Number {
line: row.header_line,
key: "height",
value: want.height,
});
}
let unchanged_order = row
.panels
.iter()
.map(|panel| panel.widget.as_str())
.eq(want.panels.iter().map(|panel| panel.widget.as_str()));
if unchanged_order {
for (panel, wanted) in row.panels.iter().zip(&want.panels) {
if panel.width != wanted.width {
edits.push(Edit::Number {
line: panel.line,
key: "width",
value: wanted.width,
});
}
}
continue;
}
let (from, to) = match (row.panels.first(), row.panels.last()) {
(Some(first), Some(last)) => (first.from, last.line + 1),
_ => (row.header_line + 1, row.header_line + 1),
};
let template = row.panels.first().map_or(row.header_line, |p| p.line);
edits.push(Edit::Replace {
from,
to,
text: panel_lines(&lines, &blocks, want, template),
});
}
edits.sort_by_key(Edit::anchor);
for edit in edits.iter().rev() {
match edit {
Edit::Number { line, key, value } => {
out[*line] = set_number(&out[*line], key, *value);
}
Edit::Replace { from, to, text } => {
out.splice(*from..*to, text.iter().cloned());
}
}
}
let mut result = out.join("\n");
if source.ends_with('\n') {
result.push('\n');
}
let reparsed: Config = toml::from_str(&result)
.map_err(|e| anyhow::anyhow!("the edited config no longer parses: {e}"))?;
if shape(&reparsed.layout) != shape(desired) {
bail!("the edited config does not describe the requested layout");
}
Ok(result)
}
fn shape(layout: &Layout) -> Vec<(u16, Vec<(String, u16)>)> {
layout
.rows
.iter()
.map(|row| {
(
row.height,
row.panels
.iter()
.map(|p| (p.widget.clone(), p.width))
.collect(),
)
})
.collect()
}
enum Edit {
Number {
line: usize,
key: &'static str,
value: u16,
},
Replace {
from: usize,
to: usize,
text: Vec<String>,
},
}
impl Edit {
fn anchor(&self) -> usize {
match self {
Self::Number { line, .. } => *line,
Self::Replace { from, .. } => *from,
}
}
}
struct PanelSite {
widget: String,
width: u16,
from: usize,
line: usize,
}
struct RowSite {
header_line: usize,
closing_line: usize,
panels: Vec<PanelSite>,
}
struct PanelBlock {
lines: Vec<String>,
}
fn panel_blocks(lines: &[&str], map: &LayoutMap) -> HashMap<String, PanelBlock> {
let mut blocks = HashMap::new();
for row in &map.rows {
for panel in &row.panels {
blocks.insert(
panel.widget.clone(),
PanelBlock {
lines: lines[panel.from..=panel.line]
.iter()
.map(|line| (*line).to_string())
.collect(),
},
);
}
}
blocks
}
fn pair_rows(map: &LayoutMap, desired: &Layout) -> Vec<Option<usize>> {
let kept = |row: &RowSite, want: &LayoutRow| {
row.panels
.iter()
.filter(|panel| want.panels.iter().any(|w| w.widget == panel.widget))
.count()
};
let mut pairing = vec![None; desired.rows.len()];
for (text_index, row) in map.rows.iter().enumerate() {
let claimant = desired
.rows
.iter()
.enumerate()
.filter(|(want_index, _)| pairing[*want_index].is_none())
.map(|(want_index, want)| (kept(row, want), want_index))
.filter(|(shared, _)| *shared > 0)
.max_by_key(|(shared, want_index)| (*shared, std::cmp::Reverse(*want_index)));
if let Some((_, want_index)) = claimant {
pairing[want_index] = Some(text_index);
}
}
pairing
}
fn panel_lines(
lines: &[&str],
blocks: &HashMap<String, PanelBlock>,
want: &LayoutRow,
template: usize,
) -> Vec<String> {
let mut out = Vec::new();
for panel in &want.panels {
match blocks.get(&panel.widget) {
Some(block) => {
let last = block.lines.len().saturating_sub(1);
for (offset, text) in block.lines.iter().enumerate() {
if offset == last {
out.push(set_number(text, "width", panel.width));
} else {
out.push(text.clone());
}
}
}
None => out.push(panel_line(lines, template, &panel.widget, panel.width)),
}
}
out
}
fn row_block(
lines: &[&str],
map: &LayoutMap,
blocks: &HashMap<String, PanelBlock>,
want: &LayoutRow,
) -> Vec<String> {
let model = &map.rows[0];
let indent: String = lines
.get(model.header_line)
.copied()
.unwrap_or("")
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let template = model.panels.first().map_or(model.header_line, |p| p.line);
let mut out = vec![format!("{indent}{{ height = {}, panels = [", want.height)];
out.extend(panel_lines(lines, blocks, want, template));
out.push(format!("{indent}] }},"));
out
}
struct LayoutMap {
rows: Vec<RowSite>,
}
fn map_layout(lines: &[&str]) -> Result<LayoutMap> {
let mut rows: Vec<RowSite> = Vec::new();
let mut in_layout = false;
for (index, raw) in lines.iter().enumerate() {
let line = strip_comment(raw);
let trimmed = line.trim();
if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
in_layout = trimmed == "[layout]";
continue;
}
if !in_layout {
continue;
}
if line.contains("panels") && line.contains('[') {
rows.push(RowSite {
header_line: index,
closing_line: index,
panels: Vec::new(),
});
}
if let Some(widget) = quoted_value(line, "widget")
&& let Some(width) = number_value(line, "width")
&& let Some(row) = rows.last_mut()
{
let mut from = index;
while from > row.header_line + 1 && lines[from - 1].trim().starts_with('#') {
from -= 1;
}
row.panels.push(PanelSite {
widget,
width,
from,
line: index,
});
}
if trimmed.starts_with(']')
&& let Some(row) = rows.last_mut()
&& row.closing_line == row.header_line
{
row.closing_line = index;
}
}
if rows.is_empty() {
bail!("no `[layout]` rows found in the config text");
}
Ok(LayoutMap { rows })
}
fn strip_comment(line: &str) -> &str {
let mut in_string = false;
for (index, ch) in line.char_indices() {
match ch {
'"' => in_string = !in_string,
'#' if !in_string => return &line[..index],
_ => {}
}
}
line
}
fn quoted_value(line: &str, key: &str) -> Option<String> {
let rest = after_key(line, key)?;
let rest = rest.trim_start().strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn number_value(line: &str, key: &str) -> Option<u16> {
let rest = after_key(line, key)?;
let digits: String = rest
.trim_start()
.chars()
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok()
}
fn after_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let mut from = 0;
while let Some(found) = line[from..].find(key) {
let at = from + found;
let before_ok = at == 0
|| !line[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_');
let rest = &line[at + key.len()..];
let after = rest.trim_start();
if before_ok && let Some(value) = after.strip_prefix('=') {
return Some(value);
}
from = at + key.len();
}
None
}
fn set_number(line: &str, key: &str, value: u16) -> String {
let Some(rest) = after_key(line, key) else {
return line.to_string();
};
let start = line.len() - rest.len();
let spaces = rest.len() - rest.trim_start().len();
let digits = rest
.trim_start()
.chars()
.take_while(char::is_ascii_digit)
.count();
let new = value.to_string();
let padding = spaces + digits.saturating_sub(new.len());
format!(
"{}{}{}{}",
&line[..start],
" ".repeat(padding.max(1)),
new,
&line[start + spaces + digits..]
)
}
fn panel_line(lines: &[&str], after: usize, widget: &str, width: u16) -> String {
let template = lines.get(after).copied().unwrap_or("");
let indent: String = template
.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>();
let indent = if quoted_value(strip_comment(template), "widget").is_some() {
indent
} else {
format!("{indent} ")
};
format!("{indent}{{ widget = \"{widget}\", width = {width} }},")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"# a comment at the top
[general]
mouse = true
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
[layout]
rows = [
{ height = 34, panels = [
{ widget = "clocks", width = 26 },
# Wide enough for two months side by side.
{ widget = "calendar", width = 34 },
] },
{ height = 42, panels = [
{ widget = "todo", width = 48 },
{ widget = "notes", width = 30 },
] },
]
[weather]
units = "imperial"
"#;
fn layout_of(text: &str) -> Layout {
toml::from_str::<Config>(text).expect("parses").layout
}
#[test]
fn no_change_produces_an_identical_file() {
let desired = layout_of(SAMPLE);
assert_eq!(apply(SAMPLE, &desired).unwrap(), SAMPLE);
}
#[test]
fn a_width_change_touches_only_that_number() {
let mut desired = layout_of(SAMPLE);
desired.rows[1].panels[0].width = 60;
let out = apply(SAMPLE, &desired).unwrap();
assert!(out.contains(r#"{ widget = "todo", width = 60 },"#));
assert!(
out.contains("# Wide enough for two months side by side."),
"a comment inside the layout block must survive"
);
assert!(out.contains("# a comment at the top"));
assert!(out.contains(r#"units = "imperial""#));
assert_eq!(layout_of(&out).rows[1].panels[0].width, 60);
}
#[test]
fn a_panel_moved_along_its_row_takes_its_comment_with_it() {
let mut desired = layout_of(SAMPLE);
desired.rows[0].panels.swap(0, 1);
let out = apply(SAMPLE, &desired).unwrap();
let calendar = out.find(r#""calendar""#).expect("calendar is still placed");
let clocks = out.find(r#""clocks""#).expect("clocks is still placed");
assert!(calendar < clocks, "calendar should now come first:\n{out}");
let comment = out
.find("# Wide enough for two months side by side.")
.expect("the comment survives");
assert!(
comment < calendar && clocks < comment.max(calendar) + out.len(),
"the comment should sit directly above calendar:\n{out}"
);
assert_eq!(shape(&layout_of(&out)), shape(&desired));
}
#[test]
fn a_new_row_can_be_written_between_two_that_exist() {
let mut desired = layout_of(SAMPLE);
let calendar = desired.rows[0].panels.remove(1);
desired.rows.insert(
1,
LayoutRow {
height: 20,
panels: vec![calendar],
},
);
let out = apply(SAMPLE, &desired).unwrap();
let written = layout_of(&out);
assert_eq!(written.rows.len(), 3, "a row was added:\n{out}");
assert_eq!(written.rows[1].panels[0].widget, "calendar");
assert_eq!(written.rows[1].height, 20);
assert!(
out.contains("# Wide enough for two months side by side."),
"the comment follows the panel into its new row:\n{out}"
);
assert_eq!(shape(&written), shape(&desired));
}
#[test]
fn the_row_a_panel_leaves_empty_is_closed() {
let mut desired = layout_of(SAMPLE);
let notes = desired.rows[1].panels.remove(1);
let todo = desired.rows[1].panels.remove(0);
desired.rows.remove(1);
desired.rows[0].panels.push(todo);
desired.rows[0].panels.push(notes);
let out = apply(SAMPLE, &desired).unwrap();
let written = layout_of(&out);
assert_eq!(written.rows.len(), 1, "the emptied row is gone:\n{out}");
assert_eq!(written.rows[0].panels.len(), 4);
assert!(out.contains("\n]\n"), "the rows array still closes:\n{out}");
assert!(out.contains(r#"units = "imperial""#), "the rest survives");
assert_eq!(shape(&written), shape(&desired));
}
#[test]
fn a_panel_moved_to_another_row_keeps_its_comment() {
let mut desired = layout_of(SAMPLE);
let calendar = desired.rows[0].panels.remove(1);
desired.rows[1].panels.insert(0, calendar);
let out = apply(SAMPLE, &desired).unwrap();
let written = layout_of(&out);
assert_eq!(written.rows[0].panels.len(), 1);
assert_eq!(written.rows[1].panels[0].widget, "calendar");
assert!(
out.contains("# Wide enough for two months side by side."),
"the comment moved rows with the panel:\n{out}"
);
assert_eq!(shape(&written), shape(&desired));
}
#[test]
fn a_resize_still_rewrites_nothing_but_the_number() {
let mut desired = layout_of(SAMPLE);
desired.rows[0].panels[0].width = 30;
let out = apply(SAMPLE, &desired).unwrap();
let before: Vec<&str> = SAMPLE.lines().collect();
let after: Vec<&str> = out.lines().collect();
assert_eq!(before.len(), after.len(), "no line was added or removed");
let changed: Vec<usize> = (0..before.len())
.filter(|i| before[*i] != after[*i])
.collect();
assert_eq!(changed.len(), 1, "exactly one line moved:\n{out}");
assert!(after[changed[0]].contains("width = 30"));
}
#[test]
fn a_height_change_edits_the_row_header() {
let mut desired = layout_of(SAMPLE);
desired.rows[0].height = 50;
let out = apply(SAMPLE, &desired).unwrap();
assert!(out.contains("{ height = 50, panels = ["));
assert_eq!(layout_of(&out).rows[0].height, 50);
}
#[test]
fn adding_a_panel_inserts_one_line_after_the_last_of_its_row() {
let mut desired = layout_of(SAMPLE);
desired.add_widget("pomodoro");
let out = apply(SAMPLE, &desired).unwrap();
assert!(out.contains(r#"{ widget = "pomodoro", width = "#));
assert_eq!(
out.lines().count(),
SAMPLE.lines().count() + 1,
"exactly one line added"
);
assert!(layout_of(&out).places("pomodoro"));
assert!(out.contains("# Wide enough for two months side by side."));
}
#[test]
fn removing_a_panel_deletes_only_its_line() {
let mut desired = layout_of(SAMPLE);
assert!(desired.remove_widget("notes"));
let out = apply(SAMPLE, &desired).unwrap();
assert!(!out.contains(r#"widget = "notes""#));
assert!(out.contains(r#"widget = "todo""#));
assert_eq!(out.lines().count(), SAMPLE.lines().count() - 1);
assert!(!layout_of(&out).places("notes"));
}
#[test]
fn several_changes_at_once_do_not_disturb_each_others_line_numbers() {
let mut desired = layout_of(SAMPLE);
desired.rows[0].panels[0].width = 20;
desired.remove_widget("calendar");
desired.add_widget("cpu");
desired.rows[1].height = 55;
let out = apply(SAMPLE, &desired).unwrap();
let got = layout_of(&out);
assert_eq!(got.rows[0].panels[0].width, 20);
assert!(!got.places("calendar"));
assert!(got.places("cpu"));
assert_eq!(got.rows[1].height, 55);
}
#[test]
fn a_layout_this_cannot_map_is_refused_rather_than_mangled() {
let flat = "[layout]\nrows = [ { height = 100, panels = [ { widget = \"todo\", width = 100 } ] } ]\n";
let mut desired = layout_of(flat);
desired.add_widget("notes");
if let Ok(out) = apply(flat, &desired) {
assert_eq!(shape(&layout_of(&out)), shape(&desired));
}
}
#[test]
fn a_file_without_a_layout_block_is_refused() {
let text = "[general]\nmouse = true\n";
let desired = layout_of(SAMPLE);
assert!(apply(text, &desired).is_err());
}
#[test]
fn width_is_not_confused_by_a_key_that_ends_in_the_same_word() {
assert_eq!(number_value("max_width = 7, width = 42", "width"), Some(42));
assert_eq!(after_key("max_width = 7", "width"), None);
}
#[test]
fn a_commented_out_panel_is_not_treated_as_a_real_one() {
let text = SAMPLE.replace(
r#" { widget = "notes", width = 30 },"#,
r#" # { widget = "notes", width = 30 },"#,
);
let desired = layout_of(&text);
assert!(!desired.places("notes"));
let out = apply(&text, &desired).unwrap();
assert!(out.contains(r#"# { widget = "notes""#));
assert!(!layout_of(&out).places("notes"));
}
#[test]
fn trailing_newline_is_preserved_either_way() {
let desired = layout_of(SAMPLE);
assert!(apply(SAMPLE, &desired).unwrap().ends_with('\n'));
let without = SAMPLE.trim_end_matches('\n');
assert!(!apply(without, &desired).unwrap().ends_with('\n'));
}
#[test]
fn the_comment_count_the_docs_quote_is_the_one_in_the_file() {
const CITED: usize = 254;
let actual = crate::config::DEFAULT_CONFIG
.lines()
.filter(|line| line.trim_start().starts_with('#'))
.count();
assert_eq!(
actual, CITED,
"the default config now has {actual} comment lines. Update this \
constant and `CLAUDE.md` invariant 16 — both quote {CITED}."
);
}
}