Skip to main content

oxicode_vtui/theme/
runtime.rs

1use anstyle::{Color, RgbColor, Style};
2use anyhow::{Context, Result, anyhow};
3use once_cell::sync::Lazy;
4use oxicode_vtui_compat::constants::ui;
5use parking_lot::RwLock;
6
7use crate::theme::color_math::{contrast_ratio, ensure_contrast, lighten};
8use crate::theme::registry::theme_definition;
9use crate::theme::types::{
10    ColorAccessibilityConfig, DEFAULT_THEME_ID, ThemeDefinition, ThemeStyles, ThemeValidationResult,
11};
12
13#[derive(Clone, Debug)]
14struct ActiveTheme {
15    definition: &'static ThemeDefinition,
16    styles: ThemeStyles,
17}
18
19static COLOR_CONFIG: Lazy<RwLock<ColorAccessibilityConfig>> =
20    Lazy::new(|| RwLock::new(ColorAccessibilityConfig::default()));
21
22fn current_color_config() -> impl std::ops::Deref<Target = ColorAccessibilityConfig> {
23    COLOR_CONFIG.read()
24}
25
26static ACTIVE: Lazy<RwLock<ActiveTheme>> = Lazy::new(|| {
27    let default = theme_definition(DEFAULT_THEME_ID).expect("default theme must exist");
28    let styles = default
29        .palette
30        .build_styles_with_accessibility(&current_color_config());
31    RwLock::new(ActiveTheme {
32        definition: default,
33        styles,
34    })
35});
36
37/// Preview state: when set, `active_styles()` returns the preview styles
38/// instead of the committed theme styles. This allows theme palette
39/// navigation to show a live preview without committing the selection.
40static PREVIEW: Lazy<RwLock<Option<ActiveTheme>>> = Lazy::new(|| RwLock::new(None));
41
42/// Update the runtime color accessibility configuration.
43pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
44    *COLOR_CONFIG.write() = config;
45}
46
47/// Return the currently configured minimum contrast ratio.
48pub fn get_minimum_contrast() -> f32 {
49    COLOR_CONFIG.read().minimum_contrast
50}
51
52/// Report whether bold text should avoid terminal bright-color behavior.
53pub fn is_bold_bright_mode() -> bool {
54    COLOR_CONFIG.read().bold_is_bright
55}
56
57/// Report whether the UI should restrict itself to safe ANSI colors.
58pub fn is_safe_colors_only() -> bool {
59    COLOR_CONFIG.read().safe_colors_only
60}
61
62/// Activate a built-in theme by identifier.
63pub fn set_active_theme(theme_id: &str) -> Result<()> {
64    let id_lc = theme_id.trim().to_lowercase();
65    let theme =
66        theme_definition(id_lc.as_str()).ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
67
68    let styles = theme
69        .palette
70        .build_styles_with_accessibility(&current_color_config());
71    let mut guard = ACTIVE.write();
72    guard.definition = theme;
73    guard.styles = styles;
74    Ok(())
75}
76
77/// Return the active theme identifier.
78pub fn active_theme_id() -> String {
79    ACTIVE.read().definition.id.to_string()
80}
81
82/// Return the active theme label.
83pub fn active_theme_label() -> String {
84    ACTIVE.read().definition.label.to_string()
85}
86
87/// Return a clone of the active style set.
88/// When a preview theme is active, returns the preview styles instead.
89pub fn active_styles() -> ThemeStyles {
90    if let Some(preview) = PREVIEW.read().as_ref() {
91        return preview.styles.clone();
92    }
93    ACTIVE.read().styles.clone()
94}
95
96/// Set a preview theme by identifier. The preview is returned by
97/// `active_styles()` until `clear_preview_theme()` is called.
98pub fn set_preview_theme(theme_id: &str) -> Result<()> {
99    let id_lc = theme_id.trim().to_lowercase();
100    let theme =
101        theme_definition(id_lc.as_str()).ok_or_else(|| anyhow!("Unknown theme '{theme_id}'"))?;
102    let styles = theme
103        .palette
104        .build_styles_with_accessibility(&current_color_config());
105    *PREVIEW.write() = Some(ActiveTheme {
106        definition: theme,
107        styles,
108    });
109    Ok(())
110}
111
112/// Return true when a preview theme is active.
113pub fn has_preview_theme() -> bool {
114    PREVIEW.read().is_some()
115}
116
117/// Clear the preview theme, reverting `active_styles()` to the committed theme.
118pub fn clear_preview_theme() {
119    *PREVIEW.write() = None;
120}
121
122/// Return a readable accent color for banner-like copy.
123pub fn banner_color() -> RgbColor {
124    let guard = ACTIVE.read();
125    let accent = guard.definition.palette.logo_accent;
126    let secondary = guard.definition.palette.secondary_accent;
127    let background = guard.definition.palette.background;
128    drop(guard);
129
130    let min_contrast = get_minimum_contrast();
131    let candidate = lighten(accent, ui::THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO);
132    ensure_contrast(
133        candidate,
134        background,
135        min_contrast,
136        &[
137            lighten(accent, ui::THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO),
138            lighten(
139                secondary,
140                ui::THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO,
141            ),
142            accent,
143        ],
144    )
145}
146
147/// Return a bold banner style derived from the active theme.
148pub fn banner_style() -> Style {
149    let accent = banner_color();
150    Style::new().fg_color(Some(Color::Rgb(accent))).bold()
151}
152
153/// Return the raw logo accent color from the active theme.
154pub fn logo_accent_color() -> RgbColor {
155    ACTIVE.read().definition.palette.logo_accent
156}
157
158/// Resolve a requested theme to a valid built-in identifier or the default.
159pub fn resolve_theme(preferred: Option<String>) -> String {
160    preferred
161        .and_then(|candidate| {
162            let trimmed = candidate.trim().to_lowercase();
163            if trimmed.is_empty() {
164                None
165            } else if theme_definition(trimmed.as_str()).is_some() {
166                Some(trimmed)
167            } else {
168                None
169            }
170        })
171        .unwrap_or_else(|| DEFAULT_THEME_ID.to_string())
172}
173
174/// Validate that a theme exists and return its label.
175pub fn ensure_theme(theme_id: &str) -> Result<&'static str> {
176    theme_definition(theme_id)
177        .map(|definition| definition.label)
178        .context("Theme not found")
179}
180
181/// Rebuild the active styles after accessibility settings change.
182pub fn rebuild_active_styles() {
183    let mut guard = ACTIVE.write();
184    guard.styles = guard
185        .definition
186        .palette
187        .build_styles_with_accessibility(&current_color_config());
188}
189
190/// Validate a theme's base palette contrast ratios.
191pub fn validate_theme_contrast(theme_id: &str) -> ThemeValidationResult {
192    let mut result = ThemeValidationResult {
193        is_valid: true,
194        warnings: Vec::new(),
195        errors: Vec::new(),
196    };
197
198    let theme = match theme_definition(theme_id) {
199        Some(theme) => theme,
200        None => {
201            result.is_valid = false;
202            result.errors.push(format!("Unknown theme: {theme_id}"));
203            return result;
204        }
205    };
206
207    let palette = &theme.palette;
208    let bg = palette.background;
209    let min_contrast = get_minimum_contrast();
210
211    for (name, color) in [
212        ("foreground", palette.foreground),
213        ("primary_accent", palette.primary_accent),
214        ("secondary_accent", palette.secondary_accent),
215        ("alert", palette.alert),
216        ("logo_accent", palette.logo_accent),
217    ] {
218        let ratio = contrast_ratio(color, bg);
219        if ratio < min_contrast {
220            result.warnings.push(format!(
221                "{} ({:02X}{:02X}{:02X}) has contrast ratio {:.2} < {:.1} against background",
222                name, color.0, color.1, color.2, ratio, min_contrast
223            ));
224        }
225    }
226
227    result
228}