1use crate::render::{Cell, Modifier};
7use crate::style::Color;
8use crate::widget::traits::{RenderContext, View, WidgetProps};
9use crate::{impl_props_builders, impl_styled_view};
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum GaugeStyle {
14 #[default]
16 Bar,
17 Battery,
19 Thermometer,
21 Arc,
23 Circle,
25 Vertical,
27 Segments,
29 Dots,
31}
32
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub enum LabelPosition {
36 None,
38 #[default]
40 Inside,
41 Left,
43 Right,
45 Above,
47 Below,
49}
50
51pub struct Gauge {
53 value: f64,
55 min: f64,
57 max: f64,
59 style: GaugeStyle,
61 width: u16,
63 height: u16,
65 label: Option<String>,
67 label_position: LabelPosition,
69 show_percent: bool,
71 fill_color: Color,
73 empty_color: Color,
75 border_color: Option<Color>,
77 warning_threshold: Option<f64>,
79 critical_threshold: Option<f64>,
81 warning_color: Color,
83 critical_color: Color,
85 segments: u16,
87 title: Option<String>,
89 props: WidgetProps,
91}
92
93impl Gauge {
94 pub fn new() -> Self {
96 Self {
97 value: 0.0,
98 min: 0.0,
99 max: 100.0,
100 style: GaugeStyle::Bar,
101 width: 20,
102 height: 5,
103 label: None,
104 label_position: LabelPosition::Inside,
105 show_percent: true,
106 fill_color: Color::GREEN,
107 empty_color: Color::rgb(60, 60, 60),
108 border_color: None,
109 warning_threshold: None,
110 critical_threshold: None,
111 warning_color: Color::YELLOW,
112 critical_color: Color::RED,
113 segments: 10,
114 title: None,
115 props: WidgetProps::new(),
116 }
117 }
118
119 pub fn value(mut self, value: f64) -> Self {
121 self.value = value.clamp(0.0, 1.0);
122 self
123 }
124
125 pub fn value_range(mut self, value: f64, min: f64, max: f64) -> Self {
130 let (min, max) = if min < max { (min, max) } else { (max, min) };
132
133 self.min = min;
134 self.max = max;
135
136 let range = max - min;
138 if range.abs() < f64::EPSILON {
139 self.value = 0.0;
140 } else {
141 self.value = ((value - min) / range).clamp(0.0, 1.0);
142 }
143 self
144 }
145
146 pub fn percent(mut self, percent: f64) -> Self {
148 self.value = (percent / 100.0).clamp(0.0, 1.0);
149 self
150 }
151
152 pub fn style(mut self, style: GaugeStyle) -> Self {
154 self.style = style;
155 self
156 }
157
158 pub fn width(mut self, width: u16) -> Self {
160 self.width = width.max(4);
161 self
162 }
163
164 pub fn height(mut self, height: u16) -> Self {
166 self.height = height.max(1);
167 self
168 }
169
170 pub fn label(mut self, label: impl Into<String>) -> Self {
172 self.label = Some(label.into());
173 self
174 }
175
176 pub fn label_position(mut self, position: LabelPosition) -> Self {
178 self.label_position = position;
179 self
180 }
181
182 pub fn show_percent(mut self, show: bool) -> Self {
184 self.show_percent = show;
185 self
186 }
187
188 pub fn fill_color(mut self, color: Color) -> Self {
190 self.fill_color = color;
191 self
192 }
193
194 pub fn empty_color(mut self, color: Color) -> Self {
196 self.empty_color = color;
197 self
198 }
199
200 pub fn border(mut self, color: Color) -> Self {
202 self.border_color = Some(color);
203 self
204 }
205
206 pub fn thresholds(mut self, warning: f64, critical: f64) -> Self {
211 let warning = warning.clamp(0.0, 1.0);
212 let critical = critical.clamp(0.0, 1.0);
213 let (warning, critical) = if warning < critical {
215 (warning, critical)
216 } else {
217 (critical, warning)
218 };
219 self.warning_threshold = Some(warning);
220 self.critical_threshold = Some(critical);
221 self
222 }
223
224 pub fn warning_color(mut self, color: Color) -> Self {
226 self.warning_color = color;
227 self
228 }
229
230 pub fn critical_color(mut self, color: Color) -> Self {
232 self.critical_color = color;
233 self
234 }
235
236 pub fn segments(mut self, count: u16) -> Self {
238 self.segments = count.max(2);
239 self
240 }
241
242 pub fn title(mut self, title: impl Into<String>) -> Self {
244 self.title = Some(title.into());
245 self
246 }
247
248 fn current_color(&self) -> Color {
250 if let Some(critical) = self.critical_threshold {
251 if self.value >= critical {
252 return self.critical_color;
253 }
254 }
255 if let Some(warning) = self.warning_threshold {
256 if self.value >= warning {
257 return self.warning_color;
258 }
259 }
260 self.fill_color
261 }
262
263 fn get_label(&self) -> String {
265 if let Some(ref label) = self.label {
266 label.clone()
267 } else if self.show_percent {
268 format!("{:.0}%", self.value * 100.0)
269 } else {
270 let display_value = self.min + self.value * (self.max - self.min);
271 format!("{:.0}", display_value)
272 }
273 }
274
275 pub fn set_value(&mut self, value: f64) {
277 self.value = value.clamp(0.0, 1.0);
278 }
279
280 pub fn get_value(&self) -> f64 {
282 self.value
283 }
284
285 fn render_bar(&self, ctx: &mut RenderContext) {
287 let area = ctx.area;
288 let width = self.width.min(area.width);
289 let filled = (self.value * width as f64).round() as u16;
290 let color = self.current_color();
291
292 for x in 0..width {
294 let ch = if x < filled { '█' } else { '░' };
295 let fg = if x < filled { color } else { self.empty_color };
296
297 let mut cell = Cell::new(ch);
298 cell.fg = Some(fg);
299 ctx.buffer.set(area.x + x, area.y, cell);
300 }
301
302 if matches!(self.label_position, LabelPosition::Inside) {
304 let label = self.get_label();
305 let label_x = area.x + (width.saturating_sub(label.len() as u16)) / 2;
306 for (i, ch) in label.chars().enumerate() {
307 let x = label_x + i as u16;
308 if x < area.x + width {
309 let mut cell = Cell::new(ch);
310 cell.fg = Some(Color::WHITE);
311 cell.modifier |= Modifier::BOLD;
312 ctx.buffer.set(x, area.y, cell);
313 }
314 }
315 }
316 }
317
318 fn render_battery(&self, ctx: &mut RenderContext) {
320 let area = ctx.area;
321 let width = self.width.min(area.width).max(6);
322 let inner_width = width - 3; let filled = (self.value * inner_width as f64).round() as u16;
324 let color = self.current_color();
325
326 let mut left = Cell::new('[');
328 left.fg = Some(Color::WHITE);
329 ctx.buffer.set(area.x, area.y, left);
330
331 for x in 0..inner_width {
332 let ch = if x < filled { '█' } else { ' ' };
333 let fg = if x < filled { color } else { self.empty_color };
334 let mut cell = Cell::new(ch);
335 cell.fg = Some(fg);
336 ctx.buffer.set(area.x + 1 + x, area.y, cell);
337 }
338
339 let mut right = Cell::new(']');
340 right.fg = Some(Color::WHITE);
341 ctx.buffer.set(area.x + 1 + inner_width, area.y, right);
342
343 let mut cap = Cell::new('▌');
345 cap.fg = Some(Color::WHITE);
346 ctx.buffer.set(area.x + 2 + inner_width, area.y, cap);
347 }
348
349 fn render_thermometer(&self, ctx: &mut RenderContext) {
351 let area = ctx.area;
352 let height = self.height.min(area.height).max(3);
353 let filled = (self.value * (height - 1) as f64).round() as u16;
354 let color = self.current_color();
355
356 let mut bulb = Cell::new('●');
358 bulb.fg = Some(color);
359 ctx.buffer.set(area.x, area.y + height - 1, bulb);
360
361 for y in 0..height - 1 {
363 let from_bottom = height - 2 - y;
364 let ch = if from_bottom < filled { '█' } else { '│' };
365 let fg = if from_bottom < filled {
366 color
367 } else {
368 self.empty_color
369 };
370 let mut cell = Cell::new(ch);
371 cell.fg = Some(fg);
372 ctx.buffer.set(area.x, area.y + y, cell);
373 }
374 }
375
376 fn render_arc(&self, ctx: &mut RenderContext) {
378 let area = ctx.area;
379 let color = self.current_color();
380
381 let width = self.width.min(area.width).max(8);
385
386 let mut tl = Cell::new('╭');
388 tl.fg = Some(color);
389 ctx.buffer.set(area.x, area.y, tl);
390
391 for x in 1..width - 1 {
392 let progress = (x - 1) as f64 / (width - 3) as f64;
393 let ch = if progress <= self.value { '━' } else { '─' };
394 let fg = if progress <= self.value {
395 color
396 } else {
397 self.empty_color
398 };
399 let mut cell = Cell::new(ch);
400 cell.fg = Some(fg);
401 ctx.buffer.set(area.x + x, area.y, cell);
402 }
403
404 let mut tr = Cell::new('╮');
405 tr.fg = Some(color);
406 ctx.buffer.set(area.x + width - 1, area.y, tr);
407
408 if area.height > 1 {
410 let label = self.get_label();
411 let label_x = area.x + (width.saturating_sub(label.len() as u16)) / 2;
412
413 let mut left = Cell::new('│');
414 left.fg = Some(color);
415 ctx.buffer.set(area.x, area.y + 1, left);
416
417 for (i, ch) in label.chars().enumerate() {
418 let mut cell = Cell::new(ch);
419 cell.fg = Some(Color::WHITE);
420 cell.modifier |= Modifier::BOLD;
421 ctx.buffer.set(label_x + i as u16, area.y + 1, cell);
422 }
423
424 let mut right = Cell::new('│');
425 right.fg = Some(color);
426 ctx.buffer.set(area.x + width - 1, area.y + 1, right);
427 }
428
429 if area.height > 2 {
431 let mut bl = Cell::new('╰');
432 bl.fg = Some(color);
433 ctx.buffer.set(area.x, area.y + 2, bl);
434
435 for x in 1..width - 1 {
436 let mut cell = Cell::new('─');
437 cell.fg = Some(self.empty_color);
438 ctx.buffer.set(area.x + x, area.y + 2, cell);
439 }
440
441 let mut br = Cell::new('╯');
442 br.fg = Some(color);
443 ctx.buffer.set(area.x + width - 1, area.y + 2, br);
444 }
445 }
446
447 fn render_circle(&self, ctx: &mut RenderContext) {
449 let area = ctx.area;
450 let color = self.current_color();
451
452 let label = self.get_label();
458
459 let segments = 5u16;
461 let filled = (self.value * segments as f64).round() as u16;
462
463 let mut open = Cell::new('(');
464 open.fg = Some(Color::WHITE);
465 ctx.buffer.set(area.x, area.y, open);
466
467 for i in 0..segments {
468 let ch = if i < filled { '●' } else { '○' };
469 let fg = if i < filled { color } else { self.empty_color };
470 let mut cell = Cell::new(ch);
471 cell.fg = Some(fg);
472 ctx.buffer.set(area.x + 1 + i, area.y, cell);
473 }
474
475 let mut close = Cell::new(')');
476 close.fg = Some(Color::WHITE);
477 ctx.buffer.set(area.x + 1 + segments, area.y, close);
478
479 let label_x = area.x + 3 + segments;
481 for (i, ch) in label.chars().enumerate() {
482 let mut cell = Cell::new(ch);
483 cell.fg = Some(Color::WHITE);
484 ctx.buffer.set(label_x + i as u16, area.y, cell);
485 }
486 }
487
488 fn render_vertical(&self, ctx: &mut RenderContext) {
490 let area = ctx.area;
491 let height = self.height.min(area.height);
492 let filled = (self.value * height as f64).round() as u16;
493 let color = self.current_color();
494
495 for y in 0..height {
496 let from_bottom = height - 1 - y;
497 let ch = if from_bottom < filled { '█' } else { '░' };
498 let fg = if from_bottom < filled {
499 color
500 } else {
501 self.empty_color
502 };
503 let mut cell = Cell::new(ch);
504 cell.fg = Some(fg);
505 ctx.buffer.set(area.x, area.y + y, cell);
506 }
507 }
508
509 fn render_segments(&self, ctx: &mut RenderContext) {
511 let area = ctx.area;
512 let segments = self.segments.min(area.width / 2);
513 let filled = (self.value * segments as f64).round() as u16;
514 let color = self.current_color();
515
516 for i in 0..segments {
517 let ch = if i < filled { '▰' } else { '▱' };
518 let fg = if i < filled { color } else { self.empty_color };
519 let mut cell = Cell::new(ch);
520 cell.fg = Some(fg);
521 ctx.buffer.set(area.x + i * 2, area.y, cell);
522 }
523 }
524
525 fn render_dots(&self, ctx: &mut RenderContext) {
527 let area = ctx.area;
528 let dots = self.segments.min(area.width);
529 let filled = (self.value * dots as f64).round() as u16;
530 let color = self.current_color();
531
532 for i in 0..dots {
533 let ch = if i < filled { '●' } else { '○' };
534 let fg = if i < filled { color } else { self.empty_color };
535 let mut cell = Cell::new(ch);
536 cell.fg = Some(fg);
537 ctx.buffer.set(area.x + i, area.y, cell);
538 }
539 }
540}
541
542impl Default for Gauge {
543 fn default() -> Self {
544 Self::new()
545 }
546}
547
548impl View for Gauge {
549 crate::impl_view_meta!("Gauge");
550
551 fn render(&self, ctx: &mut RenderContext) {
552 let area = ctx.area;
553 if area.width == 0 || area.height == 0 {
554 return;
555 }
556
557 let mut y_offset = 0u16;
559 if let Some(ref title) = self.title {
560 for (i, ch) in title.chars().enumerate() {
561 if i as u16 >= area.width {
562 break;
563 }
564 let mut cell = Cell::new(ch);
565 cell.fg = Some(Color::WHITE);
566 cell.modifier |= Modifier::BOLD;
567 ctx.buffer.set(area.x + i as u16, area.y, cell);
568 }
569 y_offset = 1;
570 }
571
572 let adjusted_area = crate::layout::Rect::new(
573 area.x,
574 area.y + y_offset,
575 area.width,
576 area.height.saturating_sub(y_offset),
577 );
578
579 let mut adjusted_ctx = RenderContext::new(ctx.buffer, adjusted_area);
580
581 match self.style {
582 GaugeStyle::Bar => self.render_bar(&mut adjusted_ctx),
583 GaugeStyle::Battery => self.render_battery(&mut adjusted_ctx),
584 GaugeStyle::Thermometer => self.render_thermometer(&mut adjusted_ctx),
585 GaugeStyle::Arc => self.render_arc(&mut adjusted_ctx),
586 GaugeStyle::Circle => self.render_circle(&mut adjusted_ctx),
587 GaugeStyle::Vertical => self.render_vertical(&mut adjusted_ctx),
588 GaugeStyle::Segments => self.render_segments(&mut adjusted_ctx),
589 GaugeStyle::Dots => self.render_dots(&mut adjusted_ctx),
590 }
591 }
592}
593
594impl_styled_view!(Gauge);
595impl_props_builders!(Gauge);
596
597pub fn gauge() -> Gauge {
599 Gauge::new()
600}
601
602pub fn percentage(value: f64) -> Gauge {
604 Gauge::new().percent(value)
605}
606
607pub fn battery(level: f64) -> Gauge {
609 Gauge::new()
610 .percent(level)
611 .style(GaugeStyle::Battery)
612 .thresholds(0.5, 0.2)
613}
614
615#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn test_gauge_new() {
624 let g = Gauge::new();
625 assert_eq!(g.value, 0.0);
626 }
627
628 #[test]
629 fn test_gauge_value() {
630 let g = Gauge::new().value(0.5);
631 assert_eq!(g.value, 0.5);
632 }
633
634 #[test]
635 fn test_gauge_percent() {
636 let g = Gauge::new().percent(75.0);
637 assert_eq!(g.value, 0.75);
638 }
639
640 #[test]
641 fn test_gauge_value_clamp() {
642 let g1 = Gauge::new().value(1.5);
643 assert_eq!(g1.value, 1.0);
644
645 let g2 = Gauge::new().value(-0.5);
646 assert_eq!(g2.value, 0.0);
647 }
648
649 #[test]
650 fn test_gauge_value_range() {
651 let g = Gauge::new().value_range(50.0, 0.0, 100.0);
652 assert_eq!(g.value, 0.5);
653 }
654
655 #[test]
656 fn test_gauge_style() {
657 let g = Gauge::new().style(GaugeStyle::Battery);
658 assert!(matches!(g.style, GaugeStyle::Battery));
659 }
660
661 #[test]
662 fn test_gauge_thresholds() {
663 let g = Gauge::new().thresholds(0.7, 0.9).value(0.95);
664
665 assert_eq!(g.current_color(), g.critical_color);
666 }
667
668 #[test]
669 fn test_gauge_warning_color() {
670 let g = Gauge::new().thresholds(0.7, 0.9).value(0.75);
671
672 assert_eq!(g.current_color(), g.warning_color);
673 }
674
675 #[test]
676 fn test_gauge_normal_color() {
677 let g = Gauge::new().thresholds(0.7, 0.9).value(0.5);
678
679 assert_eq!(g.current_color(), g.fill_color);
680 }
681
682 #[test]
683 fn test_gauge_get_label() {
684 let g = Gauge::new().percent(50.0);
685 assert_eq!(g.get_label(), "50%");
686 }
687
688 #[test]
689 fn test_gauge_custom_label() {
690 let g = Gauge::new().label("Custom");
691 assert_eq!(g.get_label(), "Custom");
692 }
693
694 #[test]
695 fn test_gauge_helper_value() {
696 let g = gauge().percent(50.0);
697 assert_eq!(g.value, 0.5);
698 }
699
700 #[test]
701 fn test_percentage_helper_value() {
702 let g = percentage(75.0);
703 assert_eq!(g.value, 0.75);
704 }
705
706 #[test]
707 fn test_battery_helper_fields() {
708 let g = battery(80.0);
709 assert!(matches!(g.style, GaugeStyle::Battery));
710 assert_eq!(g.value, 0.8);
711 }
712
713 #[test]
714 fn test_value_range_validation() {
715 let g = Gauge::new().value_range(50.0, 0.0, 100.0);
717 assert_eq!(g.value, 0.5);
718
719 let g = Gauge::new().value_range(50.0, 100.0, 0.0);
721 assert_eq!(g.min, 0.0);
722 assert_eq!(g.max, 100.0);
723 assert_eq!(g.value, 0.5);
724
725 let g = Gauge::new().value_range(50.0, 50.0, 50.0);
727 assert_eq!(g.value, 0.0);
728 }
729
730 #[test]
731 fn test_thresholds_validation() {
732 let g = Gauge::new().thresholds(0.5, 0.8);
734 assert_eq!(g.warning_threshold, Some(0.5));
735 assert_eq!(g.critical_threshold, Some(0.8));
736
737 let g = Gauge::new().thresholds(0.8, 0.5);
739 assert_eq!(g.warning_threshold, Some(0.5));
740 assert_eq!(g.critical_threshold, Some(0.8));
741 }
742}