1use presentar_core::{
12 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
13 LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
14};
15use std::any::Any;
16use std::time::Duration;
17
18use super::ux::HealthStatus;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum SemanticStatus {
24 #[default]
26 Normal,
27 Good,
29 Warning,
31 High,
33 Critical,
35 Unknown,
37 Custom(u8, u8, u8),
39}
40
41impl SemanticStatus {
42 #[must_use]
44 pub fn color(&self) -> Color {
45 match self {
46 Self::Normal => Color::new(0.3, 0.9, 0.4, 1.0), Self::Good => Color::new(0.3, 0.8, 1.0, 1.0), Self::Warning => Color::new(1.0, 0.85, 0.2, 1.0), Self::High => Color::new(1.0, 0.6, 0.2, 1.0), Self::Critical => Color::new(1.0, 0.3, 0.3, 1.0), Self::Unknown => Color::new(0.5, 0.5, 0.5, 1.0), Self::Custom(r, g, b) => {
53 Color::new(*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0)
54 }
55 }
56 }
57
58 #[must_use]
65 pub fn from_percentage(pct: f64) -> Self {
66 if pct.is_nan() {
67 Self::Unknown
68 } else if pct >= 80.0 {
69 Self::Normal
70 } else if pct >= 60.0 {
71 Self::Good
72 } else if pct >= 40.0 {
73 Self::Warning
74 } else if pct >= 20.0 {
75 Self::High
76 } else {
77 Self::Critical
78 }
79 }
80
81 #[must_use]
88 pub fn from_usage(pct: f64) -> Self {
89 if pct.is_nan() {
90 Self::Unknown
91 } else if pct <= 20.0 {
92 Self::Normal
93 } else if pct <= 40.0 {
94 Self::Good
95 } else if pct <= 60.0 {
96 Self::Warning
97 } else if pct <= 80.0 {
98 Self::High
99 } else {
100 Self::Critical
101 }
102 }
103
104 #[must_use]
106 pub fn from_temperature(temp_c: f64) -> Self {
107 if temp_c.is_nan() {
108 Self::Unknown
109 } else if temp_c <= 50.0 {
110 Self::Normal
111 } else if temp_c <= 65.0 {
112 Self::Good
113 } else if temp_c <= 80.0 {
114 Self::Warning
115 } else if temp_c <= 90.0 {
116 Self::High
117 } else {
118 Self::Critical
119 }
120 }
121
122 #[must_use]
124 pub fn from_health_status(status: HealthStatus) -> Self {
125 match status {
126 HealthStatus::Healthy => Self::Normal,
127 HealthStatus::Warning => Self::Warning,
128 HealthStatus::Critical => Self::Critical,
129 HealthStatus::Unknown => Self::Unknown,
130 }
131 }
132}
133
134#[derive(Debug, Clone)]
139pub struct SemanticLabel {
140 text: String,
142 status: SemanticStatus,
144 prefix: Option<String>,
146 suffix: Option<String>,
148 show_symbol: bool,
150 max_width: Option<usize>,
152 bounds: Rect,
154}
155
156impl Default for SemanticLabel {
157 fn default() -> Self {
158 Self::new("")
159 }
160}
161
162impl SemanticLabel {
163 #[must_use]
165 pub fn new(text: impl Into<String>) -> Self {
166 Self {
167 text: text.into(),
168 status: SemanticStatus::Normal,
169 prefix: None,
170 suffix: None,
171 show_symbol: false,
172 max_width: None,
173 bounds: Rect::default(),
174 }
175 }
176
177 #[must_use]
179 pub fn percentage(value: f64) -> Self {
180 Self::new(format!("{value:.1}%")).with_status(SemanticStatus::from_usage(value))
181 }
182
183 #[must_use]
185 pub fn temperature(temp_c: f64) -> Self {
186 Self::new(format!("{temp_c:.0}°C")).with_status(SemanticStatus::from_temperature(temp_c))
187 }
188
189 #[must_use]
191 pub fn with_status(mut self, status: SemanticStatus) -> Self {
192 self.status = status;
193 self
194 }
195
196 #[must_use]
198 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
199 self.prefix = Some(prefix.into());
200 self
201 }
202
203 #[must_use]
205 pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
206 self.suffix = Some(suffix.into());
207 self
208 }
209
210 #[must_use]
212 pub fn with_symbol(mut self) -> Self {
213 self.show_symbol = true;
214 self
215 }
216
217 #[must_use]
219 pub fn with_max_width(mut self, width: usize) -> Self {
220 self.max_width = Some(width);
221 self
222 }
223
224 fn display_text(&self) -> String {
226 let mut result = String::new();
227
228 if self.show_symbol {
229 let symbol = match self.status {
230 SemanticStatus::Normal | SemanticStatus::Good => "✓",
231 SemanticStatus::Warning => "⚠",
232 SemanticStatus::High | SemanticStatus::Critical => "✗",
233 SemanticStatus::Unknown | SemanticStatus::Custom(_, _, _) => "?",
234 };
235 result.push_str(symbol);
236 result.push(' ');
237 }
238
239 if let Some(ref prefix) = self.prefix {
240 result.push_str(prefix);
241 }
242
243 result.push_str(&self.text);
244
245 if let Some(ref suffix) = self.suffix {
246 result.push_str(suffix);
247 }
248
249 result
250 }
251
252 fn truncated_text(&self) -> String {
254 let full = self.display_text();
255 if let Some(max) = self.max_width {
256 let char_count = full.chars().count();
257 if char_count > max {
258 if max <= 1 {
259 return "…".to_string();
260 }
261 let truncated: String = full.chars().take(max - 1).collect();
262 return format!("{truncated}…");
263 }
264 }
265 full
266 }
267}
268
269impl Widget for SemanticLabel {
270 fn type_id(&self) -> TypeId {
271 TypeId::of::<Self>()
272 }
273
274 fn measure(&self, constraints: Constraints) -> Size {
275 let text = self.truncated_text();
276 let width = text.chars().count() as f32;
277 constraints.constrain(Size::new(width.min(constraints.max_width), 1.0))
278 }
279
280 fn layout(&mut self, bounds: Rect) -> LayoutResult {
281 self.bounds = bounds;
282 LayoutResult {
283 size: Size::new(bounds.width, bounds.height),
284 }
285 }
286
287 fn paint(&self, canvas: &mut dyn Canvas) {
288 if self.bounds.width < 1.0 || self.bounds.height < 1.0 {
289 return;
290 }
291
292 let text = self.truncated_text();
293 let style = TextStyle {
294 color: self.status.color(),
295 ..Default::default()
296 };
297
298 canvas.draw_text(&text, Point::new(self.bounds.x, self.bounds.y), &style);
299 }
300
301 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
302 None
303 }
304
305 fn children(&self) -> &[Box<dyn Widget>] {
306 &[]
307 }
308
309 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
310 &mut []
311 }
312}
313
314impl Brick for SemanticLabel {
315 fn brick_name(&self) -> &'static str {
316 "semantic_label"
317 }
318
319 fn assertions(&self) -> &[BrickAssertion] {
320 static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(1)];
321 ASSERTIONS
322 }
323
324 fn budget(&self) -> BrickBudget {
325 BrickBudget::uniform(1)
326 }
327
328 fn verify(&self) -> BrickVerification {
329 let color = self.status.color();
331 let color_valid = match self.status {
332 SemanticStatus::Critical => color.r > 0.8 && color.g < 0.5,
334 SemanticStatus::Warning => color.r > 0.8 && color.g > 0.7,
336 SemanticStatus::Normal => color.g > 0.7 && color.r < 0.5,
338 _ => true, };
340
341 if color_valid {
342 BrickVerification {
343 passed: self.assertions().to_vec(),
344 failed: vec![],
345 verification_time: Duration::from_micros(1),
346 }
347 } else {
348 BrickVerification {
349 passed: vec![],
350 failed: self
351 .assertions()
352 .iter()
353 .map(|a| (a.clone(), "Color does not match status".to_string()))
354 .collect(),
355 verification_time: Duration::from_micros(1),
356 }
357 }
358 }
359
360 fn to_html(&self) -> String {
361 let class = match self.status {
362 SemanticStatus::Normal => "status-normal",
363 SemanticStatus::Good => "status-good",
364 SemanticStatus::Warning => "status-warning",
365 SemanticStatus::High => "status-high",
366 SemanticStatus::Critical => "status-critical",
367 SemanticStatus::Unknown => "status-unknown",
368 SemanticStatus::Custom(_, _, _) => "status-custom",
369 };
370 format!(
371 "<span class=\"semantic-label {}\">{}</span>",
372 class,
373 self.display_text()
374 )
375 }
376
377 fn to_css(&self) -> String {
378 String::new()
379 }
380}
381
382#[cfg(test)]
383#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
384mod tests {
385 use super::*;
386 use crate::direct::{CellBuffer, DirectTerminalCanvas};
387
388 #[test]
390 fn test_critical_is_red() {
391 let label = SemanticLabel::new("Error").with_status(SemanticStatus::Critical);
392 let color = label.status.color();
393 assert!(color.r > 0.8, "Critical should be red");
394 assert!(color.g < 0.5, "Critical should not be green");
395 }
396
397 #[test]
399 fn test_warning_is_yellow() {
400 let label = SemanticLabel::new("Warn").with_status(SemanticStatus::Warning);
401 let color = label.status.color();
402 assert!(color.r > 0.8, "Warning should have high red");
403 assert!(color.g > 0.7, "Warning should have high green (yellow)");
404 }
405
406 #[test]
408 fn test_normal_is_green() {
409 let label = SemanticLabel::new("OK").with_status(SemanticStatus::Normal);
410 let color = label.status.color();
411 assert!(color.g > 0.7, "Normal should be green");
412 assert!(color.r < 0.5, "Normal should not be red");
413 }
414
415 #[test]
416 fn test_good_is_cyan() {
417 let color = SemanticStatus::Good.color();
418 assert!(color.b > 0.8, "Good should have high blue");
419 assert!(color.g > 0.7, "Good should have high green");
420 }
421
422 #[test]
423 fn test_high_is_orange() {
424 let color = SemanticStatus::High.color();
425 assert!(color.r > 0.8, "High should have high red");
426 assert!(color.g > 0.5, "High should have medium green (orange)");
427 }
428
429 #[test]
430 fn test_unknown_is_gray() {
431 let color = SemanticStatus::Unknown.color();
432 assert!((color.r - 0.5).abs() < 0.1, "Unknown should be gray");
433 assert!((color.g - 0.5).abs() < 0.1, "Unknown should be gray");
434 }
435
436 #[test]
437 fn test_custom_color() {
438 let color = SemanticStatus::Custom(255, 128, 64).color();
439 assert!((color.r - 1.0).abs() < 0.01);
440 assert!((color.g - 0.502).abs() < 0.01);
441 assert!((color.b - 0.251).abs() < 0.01);
442 }
443
444 #[test]
446 fn test_percentage_coloring() {
447 let low = SemanticLabel::percentage(10.0);
448 assert_eq!(low.status, SemanticStatus::Normal);
449
450 let high = SemanticLabel::percentage(95.0);
451 assert_eq!(high.status, SemanticStatus::Critical);
452 }
453
454 #[test]
455 fn test_from_percentage_all_ranges() {
456 assert_eq!(
457 SemanticStatus::from_percentage(90.0),
458 SemanticStatus::Normal
459 );
460 assert_eq!(SemanticStatus::from_percentage(70.0), SemanticStatus::Good);
461 assert_eq!(
462 SemanticStatus::from_percentage(50.0),
463 SemanticStatus::Warning
464 );
465 assert_eq!(SemanticStatus::from_percentage(30.0), SemanticStatus::High);
466 assert_eq!(
467 SemanticStatus::from_percentage(10.0),
468 SemanticStatus::Critical
469 );
470 assert_eq!(
471 SemanticStatus::from_percentage(f64::NAN),
472 SemanticStatus::Unknown
473 );
474 }
475
476 #[test]
477 fn test_from_usage_all_ranges() {
478 assert_eq!(SemanticStatus::from_usage(10.0), SemanticStatus::Normal);
479 assert_eq!(SemanticStatus::from_usage(30.0), SemanticStatus::Good);
480 assert_eq!(SemanticStatus::from_usage(50.0), SemanticStatus::Warning);
481 assert_eq!(SemanticStatus::from_usage(70.0), SemanticStatus::High);
482 assert_eq!(SemanticStatus::from_usage(90.0), SemanticStatus::Critical);
483 assert_eq!(
484 SemanticStatus::from_usage(f64::NAN),
485 SemanticStatus::Unknown
486 );
487 }
488
489 #[test]
490 fn test_from_temperature_all_ranges() {
491 assert_eq!(
492 SemanticStatus::from_temperature(40.0),
493 SemanticStatus::Normal
494 );
495 assert_eq!(SemanticStatus::from_temperature(60.0), SemanticStatus::Good);
496 assert_eq!(
497 SemanticStatus::from_temperature(75.0),
498 SemanticStatus::Warning
499 );
500 assert_eq!(SemanticStatus::from_temperature(85.0), SemanticStatus::High);
501 assert_eq!(
502 SemanticStatus::from_temperature(95.0),
503 SemanticStatus::Critical
504 );
505 assert_eq!(
506 SemanticStatus::from_temperature(f64::NAN),
507 SemanticStatus::Unknown
508 );
509 }
510
511 #[test]
513 fn test_temperature_coloring() {
514 let cool = SemanticLabel::temperature(40.0);
515 assert_eq!(cool.status, SemanticStatus::Normal);
516
517 let hot = SemanticLabel::temperature(95.0);
518 assert_eq!(hot.status, SemanticStatus::Critical);
519 }
520
521 #[test]
523 fn test_display_text() {
524 let label = SemanticLabel::new("50")
525 .with_prefix("CPU: ")
526 .with_suffix("%");
527 assert_eq!(label.display_text(), "CPU: 50%");
528 }
529
530 #[test]
532 fn test_symbol_display() {
533 let label = SemanticLabel::new("OK")
534 .with_status(SemanticStatus::Normal)
535 .with_symbol();
536 assert!(label.display_text().starts_with("✓"));
537 }
538
539 #[test]
540 fn test_symbol_warning() {
541 let label = SemanticLabel::new("Warn")
542 .with_status(SemanticStatus::Warning)
543 .with_symbol();
544 assert!(label.display_text().starts_with("⚠"));
545 }
546
547 #[test]
548 fn test_symbol_critical() {
549 let label = SemanticLabel::new("Err")
550 .with_status(SemanticStatus::Critical)
551 .with_symbol();
552 assert!(label.display_text().starts_with("✗"));
553 }
554
555 #[test]
556 fn test_symbol_unknown() {
557 let label = SemanticLabel::new("?")
558 .with_status(SemanticStatus::Unknown)
559 .with_symbol();
560 assert!(label.display_text().starts_with('?'));
561 }
562
563 #[test]
565 fn test_truncation() {
566 let label = SemanticLabel::new("Very long text here").with_max_width(10);
567 let text = label.truncated_text();
568 assert_eq!(text.chars().count(), 10);
569 assert!(text.ends_with('…'));
570 }
571
572 #[test]
573 fn test_truncation_width_1() {
574 let label = SemanticLabel::new("Very long text").with_max_width(1);
575 let text = label.truncated_text();
576 assert_eq!(text, "…");
577 }
578
579 #[test]
580 fn test_no_truncation_when_fits() {
581 let label = SemanticLabel::new("Short");
582 let text = label.truncated_text();
583 assert_eq!(text, "Short");
584 }
585
586 #[test]
588 fn test_measure() {
589 let label = SemanticLabel::new("Hello");
590 let constraints = Constraints {
591 min_width: 0.0,
592 max_width: 100.0,
593 min_height: 0.0,
594 max_height: 10.0,
595 };
596 let size = label.measure(constraints);
597 assert_eq!(size.width, 5.0);
598 assert_eq!(size.height, 1.0);
599 }
600
601 #[test]
602 fn test_layout() {
603 let mut label = SemanticLabel::new("Test");
604 let result = label.layout(Rect::new(5.0, 10.0, 20.0, 1.0));
605 assert_eq!(result.size.width, 20.0);
606 assert_eq!(result.size.height, 1.0);
607 }
608
609 #[test]
610 fn test_paint() {
611 let mut label = SemanticLabel::new("Hello").with_status(SemanticStatus::Normal);
612 let mut buffer = CellBuffer::new(20, 1);
613 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
614 label.layout(Rect::new(0.0, 0.0, 20.0, 1.0));
615 label.paint(&mut canvas);
616 assert_eq!(buffer.get(0, 0).unwrap().symbol, "H");
617 }
618
619 #[test]
620 fn test_paint_zero_width() {
621 let mut label = SemanticLabel::new("Hello");
622 let mut buffer = CellBuffer::new(20, 1);
623 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
624 label.layout(Rect::new(0.0, 0.0, 0.0, 1.0));
625 label.paint(&mut canvas); }
627
628 #[test]
629 fn test_paint_zero_height() {
630 let mut label = SemanticLabel::new("Hello");
631 let mut buffer = CellBuffer::new(20, 1);
632 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
633 label.layout(Rect::new(0.0, 0.0, 20.0, 0.0));
634 label.paint(&mut canvas); }
636
637 #[test]
638 fn test_event_returns_none() {
639 let mut label = SemanticLabel::new("Test");
640 let result = label.event(&Event::FocusIn);
641 assert!(result.is_none());
642 }
643
644 #[test]
645 fn test_children_empty() {
646 let label = SemanticLabel::new("Test");
647 assert!(label.children().is_empty());
648 }
649
650 #[test]
651 fn test_children_mut_empty() {
652 let mut label = SemanticLabel::new("Test");
653 assert!(label.children_mut().is_empty());
654 }
655
656 #[test]
658 fn test_brick_verification() {
659 let label = SemanticLabel::new("Test").with_status(SemanticStatus::Critical);
660 let v = label.verify();
661 assert!(
662 v.failed.is_empty(),
663 "Critical label should pass verification"
664 );
665 }
666
667 #[test]
668 fn test_brick_verification_warning() {
669 let label = SemanticLabel::new("Test").with_status(SemanticStatus::Warning);
670 let v = label.verify();
671 assert!(v.failed.is_empty());
672 }
673
674 #[test]
675 fn test_brick_verification_normal() {
676 let label = SemanticLabel::new("Test").with_status(SemanticStatus::Normal);
677 let v = label.verify();
678 assert!(v.failed.is_empty());
679 }
680
681 #[test]
682 fn test_brick_name() {
683 let label = SemanticLabel::new("Test");
684 assert_eq!(label.brick_name(), "semantic_label");
685 }
686
687 #[test]
688 fn test_brick_assertions() {
689 let label = SemanticLabel::new("Test");
690 assert!(!label.assertions().is_empty());
691 }
692
693 #[test]
694 fn test_brick_budget() {
695 let label = SemanticLabel::new("Test");
696 let budget = label.budget();
697 assert!(budget.total_ms > 0);
699 }
700
701 #[test]
702 fn test_to_html() {
703 let label = SemanticLabel::new("Test").with_status(SemanticStatus::Critical);
704 let html = label.to_html();
705 assert!(html.contains("semantic-label"));
706 assert!(html.contains("status-critical"));
707 assert!(html.contains("Test"));
708 }
709
710 #[test]
711 fn test_to_html_all_statuses() {
712 assert!(SemanticLabel::new("")
713 .with_status(SemanticStatus::Normal)
714 .to_html()
715 .contains("status-normal"));
716 assert!(SemanticLabel::new("")
717 .with_status(SemanticStatus::Good)
718 .to_html()
719 .contains("status-good"));
720 assert!(SemanticLabel::new("")
721 .with_status(SemanticStatus::Warning)
722 .to_html()
723 .contains("status-warning"));
724 assert!(SemanticLabel::new("")
725 .with_status(SemanticStatus::High)
726 .to_html()
727 .contains("status-high"));
728 assert!(SemanticLabel::new("")
729 .with_status(SemanticStatus::Unknown)
730 .to_html()
731 .contains("status-unknown"));
732 assert!(SemanticLabel::new("")
733 .with_status(SemanticStatus::Custom(0, 0, 0))
734 .to_html()
735 .contains("status-custom"));
736 }
737
738 #[test]
739 fn test_to_css() {
740 let label = SemanticLabel::new("Test");
741 let css = label.to_css();
742 assert!(css.is_empty()); }
744
745 #[test]
747 fn test_from_health_status() {
748 assert_eq!(
749 SemanticStatus::from_health_status(HealthStatus::Critical),
750 SemanticStatus::Critical
751 );
752 assert_eq!(
753 SemanticStatus::from_health_status(HealthStatus::Healthy),
754 SemanticStatus::Normal
755 );
756 }
757
758 #[test]
759 fn test_from_health_status_all() {
760 assert_eq!(
761 SemanticStatus::from_health_status(HealthStatus::Healthy),
762 SemanticStatus::Normal
763 );
764 assert_eq!(
765 SemanticStatus::from_health_status(HealthStatus::Warning),
766 SemanticStatus::Warning
767 );
768 assert_eq!(
769 SemanticStatus::from_health_status(HealthStatus::Critical),
770 SemanticStatus::Critical
771 );
772 assert_eq!(
773 SemanticStatus::from_health_status(HealthStatus::Unknown),
774 SemanticStatus::Unknown
775 );
776 }
777
778 #[test]
779 fn test_default() {
780 let label = SemanticLabel::default();
781 assert_eq!(label.text, "");
782 assert_eq!(label.status, SemanticStatus::Normal);
783 }
784
785 #[test]
786 fn test_semantic_status_default() {
787 let status = SemanticStatus::default();
788 assert_eq!(status, SemanticStatus::Normal);
789 }
790}