Rust · 10769 bytes Raw Blame History
1 //! Theme and accessibility configuration
2 //!
3 //! Supports color customization and accessibility options.
4
5 use serde::Deserialize;
6
7 /// RGBA color (0.0-1.0 range)
8 #[derive(Debug, Clone, Copy)]
9 pub struct Color {
10 pub r: f64,
11 pub g: f64,
12 pub b: f64,
13 pub a: f64,
14 }
15
16 impl Color {
17 pub fn rgb(r: f64, g: f64, b: f64) -> Self {
18 Self { r, g, b, a: 1.0 }
19 }
20
21 pub fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
22 Self { r, g, b, a }
23 }
24
25 /// Parse color from hex (#RRGGBB or #RRGGBBAA) or rgba(r, g, b, a) format
26 pub fn parse(s: &str) -> Option<Self> {
27 let s = s.trim();
28
29 // Try hex format
30 if s.starts_with('#') {
31 return Self::parse_hex(s);
32 }
33
34 // Try rgba() format
35 if s.starts_with("rgba(") && s.ends_with(')') {
36 return Self::parse_rgba(s);
37 }
38
39 // Try rgb() format
40 if s.starts_with("rgb(") && s.ends_with(')') {
41 return Self::parse_rgb(s);
42 }
43
44 None
45 }
46
47 fn parse_hex(s: &str) -> Option<Self> {
48 let s = s.trim_start_matches('#');
49
50 match s.len() {
51 6 => {
52 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
53 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
54 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
55 Some(Self::rgb(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0))
56 }
57 8 => {
58 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
59 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
60 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
61 let a = u8::from_str_radix(&s[6..8], 16).ok()?;
62 Some(Self::rgba(
63 r as f64 / 255.0,
64 g as f64 / 255.0,
65 b as f64 / 255.0,
66 a as f64 / 255.0,
67 ))
68 }
69 _ => None,
70 }
71 }
72
73 fn parse_rgba(s: &str) -> Option<Self> {
74 let inner = s.trim_start_matches("rgba(").trim_end_matches(')');
75 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
76 if parts.len() != 4 {
77 return None;
78 }
79
80 let r: f64 = parts[0].parse().ok()?;
81 let g: f64 = parts[1].parse().ok()?;
82 let b: f64 = parts[2].parse().ok()?;
83 let a: f64 = parts[3].parse().ok()?;
84
85 // Support both 0-255 and 0-1 ranges for rgb components
86 let (r, g, b) = if r > 1.0 || g > 1.0 || b > 1.0 {
87 (r / 255.0, g / 255.0, b / 255.0)
88 } else {
89 (r, g, b)
90 };
91
92 Some(Self::rgba(r, g, b, a))
93 }
94
95 fn parse_rgb(s: &str) -> Option<Self> {
96 let inner = s.trim_start_matches("rgb(").trim_end_matches(')');
97 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
98 if parts.len() != 3 {
99 return None;
100 }
101
102 let r: f64 = parts[0].parse().ok()?;
103 let g: f64 = parts[1].parse().ok()?;
104 let b: f64 = parts[2].parse().ok()?;
105
106 // Support both 0-255 and 0-1 ranges
107 let (r, g, b) = if r > 1.0 || g > 1.0 || b > 1.0 {
108 (r / 255.0, g / 255.0, b / 255.0)
109 } else {
110 (r, g, b)
111 };
112
113 Some(Self::rgb(r, g, b))
114 }
115 }
116
117 impl Default for Color {
118 fn default() -> Self {
119 Self::rgb(1.0, 1.0, 1.0)
120 }
121 }
122
123 /// Theme configuration for visual customization
124 #[derive(Debug, Clone)]
125 pub struct Theme {
126 // Panel/background colors
127 pub panel_background: Color,
128 pub background_overlay: Color,
129
130 // Text colors
131 pub text_primary: Color,
132 pub text_secondary: Color,
133 pub text_error: Color,
134 pub text_info: Color,
135
136 // Accent colors
137 pub accent: Color,
138 pub accent_hover: Color,
139
140 // Input field colors
141 pub input_background: Color,
142 pub input_background_focused: Color,
143 pub input_border: Color,
144
145 // Button colors
146 pub button_background: Color,
147 pub button_background_disabled: Color,
148
149 // Typography
150 pub font_family: String,
151 pub font_size_normal: i32,
152 pub font_size_large: i32,
153 pub font_size_title: i32,
154
155 // Layout
156 pub corner_radius: f64,
157 }
158
159 impl Default for Theme {
160 fn default() -> Self {
161 Self {
162 panel_background: Color::rgba(0.1, 0.1, 0.1, 0.85),
163 background_overlay: Color::rgba(0.0, 0.0, 0.0, 0.0),
164
165 text_primary: Color::rgb(1.0, 1.0, 1.0),
166 text_secondary: Color::rgba(0.8, 0.8, 0.8, 1.0),
167 text_error: Color::rgb(1.0, 0.3, 0.3),
168 text_info: Color::rgba(0.7, 0.7, 0.7, 1.0),
169
170 accent: Color::rgb(0.2, 0.5, 0.8),
171 accent_hover: Color::rgb(0.3, 0.6, 0.9),
172
173 input_background: Color::rgba(0.25, 0.25, 0.25, 1.0),
174 input_background_focused: Color::rgba(0.2, 0.4, 0.6, 1.0),
175 input_border: Color::rgb(0.3, 0.6, 0.9),
176
177 button_background: Color::rgba(0.2, 0.5, 0.8, 1.0),
178 button_background_disabled: Color::rgba(0.3, 0.3, 0.3, 1.0),
179
180 font_family: "Sans".to_string(),
181 font_size_normal: 14,
182 font_size_large: 18,
183 font_size_title: 24,
184
185 corner_radius: 16.0,
186 }
187 }
188 }
189
190 impl Theme {
191 /// Create a high-contrast theme variant
192 pub fn high_contrast() -> Self {
193 Self {
194 panel_background: Color::rgba(0.0, 0.0, 0.0, 0.95),
195 background_overlay: Color::rgba(0.0, 0.0, 0.0, 0.5),
196
197 text_primary: Color::rgb(1.0, 1.0, 1.0),
198 text_secondary: Color::rgb(1.0, 1.0, 0.0), // Yellow for visibility
199 text_error: Color::rgb(1.0, 0.2, 0.2),
200 text_info: Color::rgb(0.2, 1.0, 0.2),
201
202 accent: Color::rgb(0.0, 0.8, 1.0), // Bright cyan
203 accent_hover: Color::rgb(0.2, 1.0, 1.0),
204
205 input_background: Color::rgba(0.0, 0.0, 0.0, 1.0),
206 input_background_focused: Color::rgba(0.0, 0.2, 0.4, 1.0),
207 input_border: Color::rgb(1.0, 1.0, 0.0), // Yellow border
208
209 button_background: Color::rgba(0.0, 0.6, 0.8, 1.0),
210 button_background_disabled: Color::rgba(0.3, 0.3, 0.3, 1.0),
211
212 font_family: "Sans".to_string(),
213 font_size_normal: 16, // Slightly larger
214 font_size_large: 20,
215 font_size_title: 28,
216
217 corner_radius: 8.0,
218 }
219 }
220
221 /// Apply large text accessibility option
222 pub fn with_large_text(mut self) -> Self {
223 self.font_size_normal = (self.font_size_normal as f64 * 1.25) as i32;
224 self.font_size_large = (self.font_size_large as f64 * 1.25) as i32;
225 self.font_size_title = (self.font_size_title as f64 * 1.25) as i32;
226 self
227 }
228 }
229
230 /// Accessibility configuration
231 #[derive(Debug, Clone, Deserialize)]
232 pub struct AccessibilityConfig {
233 /// Enable high contrast mode
234 #[serde(default)]
235 pub high_contrast: bool,
236
237 /// Enable larger text
238 #[serde(default)]
239 pub large_text: bool,
240
241 /// Disable fade transitions
242 #[serde(default)]
243 pub reduce_motion: bool,
244 }
245
246 impl Default for AccessibilityConfig {
247 fn default() -> Self {
248 Self {
249 high_contrast: false,
250 large_text: false,
251 reduce_motion: false,
252 }
253 }
254 }
255
256 /// Raw theme config for deserialization
257 #[derive(Debug, Clone, Deserialize, Default)]
258 pub struct ThemeConfig {
259 #[serde(default)]
260 pub panel_background: Option<String>,
261 #[serde(default)]
262 pub background_overlay: Option<String>,
263 #[serde(default)]
264 pub text_primary: Option<String>,
265 #[serde(default)]
266 pub text_secondary: Option<String>,
267 #[serde(default)]
268 pub text_error: Option<String>,
269 #[serde(default)]
270 pub text_info: Option<String>,
271 #[serde(default)]
272 pub accent: Option<String>,
273 #[serde(default)]
274 pub font_family: Option<String>,
275 #[serde(default)]
276 pub font_size_normal: Option<i32>,
277 #[serde(default)]
278 pub font_size_large: Option<i32>,
279 #[serde(default)]
280 pub font_size_title: Option<i32>,
281 #[serde(default)]
282 pub corner_radius: Option<f64>,
283 }
284
285 impl ThemeConfig {
286 /// Convert to Theme, applying parsed colors over defaults
287 pub fn into_theme(self, accessibility: &AccessibilityConfig) -> Theme {
288 let mut base = if accessibility.high_contrast {
289 Theme::high_contrast()
290 } else {
291 Theme::default()
292 };
293
294 // Apply custom colors
295 if let Some(ref s) = self.panel_background {
296 if let Some(c) = Color::parse(s) {
297 base.panel_background = c;
298 }
299 }
300 if let Some(ref s) = self.background_overlay {
301 if let Some(c) = Color::parse(s) {
302 base.background_overlay = c;
303 }
304 }
305 if let Some(ref s) = self.text_primary {
306 if let Some(c) = Color::parse(s) {
307 base.text_primary = c;
308 }
309 }
310 if let Some(ref s) = self.text_secondary {
311 if let Some(c) = Color::parse(s) {
312 base.text_secondary = c;
313 }
314 }
315 if let Some(ref s) = self.text_error {
316 if let Some(c) = Color::parse(s) {
317 base.text_error = c;
318 }
319 }
320 if let Some(ref s) = self.text_info {
321 if let Some(c) = Color::parse(s) {
322 base.text_info = c;
323 }
324 }
325 if let Some(ref s) = self.accent {
326 if let Some(c) = Color::parse(s) {
327 base.accent = c;
328 // Derive hover color (slightly brighter)
329 base.accent_hover = Color::rgba(
330 (c.r + 0.1).min(1.0),
331 (c.g + 0.1).min(1.0),
332 (c.b + 0.1).min(1.0),
333 c.a,
334 );
335 base.input_background_focused = Color::rgba(c.r * 0.5, c.g * 0.5, c.b * 0.5, 1.0);
336 base.input_border = c;
337 base.button_background = c;
338 }
339 }
340
341 // Typography
342 if let Some(font) = self.font_family {
343 base.font_family = font;
344 }
345 if let Some(size) = self.font_size_normal {
346 base.font_size_normal = size;
347 }
348 if let Some(size) = self.font_size_large {
349 base.font_size_large = size;
350 }
351 if let Some(size) = self.font_size_title {
352 base.font_size_title = size;
353 }
354
355 // Layout
356 if let Some(radius) = self.corner_radius {
357 base.corner_radius = radius;
358 }
359
360 // Apply accessibility modifiers
361 if accessibility.large_text {
362 base = base.with_large_text();
363 }
364
365 base
366 }
367 }
368