oxicode_vtui/theme/
runtime.rs1use 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(¤t_color_config());
31 RwLock::new(ActiveTheme {
32 definition: default,
33 styles,
34 })
35});
36
37static PREVIEW: Lazy<RwLock<Option<ActiveTheme>>> = Lazy::new(|| RwLock::new(None));
41
42pub fn set_color_accessibility_config(config: ColorAccessibilityConfig) {
44 *COLOR_CONFIG.write() = config;
45}
46
47pub fn get_minimum_contrast() -> f32 {
49 COLOR_CONFIG.read().minimum_contrast
50}
51
52pub fn is_bold_bright_mode() -> bool {
54 COLOR_CONFIG.read().bold_is_bright
55}
56
57pub fn is_safe_colors_only() -> bool {
59 COLOR_CONFIG.read().safe_colors_only
60}
61
62pub 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(¤t_color_config());
71 let mut guard = ACTIVE.write();
72 guard.definition = theme;
73 guard.styles = styles;
74 Ok(())
75}
76
77pub fn active_theme_id() -> String {
79 ACTIVE.read().definition.id.to_string()
80}
81
82pub fn active_theme_label() -> String {
84 ACTIVE.read().definition.label.to_string()
85}
86
87pub 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
96pub 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(¤t_color_config());
105 *PREVIEW.write() = Some(ActiveTheme {
106 definition: theme,
107 styles,
108 });
109 Ok(())
110}
111
112pub fn has_preview_theme() -> bool {
114 PREVIEW.read().is_some()
115}
116
117pub fn clear_preview_theme() {
119 *PREVIEW.write() = None;
120}
121
122pub 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
147pub fn banner_style() -> Style {
149 let accent = banner_color();
150 Style::new().fg_color(Some(Color::Rgb(accent))).bold()
151}
152
153pub fn logo_accent_color() -> RgbColor {
155 ACTIVE.read().definition.palette.logo_accent
156}
157
158pub 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
174pub 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
181pub fn rebuild_active_styles() {
183 let mut guard = ACTIVE.write();
184 guard.styles = guard
185 .definition
186 .palette
187 .build_styles_with_accessibility(¤t_color_config());
188}
189
190pub 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}