1#[cfg(all(feature = "rich-ui", unix))]
7use rich_rust::prelude::{Color, Style};
8
9use crate::types::WorkerStatus;
10
11pub struct RchTheme;
28
29impl RchTheme {
30 pub const PRIMARY: &'static str = "#8B5CF6";
36
37 pub const SECONDARY: &'static str = "#06B6D4";
39
40 pub const ACCENT: &'static str = "#F59E0B";
42
43 pub const SUCCESS: &'static str = "#10B981";
49
50 pub const WARNING: &'static str = "#F59E0B";
52
53 pub const ERROR: &'static str = "#EF4444";
55
56 pub const INFO: &'static str = "#3B82F6";
58
59 pub const STATUS_HEALTHY: &'static str = "#10B981";
66
67 pub const STATUS_DEGRADED: &'static str = "#F59E0B";
69
70 pub const STATUS_UNREACHABLE: &'static str = "#EF4444";
72
73 pub const STATUS_DRAINING: &'static str = "#8B5CF6";
75
76 pub const STATUS_DRAINED: &'static str = "#6366F1";
78
79 pub const STATUS_DISABLED: &'static str = "#737B8A";
81
82 pub const MUTED: &'static str = "#9CA3AF";
88
89 pub const DIM: &'static str = "#737B8A";
91
92 pub const BRIGHT: &'static str = "#F9FAFB";
94
95 #[cfg(all(feature = "rich-ui", unix))]
101 #[must_use]
102 pub fn success() -> Style {
103 Style::new().color(Color::parse(Self::SUCCESS).unwrap_or_default())
104 }
105
106 #[cfg(all(feature = "rich-ui", unix))]
108 #[must_use]
109 pub fn error() -> Style {
110 Style::new()
111 .bold()
112 .color(Color::parse(Self::ERROR).unwrap_or_default())
113 }
114
115 #[cfg(all(feature = "rich-ui", unix))]
117 #[must_use]
118 pub fn warning() -> Style {
119 Style::new().color(Color::parse(Self::WARNING).unwrap_or_default())
120 }
121
122 #[cfg(all(feature = "rich-ui", unix))]
124 #[must_use]
125 pub fn info() -> Style {
126 Style::new().color(Color::parse(Self::INFO).unwrap_or_default())
127 }
128
129 #[cfg(all(feature = "rich-ui", unix))]
131 #[must_use]
132 pub fn muted() -> Style {
133 Style::new().color(Color::parse(Self::MUTED).unwrap_or_default())
134 }
135
136 #[cfg(all(feature = "rich-ui", unix))]
138 #[must_use]
139 pub fn dim() -> Style {
140 Style::new()
141 .dim()
142 .color(Color::parse(Self::DIM).unwrap_or_default())
143 }
144
145 #[cfg(all(feature = "rich-ui", unix))]
147 #[must_use]
148 pub fn primary() -> Style {
149 Style::new().color(Color::parse(Self::PRIMARY).unwrap_or_default())
150 }
151
152 #[cfg(all(feature = "rich-ui", unix))]
154 #[must_use]
155 pub fn secondary() -> Style {
156 Style::new().color(Color::parse(Self::SECONDARY).unwrap_or_default())
157 }
158
159 #[cfg(all(feature = "rich-ui", unix))]
161 #[must_use]
162 pub fn accent() -> Style {
163 Style::new().color(Color::parse(Self::ACCENT).unwrap_or_default())
164 }
165
166 #[cfg(all(feature = "rich-ui", unix))]
168 #[must_use]
169 pub fn worker_status_str(status: &str) -> Style {
170 let color = match status.to_lowercase().as_str() {
171 "healthy" => Self::STATUS_HEALTHY,
172 "degraded" => Self::STATUS_DEGRADED,
173 "unreachable" => Self::STATUS_UNREACHABLE,
174 "draining" => Self::STATUS_DRAINING,
175 "disabled" => Self::STATUS_DISABLED,
176 _ => Self::MUTED,
177 };
178 Style::new().color(Color::parse(color).unwrap_or_default())
179 }
180
181 #[cfg(all(feature = "rich-ui", unix))]
183 #[must_use]
184 pub fn for_worker_status(status: WorkerStatus) -> Style {
185 let color = match status {
186 WorkerStatus::Healthy => Self::STATUS_HEALTHY,
187 WorkerStatus::Degraded => Self::STATUS_DEGRADED,
188 WorkerStatus::Unreachable => Self::STATUS_UNREACHABLE,
189 WorkerStatus::Draining => Self::STATUS_DRAINING,
190 WorkerStatus::Drained => Self::STATUS_DRAINED,
191 WorkerStatus::Disabled => Self::STATUS_DISABLED,
192 };
193 Style::new().color(Color::parse(color).unwrap_or_default())
194 }
195
196 #[cfg(all(feature = "rich-ui", unix))]
202 #[must_use]
203 pub fn table_header() -> Style {
204 Style::new()
205 .bold()
206 .color(Color::parse(Self::BRIGHT).unwrap_or_default())
207 }
208
209 #[cfg(all(feature = "rich-ui", unix))]
211 #[must_use]
212 pub fn table_border() -> Style {
213 Style::new().color(Color::parse(Self::PRIMARY).unwrap_or_default())
214 }
215
216 #[cfg(all(feature = "rich-ui", unix))]
218 #[must_use]
219 pub fn panel_title() -> Style {
220 Style::new()
221 .bold()
222 .color(Color::parse(Self::SECONDARY).unwrap_or_default())
223 }
224
225 #[cfg(all(feature = "rich-ui", unix))]
227 #[must_use]
228 pub fn code() -> Style {
229 Style::new().color(Color::parse(Self::SECONDARY).unwrap_or_default())
230 }
231
232 #[cfg(all(feature = "rich-ui", unix))]
234 #[must_use]
235 pub fn path() -> Style {
236 Style::new()
237 .italic()
238 .color(Color::parse(Self::INFO).unwrap_or_default())
239 }
240
241 #[cfg(all(feature = "rich-ui", unix))]
243 #[must_use]
244 pub fn number() -> Style {
245 Style::new().color(Color::parse(Self::ACCENT).unwrap_or_default())
246 }
247
248 #[must_use]
254 pub const fn color_for_worker_status(status: WorkerStatus) -> &'static str {
255 match status {
256 WorkerStatus::Healthy => Self::STATUS_HEALTHY,
257 WorkerStatus::Degraded => Self::STATUS_DEGRADED,
258 WorkerStatus::Unreachable => Self::STATUS_UNREACHABLE,
259 WorkerStatus::Draining => Self::STATUS_DRAINING,
260 WorkerStatus::Drained => Self::STATUS_DRAINED,
261 WorkerStatus::Disabled => Self::STATUS_DISABLED,
262 }
263 }
264
265 #[must_use]
267 pub fn color_for_status_str(status: &str) -> &'static str {
268 match status.to_lowercase().as_str() {
269 "healthy" => Self::STATUS_HEALTHY,
270 "degraded" => Self::STATUS_DEGRADED,
271 "unreachable" => Self::STATUS_UNREACHABLE,
272 "draining" => Self::STATUS_DRAINING,
273 "drained" => Self::STATUS_DRAINED,
274 "disabled" => Self::STATUS_DISABLED,
275 "success" | "ok" => Self::SUCCESS,
276 "warning" | "warn" => Self::WARNING,
277 "error" | "fail" | "failed" => Self::ERROR,
278 "info" => Self::INFO,
279 _ => Self::MUTED,
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn parse_hex_rgb(color: &str) -> (u8, u8, u8) {
289 let color = color.strip_prefix('#').unwrap_or(color);
290 assert_eq!(color.len(), 6, "Expected RRGGBB hex string, got: {color}");
291
292 let r = u8::from_str_radix(&color[0..2], 16).expect("Invalid hex for R");
293 let g = u8::from_str_radix(&color[2..4], 16).expect("Invalid hex for G");
294 let b = u8::from_str_radix(&color[4..6], 16).expect("Invalid hex for B");
295
296 (r, g, b)
297 }
298
299 fn srgb_channel_to_linear(channel: u8) -> f64 {
300 let c = f64::from(channel) / 255.0;
301 if c <= 0.04045 {
302 c / 12.92
303 } else {
304 ((c + 0.055) / 1.055).powf(2.4)
305 }
306 }
307
308 fn relative_luminance(color: &str) -> f64 {
309 let (r, g, b) = parse_hex_rgb(color);
310 let r = srgb_channel_to_linear(r);
311 let g = srgb_channel_to_linear(g);
312 let b = srgb_channel_to_linear(b);
313
314 0.2126 * r + 0.7152 * g + 0.0722 * b
315 }
316
317 fn contrast_ratio(foreground: &str, background: &str) -> f64 {
318 let l1 = relative_luminance(foreground);
319 let l2 = relative_luminance(background);
320 let (lighter, darker) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
321 (lighter + 0.05) / (darker + 0.05)
322 }
323
324 #[test]
325 fn test_all_color_constants_are_valid_hex() {
326 let colors = [
328 RchTheme::PRIMARY,
329 RchTheme::SECONDARY,
330 RchTheme::ACCENT,
331 RchTheme::SUCCESS,
332 RchTheme::WARNING,
333 RchTheme::ERROR,
334 RchTheme::INFO,
335 RchTheme::STATUS_HEALTHY,
336 RchTheme::STATUS_DEGRADED,
337 RchTheme::STATUS_UNREACHABLE,
338 RchTheme::STATUS_DRAINING,
339 RchTheme::STATUS_DISABLED,
340 RchTheme::MUTED,
341 RchTheme::DIM,
342 RchTheme::BRIGHT,
343 ];
344
345 for color in colors {
346 assert!(color.starts_with('#'), "Color should start with #: {color}");
347 assert_eq!(color.len(), 7, "Color should be 7 chars: {color}");
348 assert!(
349 color[1..].chars().all(|c| c.is_ascii_hexdigit()),
350 "Color should be valid hex: {color}"
351 );
352 }
353 }
354
355 #[test]
356 fn test_semantic_colors_meet_contrast_on_dark_background() {
357 const BACKGROUND: &str = "#000000";
363 const MIN_RATIO: f64 = 4.5;
364
365 let colors = [
366 ("PRIMARY", RchTheme::PRIMARY),
367 ("SECONDARY", RchTheme::SECONDARY),
368 ("ACCENT", RchTheme::ACCENT),
369 ("SUCCESS", RchTheme::SUCCESS),
370 ("WARNING", RchTheme::WARNING),
371 ("ERROR", RchTheme::ERROR),
372 ("INFO", RchTheme::INFO),
373 ("STATUS_HEALTHY", RchTheme::STATUS_HEALTHY),
374 ("STATUS_DEGRADED", RchTheme::STATUS_DEGRADED),
375 ("STATUS_UNREACHABLE", RchTheme::STATUS_UNREACHABLE),
376 ("STATUS_DRAINING", RchTheme::STATUS_DRAINING),
377 ("STATUS_DISABLED", RchTheme::STATUS_DISABLED),
378 ("MUTED", RchTheme::MUTED),
379 ("DIM", RchTheme::DIM),
380 ("BRIGHT", RchTheme::BRIGHT),
381 ];
382
383 for (name, color) in colors {
384 let ratio = contrast_ratio(color, BACKGROUND);
385 assert!(
386 ratio >= MIN_RATIO,
387 "{name} ({color}) contrast vs {BACKGROUND} too low: {ratio:.2}"
388 );
389 }
390 }
391
392 #[test]
393 fn test_color_for_worker_status() {
394 assert_eq!(
395 RchTheme::color_for_worker_status(WorkerStatus::Healthy),
396 RchTheme::STATUS_HEALTHY
397 );
398 assert_eq!(
399 RchTheme::color_for_worker_status(WorkerStatus::Degraded),
400 RchTheme::STATUS_DEGRADED
401 );
402 assert_eq!(
403 RchTheme::color_for_worker_status(WorkerStatus::Unreachable),
404 RchTheme::STATUS_UNREACHABLE
405 );
406 assert_eq!(
407 RchTheme::color_for_worker_status(WorkerStatus::Draining),
408 RchTheme::STATUS_DRAINING
409 );
410 assert_eq!(
411 RchTheme::color_for_worker_status(WorkerStatus::Drained),
412 RchTheme::STATUS_DRAINED
413 );
414 assert_eq!(
415 RchTheme::color_for_worker_status(WorkerStatus::Disabled),
416 RchTheme::STATUS_DISABLED
417 );
418 }
419
420 #[test]
421 fn test_color_for_status_str() {
422 assert_eq!(
424 RchTheme::color_for_status_str("HEALTHY"),
425 RchTheme::STATUS_HEALTHY
426 );
427 assert_eq!(
428 RchTheme::color_for_status_str("healthy"),
429 RchTheme::STATUS_HEALTHY
430 );
431 assert_eq!(
432 RchTheme::color_for_status_str("Healthy"),
433 RchTheme::STATUS_HEALTHY
434 );
435
436 assert_eq!(RchTheme::color_for_status_str("success"), RchTheme::SUCCESS);
438 assert_eq!(RchTheme::color_for_status_str("ok"), RchTheme::SUCCESS);
439 assert_eq!(RchTheme::color_for_status_str("error"), RchTheme::ERROR);
440 assert_eq!(RchTheme::color_for_status_str("fail"), RchTheme::ERROR);
441 assert_eq!(RchTheme::color_for_status_str("failed"), RchTheme::ERROR);
442
443 assert_eq!(RchTheme::color_for_status_str("unknown"), RchTheme::MUTED);
445 assert_eq!(RchTheme::color_for_status_str(""), RchTheme::MUTED);
446 }
447
448 #[test]
449 fn test_all_status_colors_are_distinct() {
450 let colors = [
451 RchTheme::STATUS_HEALTHY,
452 RchTheme::STATUS_DEGRADED,
453 RchTheme::STATUS_UNREACHABLE,
454 RchTheme::STATUS_DRAINING,
455 RchTheme::STATUS_DRAINED,
456 RchTheme::STATUS_DISABLED,
457 ];
458
459 for (i, &c1) in colors.iter().enumerate() {
461 for &c2 in &colors[i + 1..] {
462 assert_ne!(c1, c2, "Status colors should be distinct");
463 }
464 }
465 }
466
467 #[test]
473 fn test_contrast_ratio_calculation_accuracy() {
474 let ratio = contrast_ratio("#FFFFFF", "#000000");
476 assert!(
477 (ratio - 21.0).abs() < 0.1,
478 "White/black contrast should be ~21:1, got {ratio:.2}:1"
479 );
480
481 let same = contrast_ratio("#FF0000", "#FF0000");
483 assert!(
484 (same - 1.0).abs() < 0.01,
485 "Same color contrast should be 1:1"
486 );
487 }
488
489 #[test]
491 fn test_colors_on_dark_gray_background() {
492 const DARK_GRAY: &str = "#1a1a1a";
493 const MIN_RATIO: f64 = 4.5;
494
495 let critical_colors = [
496 ("ERROR", RchTheme::ERROR),
497 ("SUCCESS", RchTheme::SUCCESS),
498 ("WARNING", RchTheme::WARNING),
499 ];
500
501 for (name, color) in critical_colors {
502 let ratio = contrast_ratio(color, DARK_GRAY);
503 assert!(
504 ratio >= MIN_RATIO,
505 "{name} ({color}) must be readable on dark gray: {ratio:.2}:1 < {MIN_RATIO}:1"
506 );
507 }
508 }
509
510 #[cfg(all(feature = "rich-ui", unix))]
511 mod rich_ui_tests {
512 use super::*;
513 use rich_rust::prelude::Color;
514
515 #[test]
516 fn test_all_colors_parse_with_rich_rust() {
517 assert!(Color::parse(RchTheme::PRIMARY).is_ok());
519 assert!(Color::parse(RchTheme::SECONDARY).is_ok());
520 assert!(Color::parse(RchTheme::ACCENT).is_ok());
521 assert!(Color::parse(RchTheme::SUCCESS).is_ok());
522 assert!(Color::parse(RchTheme::WARNING).is_ok());
523 assert!(Color::parse(RchTheme::ERROR).is_ok());
524 assert!(Color::parse(RchTheme::INFO).is_ok());
525 assert!(Color::parse(RchTheme::STATUS_HEALTHY).is_ok());
526 assert!(Color::parse(RchTheme::STATUS_DEGRADED).is_ok());
527 assert!(Color::parse(RchTheme::STATUS_UNREACHABLE).is_ok());
528 assert!(Color::parse(RchTheme::STATUS_DRAINING).is_ok());
529 assert!(Color::parse(RchTheme::STATUS_DRAINED).is_ok());
530 assert!(Color::parse(RchTheme::STATUS_DISABLED).is_ok());
531 assert!(Color::parse(RchTheme::MUTED).is_ok());
532 assert!(Color::parse(RchTheme::DIM).is_ok());
533 assert!(Color::parse(RchTheme::BRIGHT).is_ok());
534 }
535
536 #[test]
537 fn test_styles_dont_panic() {
538 let _ = RchTheme::success();
540 let _ = RchTheme::error();
541 let _ = RchTheme::warning();
542 let _ = RchTheme::info();
543 let _ = RchTheme::muted();
544 let _ = RchTheme::dim();
545 let _ = RchTheme::primary();
546 let _ = RchTheme::secondary();
547 let _ = RchTheme::accent();
548 let _ = RchTheme::table_header();
549 let _ = RchTheme::table_border();
550 let _ = RchTheme::panel_title();
551 let _ = RchTheme::code();
552 let _ = RchTheme::path();
553 let _ = RchTheme::number();
554 }
555
556 #[test]
557 fn test_worker_status_styles() {
558 let _ = RchTheme::worker_status_str("healthy");
560 let _ = RchTheme::worker_status_str("unknown"); let _ = RchTheme::for_worker_status(WorkerStatus::Healthy);
564 let _ = RchTheme::for_worker_status(WorkerStatus::Degraded);
565 let _ = RchTheme::for_worker_status(WorkerStatus::Unreachable);
566 let _ = RchTheme::for_worker_status(WorkerStatus::Draining);
567 let _ = RchTheme::for_worker_status(WorkerStatus::Drained);
568 let _ = RchTheme::for_worker_status(WorkerStatus::Disabled);
569 }
570 }
571}