inkhaven 1.2.4

Inkhaven — TUI literary work editor for Typst books
//! Decoded form of `ThemeConfig`. Parses the user-supplied hex strings once
//! at startup and exposes ratatui `Color`s for every place in the renderer
//! that used to hard-code colours. Missing/invalid values fall back to the
//! shipped Catppuccin Mocha defaults — the TUI never panics on a malformed
//! theme block.

use ratatui::style::Color;

use crate::config::{color_or, ThemeConfig};

#[derive(Debug, Clone)]
pub struct Theme {
    pub pane_bg: Color,
    pub pane_fg: Color,
    pub line_number_fg: Color,
    pub current_line_bg: Color,

    pub border_focused: Color,
    pub border_unfocused: Color,
    pub border_dirty: Color,
    pub border_saved: Color,
    pub border_readonly: Color,

    pub modal_bg: Color,
    pub modal_border: Color,
    pub modal_fg: Color,

    pub places_fg: Color,
    pub characters_fg: Color,
    pub artefacts_fg: Color,
    pub notes_underline_fg: Color,

    pub search_match_bg: Color,
    pub search_current_bg: Color,

    pub tree_open_marker: Color,
    pub tree_book_fg: Color,
    pub tree_chapter_fg: Color,
    pub tree_subchapter_fg: Color,
    pub tree_paragraph_fg: Color,
    pub tree_image_fg: Color,
    pub tree_script_fg: Color,
    pub editor_position_fg: Color,
    pub ai_scope_fg: Color,
    pub ai_infer_fg: Color,
    pub grammar_change_fg: Color,

    pub syntax_heading: Color,
    pub syntax_bold: Color,
    pub syntax_italic: Color,
    pub syntax_string: Color,
    pub syntax_number: Color,
    pub syntax_comment: Color,
    pub syntax_keyword: Color,
    pub syntax_function: Color,
    pub syntax_operator: Color,
    pub syntax_list_marker: Color,
    pub syntax_raw: Color,
    pub syntax_tag: Color,
    pub syntax_quote: Color,
}

impl Theme {
    pub fn from_config(cfg: &ThemeConfig) -> Self {
        // Per-field default mirrors `ThemeConfig::default()` — kept in sync
        // by hand because the round-trip would otherwise involve parsing
        // the defaults from strings (works, but adds a layer of indirection
        // for no gain).
        Self {
            pane_bg: color_or(&cfg.pane_bg, Color::Rgb(0x1e, 0x1e, 0x2e)),
            pane_fg: color_or(&cfg.pane_fg, Color::Rgb(0xcd, 0xd6, 0xf4)),
            line_number_fg: color_or(&cfg.line_number_fg, Color::Rgb(0x6c, 0x70, 0x86)),
            current_line_bg: color_or(&cfg.current_line_bg, Color::Rgb(0x31, 0x32, 0x44)),

            border_focused: color_or(&cfg.border_focused, Color::Rgb(0xcb, 0xa6, 0xf7)),
            border_unfocused: color_or(&cfg.border_unfocused, Color::Rgb(0x45, 0x47, 0x5a)),
            border_dirty: color_or(&cfg.border_dirty, Color::Rgb(0xf9, 0xe2, 0xaf)),
            border_saved: color_or(&cfg.border_saved, Color::Rgb(0xa6, 0xe3, 0xa1)),
            border_readonly: color_or(&cfg.border_readonly, Color::Rgb(0x94, 0xe2, 0xd5)),

            modal_bg: color_or(&cfg.modal_bg, Color::Rgb(0x18, 0x18, 0x25)),
            modal_border: color_or(&cfg.modal_border, Color::Rgb(0xcb, 0xa6, 0xf7)),
            modal_fg: color_or(&cfg.modal_fg, Color::Rgb(0xcd, 0xd6, 0xf4)),

            places_fg: color_or(&cfg.places_fg, Color::Rgb(0x89, 0xdc, 0xeb)),
            characters_fg: color_or(&cfg.characters_fg, Color::Rgb(0xf9, 0xe2, 0xaf)),
            // Catppuccin Mocha "peach" — a clearly distinct yellow-
            // orange so Artefacts don't clash with Characters' amber.
            artefacts_fg: color_or(&cfg.artefacts_fg, Color::Rgb(0xfa, 0xb3, 0x87)),
            // Underline uses the regular pane text colour by default;
            // a separate knob lets the user tint the underline if the
            // pane_fg is too subtle.
            notes_underline_fg: color_or(&cfg.notes_underline_fg, Color::Rgb(0xcd, 0xd6, 0xf4)),

            search_match_bg: color_or(&cfg.search_match_bg, Color::Rgb(0xf3, 0x8b, 0xa8)),
            search_current_bg: color_or(&cfg.search_current_bg, Color::Rgb(0xf5, 0xc2, 0xe7)),

            tree_open_marker: color_or(&cfg.tree_open_marker, Color::Rgb(0xa6, 0xe3, 0xa1)),
            tree_book_fg: color_or(&cfg.tree_book_fg, Color::Rgb(0xf5, 0xc2, 0xe7)),
            tree_chapter_fg: color_or(&cfg.tree_chapter_fg, Color::Rgb(0x89, 0xb4, 0xfa)),
            tree_subchapter_fg: color_or(&cfg.tree_subchapter_fg, Color::Rgb(0x94, 0xe2, 0xd5)),
            tree_paragraph_fg: color_or(&cfg.tree_paragraph_fg, Color::Rgb(0xcd, 0xd6, 0xf4)),
            // Same peach as the Artefacts editor overlay so the "this
            // is media, not text" cue is consistent.
            tree_image_fg: color_or(&cfg.tree_image_fg, Color::Rgb(0xfa, 0xb3, 0x87)),
            // Catppuccin-mocha "mauve" — distinct from prose / image
            // / data colours, signals "this is code, not prose".
            tree_script_fg: color_or(&cfg.tree_script_fg, Color::Rgb(0xcb, 0xa6, 0xf7)),
            editor_position_fg: color_or(&cfg.editor_position_fg, Color::Rgb(0x89, 0xdc, 0xeb)),
            ai_scope_fg: color_or(&cfg.ai_scope_fg, Color::Rgb(0xfa, 0xb3, 0x87)),
            ai_infer_fg: color_or(&cfg.ai_infer_fg, Color::Rgb(0x94, 0xe2, 0xd5)),
            grammar_change_fg: color_or(
                &cfg.grammar_change_fg,
                // Catppuccin Mocha red; user's spec defaults to "red" so
                // this honours that intent while keeping palette
                // consistency.
                Color::Rgb(0xf3, 0x8b, 0xa8),
            ),

            syntax_heading: color_or(&cfg.syntax_heading, Color::Rgb(0xcb, 0xa6, 0xf7)),
            syntax_bold: color_or(&cfg.syntax_bold, Color::Rgb(0xf9, 0xe2, 0xaf)),
            syntax_italic: color_or(&cfg.syntax_italic, Color::Rgb(0x94, 0xe2, 0xd5)),
            syntax_string: color_or(&cfg.syntax_string, Color::Rgb(0xa6, 0xe3, 0xa1)),
            syntax_number: color_or(&cfg.syntax_number, Color::Rgb(0xfa, 0xb3, 0x87)),
            syntax_comment: color_or(&cfg.syntax_comment, Color::Rgb(0x6c, 0x70, 0x86)),
            syntax_keyword: color_or(&cfg.syntax_keyword, Color::Rgb(0xcb, 0xa6, 0xf7)),
            syntax_function: color_or(&cfg.syntax_function, Color::Rgb(0x89, 0xdc, 0xeb)),
            syntax_operator: color_or(&cfg.syntax_operator, Color::Rgb(0x94, 0xe2, 0xd5)),
            syntax_list_marker: color_or(&cfg.syntax_list_marker, Color::Rgb(0xcb, 0xa6, 0xf7)),
            syntax_raw: color_or(&cfg.syntax_raw, Color::Rgb(0xfa, 0xb3, 0x87)),
            syntax_tag: color_or(&cfg.syntax_tag, Color::Rgb(0x89, 0xb4, 0xfa)),
            syntax_quote: color_or(&cfg.syntax_quote, Color::Rgb(0x93, 0x99, 0xb2)),
        }
    }

    /// Set a theme colour by field name at runtime. Used by the
    /// `ink.theme.set` Bund stdlib word so scripts can recolour
    /// the interface without restarting the TUI. `hex` is parsed
    /// via the same `color_or` helper as HJSON config so the
    /// accepted forms match.
    ///
    /// Returns `Err` with the offending field name when no field
    /// matches — keeps the script's error message useful.
    pub fn set_by_name(&mut self, field: &str, hex: &str) -> Result<(), String> {
        let parsed = crate::config::parse_color(hex).ok_or_else(|| {
            format!("unrecognised colour `{hex}` — use #rrggbb or a named colour")
        })?;
        match field {
            "pane_bg" => self.pane_bg = parsed,
            "pane_fg" => self.pane_fg = parsed,
            "line_number_fg" => self.line_number_fg = parsed,
            "current_line_bg" => self.current_line_bg = parsed,
            "border_focused" => self.border_focused = parsed,
            "border_unfocused" => self.border_unfocused = parsed,
            "border_dirty" => self.border_dirty = parsed,
            "border_saved" => self.border_saved = parsed,
            "border_readonly" => self.border_readonly = parsed,
            "modal_bg" => self.modal_bg = parsed,
            "modal_border" => self.modal_border = parsed,
            "modal_fg" => self.modal_fg = parsed,
            "places_fg" => self.places_fg = parsed,
            "characters_fg" => self.characters_fg = parsed,
            "artefacts_fg" => self.artefacts_fg = parsed,
            "notes_underline_fg" => self.notes_underline_fg = parsed,
            "search_match_bg" => self.search_match_bg = parsed,
            "search_current_bg" => self.search_current_bg = parsed,
            "tree_open_marker" => self.tree_open_marker = parsed,
            "tree_book_fg" => self.tree_book_fg = parsed,
            "tree_chapter_fg" => self.tree_chapter_fg = parsed,
            "tree_subchapter_fg" => self.tree_subchapter_fg = parsed,
            "tree_paragraph_fg" => self.tree_paragraph_fg = parsed,
            "tree_image_fg" => self.tree_image_fg = parsed,
            "tree_script_fg" => self.tree_script_fg = parsed,
            "editor_position_fg" => self.editor_position_fg = parsed,
            "ai_scope_fg" => self.ai_scope_fg = parsed,
            "ai_infer_fg" => self.ai_infer_fg = parsed,
            "grammar_change_fg" => self.grammar_change_fg = parsed,
            "syntax_heading" => self.syntax_heading = parsed,
            "syntax_bold" => self.syntax_bold = parsed,
            "syntax_italic" => self.syntax_italic = parsed,
            "syntax_string" => self.syntax_string = parsed,
            "syntax_number" => self.syntax_number = parsed,
            "syntax_comment" => self.syntax_comment = parsed,
            "syntax_keyword" => self.syntax_keyword = parsed,
            "syntax_function" => self.syntax_function = parsed,
            "syntax_operator" => self.syntax_operator = parsed,
            "syntax_list_marker" => self.syntax_list_marker = parsed,
            "syntax_raw" => self.syntax_raw = parsed,
            "syntax_tag" => self.syntax_tag = parsed,
            "syntax_quote" => self.syntax_quote = parsed,
            other => return Err(format!("unknown theme field `{other}`")),
        }
        Ok(())
    }
}