1use presentar_core::{
7 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8 LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::fmt::Write as _;
12use std::time::Duration;
13
14#[derive(Debug, Clone)]
16pub struct BarSegment {
17 pub value: f64,
19 pub color: Color,
21}
22
23#[derive(Debug, Clone, Default)]
28pub struct ProportionalBar {
29 pub segments: Vec<BarSegment>,
31 pub background_color: Option<Color>,
33 bounds: Rect,
35}
36
37impl ProportionalBar {
38 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub fn with_segment(mut self, value: f64, color: Color) -> Self {
45 self.segments.push(BarSegment { value, color });
46 self
47 }
48
49 pub fn with_background(mut self, color: Color) -> Self {
51 self.background_color = Some(color);
52 self
53 }
54
55 pub fn total_value(&self) -> f64 {
57 self.segments.iter().map(|s| s.value).sum()
58 }
59
60 fn get_block_char(fraction: f64) -> (char, bool) {
63 if fraction >= 1.0 {
64 ('█', true)
65 } else if fraction >= 0.875 {
66 ('▇', false)
67 } else if fraction >= 0.75 {
68 ('▆', false)
69 } else if fraction >= 0.625 {
70 ('▅', false)
71 } else if fraction >= 0.5 {
72 ('▄', false)
73 } else if fraction >= 0.375 {
74 ('▃', false)
75 } else if fraction >= 0.25 {
76 ('▂', false)
77 } else if fraction >= 0.125 {
78 ('▁', false)
79 } else {
80 (' ', false) }
82 }
83}
84
85impl Widget for ProportionalBar {
86 fn type_id(&self) -> TypeId {
87 TypeId::of::<Self>()
88 }
89
90 fn measure(&self, constraints: Constraints) -> Size {
91 constraints.constrain(Size::new(constraints.max_width, 1.0))
93 }
94
95 fn layout(&mut self, bounds: Rect) -> LayoutResult {
96 self.bounds = bounds;
97 LayoutResult {
98 size: Size::new(bounds.width, bounds.height),
99 }
100 }
101
102 fn paint(&self, canvas: &mut dyn Canvas) {
103 if self.bounds.width < 1.0 || self.bounds.height < 1.0 {
104 return;
105 }
106
107 let width_chars = self.bounds.width as usize;
108 let x = self.bounds.x;
109 let y = self.bounds.y;
110
111 let total = self.total_value();
114 let _safe_total = if total.is_nan() { 0.0 } else { total.min(1.0) };
115
116 if let Some(bg) = self.background_color {
118 canvas.fill_rect(Rect::new(x, y, self.bounds.width, 1.0), bg);
119 }
120
121 let mut current_pos_chars = 0.0;
123
124 for segment in &self.segments {
125 let val = if segment.value.is_nan() {
127 0.0
128 } else {
129 segment.value
130 };
131 if val <= 0.0 {
132 continue;
133 }
134
135 let segment_width_chars = val * width_chars as f64;
136 let end_pos_chars = current_pos_chars + segment_width_chars;
137
138 let start_idx = current_pos_chars.floor() as usize;
140 let end_idx = end_pos_chars.floor() as usize;
141
142 for i in start_idx..end_idx {
144 if i < width_chars {
145 canvas.draw_text(
146 "█",
147 Point::new(x + i as f32, y),
148 &TextStyle {
149 color: segment.color,
150 ..Default::default()
151 },
152 );
153 }
154 }
155
156 let fractional_part = end_pos_chars - end_pos_chars.floor();
158 if fractional_part > 0.001 && end_idx < width_chars {
159 let (ch, _) = Self::get_block_char(fractional_part);
161 canvas.draw_text(
162 &ch.to_string(),
163 Point::new(x + end_idx as f32, y),
164 &TextStyle {
165 color: segment.color,
166 ..Default::default()
167 },
168 );
169 }
170
171 current_pos_chars = end_pos_chars;
172 }
173
174 }
177
178 fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
179 None
180 }
181
182 fn children(&self) -> &[Box<dyn Widget>] {
183 &[]
184 }
185
186 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
187 &mut []
188 }
189}
190
191impl Brick for ProportionalBar {
193 fn brick_name(&self) -> &'static str {
194 "proportional_bar"
195 }
196
197 fn assertions(&self) -> &[BrickAssertion] {
198 static ASSERTIONS: &[BrickAssertion] = &[
199 BrickAssertion::max_latency_ms(1), ];
203 ASSERTIONS
204 }
205
206 fn budget(&self) -> BrickBudget {
207 BrickBudget::uniform(1) }
209
210 fn verify(&self) -> BrickVerification {
211 let total = self.total_value();
213
214 let nan_safe = !total.is_nan(); let bounds_safe = total <= 1.0 + f64::EPSILON; if nan_safe && bounds_safe {
218 BrickVerification {
219 passed: self.assertions().to_vec(),
220 failed: vec![],
221 verification_time: Duration::from_micros(1),
222 }
223 } else {
224 BrickVerification {
225 passed: vec![],
226 failed: self
227 .assertions()
228 .iter()
229 .map(|a| (a.clone(), "NaN or bounds violation".to_string()))
230 .collect(),
231 verification_time: Duration::from_micros(1),
232 }
233 }
234 }
235
236 fn to_html(&self) -> String {
237 let mut html =
238 String::from("<div class=\"proportional-bar\" style=\"display:flex;height:1em;\">");
239 let total: f64 = self.segments.iter().map(|s| s.value).sum();
240 for seg in &self.segments {
241 let pct = if total > 0.0 {
242 (seg.value / total) * 100.0
243 } else {
244 0.0
245 };
246 let Color { r, g, b, .. } = seg.color;
247 let _ = write!(
248 html,
249 "<div style=\"width:{pct:.1}%;background:rgb({r},{g},{b})\"></div>"
250 );
251 }
252 html.push_str("</div>");
253 html
254 }
255
256 fn to_css(&self) -> String {
257 String::from(".proportional-bar{display:flex;height:1em;width:100%}.proportional-bar>div{min-width:1px}")
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::direct::{CellBuffer, DirectTerminalCanvas};
265
266 #[test]
268 #[allow(clippy::assertions_on_constants)]
269 fn test_f_atom_001_no_bleed() {
270 let mut bar = ProportionalBar::new()
271 .with_segment(0.6, Color::RED)
272 .with_segment(0.6, Color::BLUE); let mut buffer = CellBuffer::new(10, 1);
275 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
276
277 bar.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
278 bar.paint(&mut canvas);
279
280 assert!(true, "Implementation limits loop to width_chars");
284 }
285
286 #[test]
288 fn test_f_atom_002_nan_safe() {
289 let bar = ProportionalBar::new().with_segment(f64::NAN, Color::RED);
290
291 let _v = bar.verify();
292 let mut buffer = CellBuffer::new(10, 1);
300 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
301 bar.paint(&mut canvas); }
303
304 #[test]
306 fn test_f_atom_003_linear_interpolation() {
307 let (ch, _) = ProportionalBar::get_block_char(0.5);
308 assert_eq!(ch, '▄'); let (ch, _) = ProportionalBar::get_block_char(0.1);
311 assert_eq!(ch, ' '); let (ch, _) = ProportionalBar::get_block_char(0.9);
314 assert_eq!(ch, '▇'); }
316
317 #[test]
319 fn test_bar_segment_debug() {
320 let seg = BarSegment {
321 value: 0.5,
322 color: Color::RED,
323 };
324 let debug = format!("{:?}", seg);
325 assert!(debug.contains("BarSegment"));
326 }
327
328 #[test]
329 fn test_bar_segment_clone() {
330 let seg = BarSegment {
331 value: 0.75,
332 color: Color::BLUE,
333 };
334 let cloned = seg;
335 assert!((cloned.value - 0.75).abs() < f64::EPSILON);
336 }
337
338 #[test]
339 fn test_proportional_bar_default() {
340 let bar = ProportionalBar::default();
341 assert!(bar.segments.is_empty());
342 assert!(bar.background_color.is_none());
343 }
344
345 #[test]
346 fn test_proportional_bar_new() {
347 let bar = ProportionalBar::new();
348 assert!(bar.segments.is_empty());
349 }
350
351 #[test]
352 fn test_proportional_bar_debug() {
353 let bar = ProportionalBar::new();
354 let debug = format!("{:?}", bar);
355 assert!(debug.contains("ProportionalBar"));
356 }
357
358 #[test]
359 fn test_proportional_bar_clone() {
360 let bar = ProportionalBar::new()
361 .with_segment(0.3, Color::RED)
362 .with_background(Color::BLACK);
363 let cloned = bar;
364 assert_eq!(cloned.segments.len(), 1);
365 assert!(cloned.background_color.is_some());
366 }
367
368 #[test]
369 fn test_with_segment() {
370 let bar = ProportionalBar::new()
371 .with_segment(0.25, Color::RED)
372 .with_segment(0.35, Color::GREEN);
373 assert_eq!(bar.segments.len(), 2);
374 }
375
376 #[test]
377 fn test_with_background() {
378 let bar = ProportionalBar::new().with_background(Color::rgb(0.5, 0.5, 0.5));
379 assert!(bar.background_color.is_some());
380 }
381
382 #[test]
383 fn test_total_value() {
384 let bar = ProportionalBar::new()
385 .with_segment(0.2, Color::RED)
386 .with_segment(0.3, Color::GREEN)
387 .with_segment(0.1, Color::BLUE);
388 let total = bar.total_value();
389 assert!((total - 0.6).abs() < f64::EPSILON);
390 }
391
392 #[test]
393 fn test_total_value_empty() {
394 let bar = ProportionalBar::new();
395 assert!((bar.total_value() - 0.0).abs() < f64::EPSILON);
396 }
397
398 #[test]
399 fn test_get_block_char_full() {
400 let (ch, full) = ProportionalBar::get_block_char(1.0);
401 assert_eq!(ch, '█');
402 assert!(full);
403 }
404
405 #[test]
406 fn test_get_block_char_above_full() {
407 let (ch, full) = ProportionalBar::get_block_char(1.5);
408 assert_eq!(ch, '█');
409 assert!(full);
410 }
411
412 #[test]
413 fn test_get_block_char_875() {
414 let (ch, full) = ProportionalBar::get_block_char(0.88);
415 assert_eq!(ch, '▇');
416 assert!(!full);
417 }
418
419 #[test]
420 fn test_get_block_char_75() {
421 let (ch, _) = ProportionalBar::get_block_char(0.76);
422 assert_eq!(ch, '▆');
423 }
424
425 #[test]
426 fn test_get_block_char_625() {
427 let (ch, _) = ProportionalBar::get_block_char(0.63);
428 assert_eq!(ch, '▅');
429 }
430
431 #[test]
432 fn test_get_block_char_375() {
433 let (ch, _) = ProportionalBar::get_block_char(0.38);
434 assert_eq!(ch, '▃');
435 }
436
437 #[test]
438 fn test_get_block_char_25() {
439 let (ch, _) = ProportionalBar::get_block_char(0.26);
440 assert_eq!(ch, '▂');
441 }
442
443 #[test]
444 fn test_get_block_char_125() {
445 let (ch, _) = ProportionalBar::get_block_char(0.13);
446 assert_eq!(ch, '▁');
447 }
448
449 #[test]
450 fn test_get_block_char_zero() {
451 let (ch, full) = ProportionalBar::get_block_char(0.0);
452 assert_eq!(ch, ' ');
453 assert!(!full);
454 }
455
456 #[test]
457 fn test_get_block_char_negative() {
458 let (ch, _) = ProportionalBar::get_block_char(-0.5);
459 assert_eq!(ch, ' ');
460 }
461
462 #[test]
463 fn test_measure() {
464 let bar = ProportionalBar::new();
465 let size = bar.measure(Constraints {
466 min_width: 0.0,
467 min_height: 0.0,
468 max_width: 50.0,
469 max_height: 10.0,
470 });
471 assert!((size.width - 50.0).abs() < f32::EPSILON);
472 assert!((size.height - 1.0).abs() < f32::EPSILON);
473 }
474
475 #[test]
476 fn test_layout() {
477 let mut bar = ProportionalBar::new();
478 let result = bar.layout(Rect::new(5.0, 10.0, 20.0, 1.0));
479 assert!((result.size.width - 20.0).abs() < f32::EPSILON);
480 assert!((result.size.height - 1.0).abs() < f32::EPSILON);
481 }
482
483 #[test]
484 fn test_paint_empty_bar() {
485 let bar = ProportionalBar::new();
486 let mut buffer = CellBuffer::new(10, 1);
487 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
488 bar.paint(&mut canvas);
489 }
491
492 #[test]
493 fn test_paint_zero_width() {
494 let mut bar = ProportionalBar::new().with_segment(0.5, Color::RED);
495 bar.layout(Rect::new(0.0, 0.0, 0.0, 1.0));
496 let mut buffer = CellBuffer::new(0, 1);
497 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
498 bar.paint(&mut canvas);
499 }
501
502 #[test]
503 fn test_paint_zero_height() {
504 let mut bar = ProportionalBar::new().with_segment(0.5, Color::RED);
505 bar.layout(Rect::new(0.0, 0.0, 10.0, 0.0));
506 let mut buffer = CellBuffer::new(10, 0);
507 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
508 bar.paint(&mut canvas);
509 }
511
512 #[test]
513 fn test_paint_with_background() {
514 let mut bar = ProportionalBar::new()
515 .with_segment(0.3, Color::RED)
516 .with_background(Color::rgb(0.5, 0.5, 0.5));
517 bar.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
518 let mut buffer = CellBuffer::new(10, 1);
519 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
520 bar.paint(&mut canvas);
521 }
523
524 #[test]
525 fn test_paint_multiple_segments() {
526 let mut bar = ProportionalBar::new()
527 .with_segment(0.3, Color::RED)
528 .with_segment(0.3, Color::GREEN)
529 .with_segment(0.3, Color::BLUE);
530 bar.layout(Rect::new(0.0, 0.0, 20.0, 1.0));
531 let mut buffer = CellBuffer::new(20, 1);
532 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
533 bar.paint(&mut canvas);
534 }
536
537 #[test]
538 fn test_paint_zero_value_segment() {
539 let mut bar = ProportionalBar::new()
540 .with_segment(0.0, Color::RED) .with_segment(0.5, Color::GREEN);
542 bar.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
543 let mut buffer = CellBuffer::new(10, 1);
544 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
545 bar.paint(&mut canvas);
546 }
548
549 #[test]
550 fn test_paint_negative_value_segment() {
551 let mut bar = ProportionalBar::new()
552 .with_segment(-0.5, Color::RED) .with_segment(0.5, Color::GREEN);
554 bar.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
555 let mut buffer = CellBuffer::new(10, 1);
556 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
557 bar.paint(&mut canvas);
558 }
560
561 #[test]
562 fn test_paint_fractional_segments() {
563 let mut bar = ProportionalBar::new().with_segment(0.15, Color::RED); bar.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
565 let mut buffer = CellBuffer::new(10, 1);
566 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
567 bar.paint(&mut canvas);
568 }
570
571 #[test]
572 fn test_type_id() {
573 let bar = ProportionalBar::new();
574 let _ = Widget::type_id(&bar);
575 }
576
577 #[test]
578 fn test_event() {
579 let mut bar = ProportionalBar::new();
580 let result = bar.event(&Event::Resize {
581 width: 100.0,
582 height: 50.0,
583 });
584 assert!(result.is_none());
585 }
586
587 #[test]
588 fn test_children() {
589 let bar = ProportionalBar::new();
590 assert!(bar.children().is_empty());
591 }
592
593 #[test]
594 fn test_children_mut() {
595 let mut bar = ProportionalBar::new();
596 assert!(bar.children_mut().is_empty());
597 }
598
599 #[test]
600 fn test_brick_name() {
601 let bar = ProportionalBar::new();
602 assert_eq!(bar.brick_name(), "proportional_bar");
603 }
604
605 #[test]
606 fn test_assertions() {
607 let bar = ProportionalBar::new();
608 let assertions = bar.assertions();
609 assert!(!assertions.is_empty());
610 }
611
612 #[test]
613 fn test_budget() {
614 let bar = ProportionalBar::new();
615 let _budget = bar.budget();
616 }
617
618 #[test]
619 fn test_verify_valid() {
620 let bar = ProportionalBar::new()
621 .with_segment(0.3, Color::RED)
622 .with_segment(0.4, Color::GREEN);
623 let verification = bar.verify();
624 assert!(verification.failed.is_empty());
625 }
626
627 #[test]
628 fn test_verify_exceeds_bounds() {
629 let bar = ProportionalBar::new()
630 .with_segment(0.6, Color::RED)
631 .with_segment(0.6, Color::GREEN); let verification = bar.verify();
633 assert!(!verification.failed.is_empty());
634 }
635
636 #[test]
637 fn test_verify_nan() {
638 let bar = ProportionalBar::new().with_segment(f64::NAN, Color::RED);
639 let verification = bar.verify();
640 assert!(!verification.failed.is_empty());
641 }
642
643 #[test]
644 fn test_to_html() {
645 let bar = ProportionalBar::new();
646 let html = bar.to_html();
647 assert!(html.contains("proportional-bar"));
648 assert!(html.starts_with("<div"));
649 assert!(html.ends_with("</div>"));
650 }
651
652 #[test]
653 fn test_to_css() {
654 let bar = ProportionalBar::new();
655 let css = bar.to_css();
656 assert!(css.contains("proportional-bar"));
657 assert!(css.contains("display:flex"));
658 }
659}