use super::super::ui::theme::ThemePalette;
use super::App;
impl App {
pub fn cycle_color_scheme(&mut self) {
self.scheme = self.scheme.next();
self.palette = ThemePalette::for_scheme(self.scheme, self.theme_mode);
if let Some(ref custom) = self.config.theme.custom {
self.palette.apply_custom(custom);
}
self.save_theme_scheme();
}
fn save_theme_scheme(&self) {
let Some(ref path) = self.config_path else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let contents = std::fs::read_to_string(path).unwrap_or_default();
let result = update_theme_in_config(&contents, self.scheme, self.config.theme.mode);
let _ = std::fs::write(path, result);
}
}
fn update_theme_in_config(
contents: &str,
scheme: crate::config::ThemeScheme,
explicit_mode: Option<crate::config::ThemeMode>,
) -> String {
use crate::config::{ThemeMode, ThemeScheme};
let slug = scheme.slug();
let lines: Vec<&str> = contents.lines().collect();
let mut uncommented_idx = None;
let mut commented_idx = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("theme:") && uncommented_idx.is_none() {
uncommented_idx = Some(i);
} else if trimmed.starts_with("# theme:") && commented_idx.is_none() {
commented_idx = Some(i);
}
}
let target_idx = uncommented_idx.or(commented_idx);
let mut custom_lines: Vec<String> = Vec::new();
if let Some(idx) = target_idx {
let trimmed = lines[idx].trim_start();
let is_block = trimmed
.strip_prefix("theme:")
.is_some_and(|rest| rest.trim().is_empty());
if is_block {
let block_indent = lines[idx].len() - trimmed.len();
let mut i = idx + 1;
let mut in_custom = false;
while i < lines.len() {
let next_trimmed = lines[i].trim_start();
if next_trimmed.is_empty() {
i += 1;
continue;
}
if (lines[i].len() - next_trimmed.len()) <= block_indent {
break;
}
if next_trimmed.starts_with("custom:") {
in_custom = true;
custom_lines.push(lines[i].to_string());
} else if in_custom {
let custom_indent = custom_lines
.first()
.map(|l| l.len() - l.trim_start().len())
.unwrap_or(0);
if (lines[i].len() - next_trimmed.len()) > custom_indent {
custom_lines.push(lines[i].to_string());
} else {
in_custom = false;
}
}
i += 1;
}
}
}
let needs_block = explicit_mode.is_some() || !custom_lines.is_empty();
let new_lines_for_theme =
if scheme == ThemeScheme::Default && explicit_mode.is_none() && custom_lines.is_empty() {
vec!["# theme: default".to_string()]
} else if needs_block {
let mut v = vec!["theme:".to_string()];
v.push(format!(" scheme: {}", slug));
if let Some(mode) = explicit_mode {
let mode_str = match mode {
ThemeMode::Dark => "dark",
ThemeMode::Light => "light",
};
v.push(format!(" mode: {}", mode_str));
}
v.extend(custom_lines);
v
} else {
vec![format!("theme: {}", slug)]
};
let mut result_lines: Vec<String> = Vec::new();
let mut replaced = false;
let mut iter = lines.iter().enumerate().peekable();
while let Some((i, line)) = iter.next() {
if !replaced && Some(i) == target_idx {
replaced = true;
result_lines.extend(new_lines_for_theme.clone());
let trimmed = line.trim_start();
let is_block = trimmed
.strip_prefix("theme:")
.is_some_and(|rest| rest.trim().is_empty());
if is_block {
let block_indent = line.len() - trimmed.len();
while let Some(&(_, next_line)) = iter.peek() {
let next_trimmed = next_line.trim_start();
if next_trimmed.is_empty() {
iter.next();
continue;
}
if (next_line.len() - next_trimmed.len()) > block_indent {
iter.next();
} else {
break;
}
}
}
} else {
result_lines.push(line.to_string());
}
}
if !replaced && scheme != ThemeScheme::Default {
result_lines.extend(new_lines_for_theme);
}
let mut result = result_lines.join("\n");
if contents.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
if !contents.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
#[cfg(test)]
mod theme_persistence_tests {
use super::update_theme_in_config;
use crate::config::{ThemeMode, ThemeScheme};
#[test]
fn simple_theme_line() {
let input = "agent: claude\ntheme: default\nmode: window\n";
let result = update_theme_in_config(input, ThemeScheme::Emberforge, None);
assert_eq!(result, "agent: claude\ntheme: emberforge\nmode: window\n");
}
#[test]
fn no_theme_line_appends() {
let input = "agent: claude\n";
let result = update_theme_in_config(input, ThemeScheme::Lasergrid, None);
assert_eq!(result, "agent: claude\ntheme: lasergrid\n");
}
#[test]
fn no_theme_line_default_does_nothing() {
let input = "agent: claude\n";
let result = update_theme_in_config(input, ThemeScheme::Default, None);
assert_eq!(result, "agent: claude\n");
}
#[test]
fn default_scheme_comments_out() {
let input = "theme: emberforge\n";
let result = update_theme_in_config(input, ThemeScheme::Default, None);
assert_eq!(result, "# theme: default\n");
}
#[test]
fn structured_block_replaced() {
let input = "agent: claude\ntheme:\n scheme: emberforge\n mode: dark\nmode: window\n";
let result = update_theme_in_config(input, ThemeScheme::SlateGarden, None);
assert_eq!(result, "agent: claude\ntheme: slate-garden\nmode: window\n");
}
#[test]
fn structured_block_with_blank_lines() {
let input = "agent: claude\ntheme:\n scheme: emberforge\n\n mode: dark\nmode: window\n";
let result = update_theme_in_config(input, ThemeScheme::Mossfire, None);
assert_eq!(result, "agent: claude\ntheme: mossfire\nmode: window\n");
}
#[test]
fn preserves_explicit_mode() {
let input = "theme: emberforge\n";
let result =
update_theme_in_config(input, ThemeScheme::GlacierSignal, Some(ThemeMode::Light));
assert_eq!(result, "theme:\n scheme: glacier-signal\n mode: light\n");
}
#[test]
fn preserves_explicit_dark_mode() {
let input = "theme: default\n";
let result = update_theme_in_config(input, ThemeScheme::ObsidianPop, Some(ThemeMode::Dark));
assert_eq!(result, "theme:\n scheme: obsidian-pop\n mode: dark\n");
}
#[test]
fn default_with_explicit_mode() {
let input = "theme: emberforge\n";
let result = update_theme_in_config(input, ThemeScheme::Default, Some(ThemeMode::Light));
assert_eq!(result, "theme:\n scheme: default\n mode: light\n");
}
#[test]
fn prefers_uncommented_over_commented() {
let input = "# theme: default\nagent: claude\ntheme: emberforge\n";
let result = update_theme_in_config(input, ThemeScheme::Lasergrid, None);
assert_eq!(
result,
"# theme: default\nagent: claude\ntheme: lasergrid\n"
);
}
#[test]
fn falls_back_to_commented_if_no_active() {
let input = "# theme: default\nagent: claude\n";
let result = update_theme_in_config(input, ThemeScheme::NightSorbet, None);
assert_eq!(result, "theme: night-sorbet\nagent: claude\n");
}
#[test]
fn empty_file() {
let result = update_theme_in_config("", ThemeScheme::Emberforge, None);
assert_eq!(result, "theme: emberforge");
}
#[test]
fn empty_file_default() {
let result = update_theme_in_config("", ThemeScheme::Default, None);
assert_eq!(result, "");
}
#[test]
fn preserves_surrounding_content() {
let input = "# my config\nagent: claude\ntheme: mossfire\nnerdfont: true\n# end\n";
let result = update_theme_in_config(input, ThemeScheme::TealDrift, None);
assert_eq!(
result,
"# my config\nagent: claude\ntheme: teal-drift\nnerdfont: true\n# end\n"
);
}
#[test]
fn structured_to_structured_preserves_mode() {
let input = "theme:\n scheme: emberforge\n mode: light\n";
let result =
update_theme_in_config(input, ThemeScheme::FestivalCircuit, Some(ThemeMode::Light));
assert_eq!(
result,
"theme:\n scheme: festival-circuit\n mode: light\n"
);
}
#[test]
fn no_trailing_newline_preserved() {
let input = "theme: default";
let result = update_theme_in_config(input, ThemeScheme::Emberforge, None);
assert_eq!(result, "theme: emberforge");
}
#[test]
fn preserves_custom_block_when_cycling() {
let input = "theme:\n scheme: emberforge\n custom:\n accent: \"#51afef\"\n danger: \"#ff6c6b\"\n";
let result = update_theme_in_config(input, ThemeScheme::SlateGarden, None);
assert_eq!(
result,
"theme:\n scheme: slate-garden\n custom:\n accent: \"#51afef\"\n danger: \"#ff6c6b\"\n"
);
}
#[test]
fn preserves_custom_block_with_mode() {
let input =
"theme:\n scheme: default\n mode: dark\n custom:\n success: \"#00ff00\"\n";
let result = update_theme_in_config(input, ThemeScheme::Lasergrid, Some(ThemeMode::Dark));
assert_eq!(
result,
"theme:\n scheme: lasergrid\n mode: dark\n custom:\n success: \"#00ff00\"\n"
);
}
#[test]
fn preserves_custom_block_default_scheme_comments_out() {
let input = "theme:\n scheme: emberforge\n custom:\n accent: \"#51afef\"\n";
let result = update_theme_in_config(input, ThemeScheme::Default, None);
assert_eq!(
result,
"theme:\n scheme: default\n custom:\n accent: \"#51afef\"\n"
);
}
#[test]
fn structured_block_without_custom_stays_simple() {
let input = "theme:\n scheme: emberforge\n mode: dark\n";
let result = update_theme_in_config(input, ThemeScheme::Mossfire, None);
assert_eq!(result, "theme: mossfire\n");
}
}