workmux 0.1.181

An opinionated workflow tool that orchestrates git worktrees and tmux
//! Theme and appearance logic for the dashboard.

use super::super::ui::theme::ThemePalette;
use super::App;

impl App {
    /// Cycle to the next color scheme, re-render palette, and persist to config
    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);
    }
}

/// Update the `theme:` entry in a YAML config string.
/// Preserves explicit mode override when present.
/// Preserves `custom:` block when present in existing config.
/// Prefers uncommented `theme:` over `# theme:`.
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();

    // First pass: find the best target line (prefer uncommented over commented)
    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);

    // Extract existing custom block lines if the theme is a structured block
    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;
                }
                // Check if this is the custom: sub-key
                if next_trimmed.starts_with("custom:") {
                    in_custom = true;
                    custom_lines.push(lines[i].to_string());
                } else if in_custom {
                    // Collect lines that are deeper than the custom: key
                    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;
            }
        }
    }

    // Build replacement lines
    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)]
        };

    // Second pass: build output
    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());

            // Skip structured block sub-keys (including blank lines within)
            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");
    }
}