1#![forbid(unsafe_code)]
2
3use crate::{StatefulWidget, Widget, draw_text_span};
25use ftui_core::geometry::Rect;
26use ftui_render::frame::Frame;
27use ftui_style::Style;
28
29pub use crate::stopwatch::StopwatchFormat as TimerFormat;
31
32#[derive(Debug, Clone)]
34pub struct TimerState {
35 duration: std::time::Duration,
36 remaining: std::time::Duration,
37 running: bool,
38}
39
40impl TimerState {
41 pub fn new(duration: std::time::Duration) -> Self {
43 Self {
44 duration,
45 remaining: duration,
46 running: false,
47 }
48 }
49
50 pub fn duration(&self) -> std::time::Duration {
52 self.duration
53 }
54
55 pub fn remaining(&self) -> std::time::Duration {
57 self.remaining
58 }
59
60 pub fn running(&self) -> bool {
62 self.running && !self.finished()
63 }
64
65 pub fn finished(&self) -> bool {
67 self.remaining.is_zero()
68 }
69
70 pub fn start(&mut self) {
72 self.running = true;
73 }
74
75 pub fn stop(&mut self) {
77 self.running = false;
78 }
79
80 pub fn toggle(&mut self) {
82 self.running = !self.running;
83 }
84
85 pub fn reset(&mut self) {
87 self.remaining = self.duration;
88 }
89
90 pub fn set_duration(&mut self, duration: std::time::Duration) {
92 self.duration = duration;
93 self.remaining = duration;
94 }
95
96 pub fn tick(&mut self, delta: std::time::Duration) -> bool {
99 if self.running && !self.finished() {
100 self.remaining = self.remaining.saturating_sub(delta);
101 true
102 } else {
103 false
104 }
105 }
106}
107
108#[derive(Debug, Clone, Default)]
110pub struct Timer<'a> {
111 format: TimerFormat,
112 style: Style,
113 running_style: Option<Style>,
114 finished_style: Option<Style>,
115 label: Option<&'a str>,
116}
117
118impl<'a> Timer<'a> {
119 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn format(mut self, format: TimerFormat) -> Self {
126 self.format = format;
127 self
128 }
129
130 pub fn style(mut self, style: Style) -> Self {
132 self.style = style;
133 self
134 }
135
136 pub fn running_style(mut self, style: Style) -> Self {
138 self.running_style = Some(style);
139 self
140 }
141
142 pub fn finished_style(mut self, style: Style) -> Self {
144 self.finished_style = Some(style);
145 self
146 }
147
148 pub fn label(mut self, label: &'a str) -> Self {
150 self.label = Some(label);
151 self
152 }
153}
154
155impl StatefulWidget for Timer<'_> {
156 type State = TimerState;
157
158 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
159 if area.is_empty() || area.height == 0 {
160 return;
161 }
162
163 let deg = frame.buffer.degradation;
164 if !deg.render_content() {
165 return;
166 }
167
168 let style = if deg.apply_styling() {
169 if state.finished() {
170 self.finished_style.unwrap_or(self.style)
171 } else if state.running() {
172 self.running_style.unwrap_or(self.style)
173 } else {
174 self.style
175 }
176 } else {
177 Style::default()
178 };
179
180 let formatted = crate::stopwatch::format_duration(state.remaining, self.format);
181 let mut x = area.x;
182
183 if let Some(label) = self.label {
184 x = draw_text_span(frame, x, area.y, label, style, area.right());
185 if x < area.right() {
186 x = draw_text_span(frame, x, area.y, " ", style, area.right());
187 }
188 }
189
190 draw_text_span(frame, x, area.y, &formatted, style, area.right());
191 }
192}
193
194impl Widget for Timer<'_> {
195 fn render(&self, area: Rect, frame: &mut Frame) {
196 let mut state = TimerState::new(std::time::Duration::ZERO);
197 StatefulWidget::render(self, area, frame, &mut state);
198 }
199
200 fn is_essential(&self) -> bool {
201 true
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use ftui_render::buffer::Buffer;
209 use ftui_render::grapheme_pool::GraphemePool;
210 use std::time::Duration;
211
212 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
213 buf.get(x, y).and_then(|c| c.content.as_char())
214 }
215
216 fn render_to_string(widget: &Timer, state: &mut TimerState, width: u16) -> String {
217 let mut pool = GraphemePool::new();
218 let mut frame = Frame::new(width, 1, &mut pool);
219 let area = Rect::new(0, 0, width, 1);
220 StatefulWidget::render(widget, area, &mut frame, state);
221 (0..width)
222 .filter_map(|x| cell_char(&frame.buffer, x, 0))
223 .collect::<String>()
224 .trim_end()
225 .to_string()
226 }
227
228 #[test]
231 fn state_new() {
232 let state = TimerState::new(Duration::from_secs(60));
233 assert_eq!(state.duration(), Duration::from_secs(60));
234 assert_eq!(state.remaining(), Duration::from_secs(60));
235 assert!(!state.running());
236 assert!(!state.finished());
237 }
238
239 #[test]
240 fn state_start_stop() {
241 let mut state = TimerState::new(Duration::from_secs(10));
242 state.start();
243 assert!(state.running());
244 state.stop();
245 assert!(!state.running());
246 }
247
248 #[test]
249 fn state_toggle() {
250 let mut state = TimerState::new(Duration::from_secs(10));
251 state.toggle();
252 assert!(state.running());
253 state.toggle();
254 assert!(!state.running());
255 }
256
257 #[test]
258 fn state_tick_counts_down() {
259 let mut state = TimerState::new(Duration::from_secs(10));
260 state.start();
261 assert!(state.tick(Duration::from_secs(3)));
262 assert_eq!(state.remaining(), Duration::from_secs(7));
263 }
264
265 #[test]
266 fn state_tick_when_stopped_is_noop() {
267 let mut state = TimerState::new(Duration::from_secs(10));
268 assert!(!state.tick(Duration::from_secs(1)));
269 assert_eq!(state.remaining(), Duration::from_secs(10));
270 }
271
272 #[test]
273 fn state_tick_saturates_at_zero() {
274 let mut state = TimerState::new(Duration::from_secs(2));
275 state.start();
276 state.tick(Duration::from_secs(5));
277 assert_eq!(state.remaining(), Duration::ZERO);
278 assert!(state.finished());
279 }
280
281 #[test]
282 fn state_finished_stops_running() {
283 let mut state = TimerState::new(Duration::from_secs(1));
284 state.start();
285 state.tick(Duration::from_secs(1));
286 assert!(state.finished());
287 assert!(!state.running()); }
289
290 #[test]
291 fn state_tick_after_finished_is_noop() {
292 let mut state = TimerState::new(Duration::from_secs(1));
293 state.start();
294 state.tick(Duration::from_secs(1));
295 assert!(!state.tick(Duration::from_secs(1)));
296 assert_eq!(state.remaining(), Duration::ZERO);
297 }
298
299 #[test]
300 fn state_reset() {
301 let mut state = TimerState::new(Duration::from_secs(60));
302 state.start();
303 state.tick(Duration::from_secs(30));
304 state.reset();
305 assert_eq!(state.remaining(), Duration::from_secs(60));
306 }
307
308 #[test]
309 fn state_set_duration() {
310 let mut state = TimerState::new(Duration::from_secs(60));
311 state.start();
312 state.tick(Duration::from_secs(10));
313 state.set_duration(Duration::from_secs(120));
314 assert_eq!(state.duration(), Duration::from_secs(120));
315 assert_eq!(state.remaining(), Duration::from_secs(120));
316 }
317
318 #[test]
319 fn state_zero_duration_is_finished() {
320 let state = TimerState::new(Duration::ZERO);
321 assert!(state.finished());
322 assert!(!state.running());
323 }
324
325 #[test]
328 fn render_zero_area() {
329 let widget = Timer::new();
330 let area = Rect::new(0, 0, 0, 0);
331 let mut pool = GraphemePool::new();
332 let mut frame = Frame::new(1, 1, &mut pool);
333 let mut state = TimerState::new(Duration::from_secs(60));
334 StatefulWidget::render(&widget, area, &mut frame, &mut state);
335 }
336
337 #[test]
338 fn render_remaining_human() {
339 let widget = Timer::new();
340 let mut state = TimerState::new(Duration::from_secs(125));
341 let text = render_to_string(&widget, &mut state, 20);
342 assert_eq!(text, "2m5s");
343 }
344
345 #[test]
346 fn render_digital_format() {
347 let widget = Timer::new().format(TimerFormat::Digital);
348 let mut state = TimerState::new(Duration::from_secs(3665));
349 let text = render_to_string(&widget, &mut state, 20);
350 assert_eq!(text, "01:01:05");
351 }
352
353 #[test]
354 fn render_seconds_format() {
355 let widget = Timer::new().format(TimerFormat::Seconds);
356 let mut state = TimerState::new(Duration::from_secs(90));
357 let text = render_to_string(&widget, &mut state, 20);
358 assert_eq!(text, "90s");
359 }
360
361 #[test]
362 fn render_with_label() {
363 let widget = Timer::new().label("Remaining:");
364 let mut state = TimerState::new(Duration::from_secs(45));
365 let text = render_to_string(&widget, &mut state, 30);
366 assert_eq!(text, "Remaining: 45s");
367 }
368
369 #[test]
370 fn render_finished_shows_zero() {
371 let widget = Timer::new();
372 let mut state = TimerState::new(Duration::from_secs(1));
373 state.start();
374 state.tick(Duration::from_secs(1));
375 let text = render_to_string(&widget, &mut state, 20);
376 assert_eq!(text, "0s");
377 }
378
379 #[test]
380 fn stateless_render_shows_zero() {
381 let widget = Timer::new();
382 let area = Rect::new(0, 0, 10, 1);
383 let mut pool = GraphemePool::new();
384 let mut frame = Frame::new(10, 1, &mut pool);
385 Widget::render(&widget, area, &mut frame);
386 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
387 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
388 }
389
390 #[test]
391 fn is_essential() {
392 let widget = Timer::new();
393 assert!(widget.is_essential());
394 }
395
396 #[test]
399 fn degradation_skeleton_skips() {
400 use ftui_render::budget::DegradationLevel;
401
402 let widget = Timer::new();
403 let area = Rect::new(0, 0, 20, 1);
404 let mut pool = GraphemePool::new();
405 let mut frame = Frame::new(20, 1, &mut pool);
406 frame.buffer.degradation = DegradationLevel::Skeleton;
407 let mut state = TimerState::new(Duration::from_secs(60));
408 StatefulWidget::render(&widget, area, &mut frame, &mut state);
409 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
410 }
411
412 #[test]
415 fn countdown_progression() {
416 let mut state = TimerState::new(Duration::from_secs(5));
417 state.start();
418
419 for expected in (0..=4).rev() {
420 state.tick(Duration::from_secs(1));
421 assert_eq!(state.remaining(), Duration::from_secs(expected));
422 }
423
424 assert!(state.finished());
425 }
426}