Skip to main content

blizz_ui/components/
timer_bar.rs

1use std::io::Write;
2
3use crossterm::{
4  cursor::MoveTo,
5  queue,
6  style::{Color, Print, ResetColor, SetForegroundColor},
7};
8
9use crate::layout::centered_column;
10use crate::prompt::text_entry;
11
12/// Single-column heavy horizontal segment (often called "heavy minus").
13const BAR_CHAR: char = '\u{2501}';
14
15/// Countdown strip aligned with prompt text-entry width (`text_entry(..).inner_width`).
16#[derive(Debug, Clone)]
17pub struct TimerBarComponent {
18  pub progress: f64,
19  pub visible: bool,
20}
21
22impl TimerBarComponent {
23  pub fn new() -> Self {
24    Self {
25      progress: 1.0,
26      visible: false,
27    }
28  }
29
30  pub fn hidden() -> Self {
31    Self::new()
32  }
33
34  /// Visible segment count when `progress` is in \[0, 1\] and terminal width `tw`.
35  pub fn segment_count(progress: f64, tw: u16) -> usize {
36    let inner = text_entry("", "", tw).inner_width;
37    visible_segments(inner, progress)
38  }
39
40  #[cfg(not(tarpaulin_include))]
41  pub fn render<W: Write>(&self, writer: &mut W, tw: u16, row: u16) -> std::io::Result<()> {
42    if !self.visible {
43      return Ok(());
44    }
45
46    let seg = Self::segment_count(self.progress, tw);
47    if seg == 0 {
48      return Ok(());
49    }
50
51    let col = centered_column(tw, seg as u16);
52    let body: String = BAR_CHAR.to_string().repeat(seg);
53    queue!(
54      writer,
55      MoveTo(col, row),
56      SetForegroundColor(Color::DarkGrey),
57      Print(body),
58      ResetColor
59    )
60  }
61}
62
63fn visible_segments(inner: u16, progress: f64) -> usize {
64  let p = progress.clamp(0.0, 1.0);
65  let n = ((inner as f64) * p).floor() as usize;
66  n.min(inner as usize)
67}
68
69impl Default for TimerBarComponent {
70  fn default() -> Self {
71    Self::new()
72  }
73}
74
75#[cfg(test)]
76mod tests {
77  use super::*;
78
79  #[test]
80  fn new_is_hidden_with_full_progress() {
81    let t = TimerBarComponent::new();
82    assert!(!t.visible);
83    assert_eq!(t.progress, 1.0);
84  }
85
86  #[test]
87  fn segment_count_scales_with_progress() {
88    let tw = 80_u16;
89    let inner = text_entry("", "", tw).inner_width as usize;
90    assert_eq!(TimerBarComponent::segment_count(0.0, tw), 0);
91    assert_eq!(TimerBarComponent::segment_count(1.0, tw), inner);
92    let half = TimerBarComponent::segment_count(0.5, tw);
93    assert!(half > 0 && half < inner);
94  }
95
96  #[test]
97  fn segment_count_clamps_progress() {
98    let tw = 100_u16;
99    let inner = text_entry("", "", tw).inner_width as usize;
100    assert_eq!(TimerBarComponent::segment_count(-1.0, tw), 0);
101    assert_eq!(TimerBarComponent::segment_count(2.0, tw), inner);
102  }
103
104  #[test]
105  fn render_skips_when_not_visible() {
106    let t = TimerBarComponent {
107      progress: 1.0,
108      visible: false,
109    };
110    let mut buf = Vec::new();
111    t.render(&mut buf, 80, 5).unwrap();
112    assert!(buf.is_empty());
113  }
114
115  #[test]
116  fn render_skips_when_zero_segments() {
117    let t = TimerBarComponent {
118      progress: 0.0,
119      visible: true,
120    };
121    let mut buf = Vec::new();
122    t.render(&mut buf, 80, 5).unwrap();
123    assert!(buf.is_empty());
124  }
125
126  #[test]
127  fn render_writes_bar_when_visible() {
128    let n = TimerBarComponent::segment_count(1.0, 80);
129    let t = TimerBarComponent {
130      progress: 1.0,
131      visible: true,
132    };
133    let mut buf = Vec::new();
134    t.render(&mut buf, 80, 3).unwrap();
135    let s = String::from_utf8(buf).unwrap();
136    assert_eq!(s.matches(BAR_CHAR).count(), n);
137  }
138}