1#![forbid(unsafe_code)]
2
3use crate::{StatefulWidget, Widget, clear_text_row, 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 #[inline]
52 pub fn duration(&self) -> std::time::Duration {
53 self.duration
54 }
55
56 #[inline]
58 pub fn remaining(&self) -> std::time::Duration {
59 self.remaining
60 }
61
62 #[inline]
64 pub fn running(&self) -> bool {
65 self.running && !self.finished()
66 }
67
68 #[inline]
70 pub fn finished(&self) -> bool {
71 self.remaining.is_zero()
72 }
73
74 pub fn start(&mut self) {
76 self.running = true;
77 }
78
79 pub fn stop(&mut self) {
81 self.running = false;
82 }
83
84 pub fn toggle(&mut self) {
86 self.running = !self.running;
87 }
88
89 pub fn reset(&mut self) {
91 self.remaining = self.duration;
92 }
93
94 pub fn set_duration(&mut self, duration: std::time::Duration) {
96 self.duration = duration;
97 self.remaining = duration;
98 }
99
100 pub fn tick(&mut self, delta: std::time::Duration) -> bool {
103 if self.running && !self.finished() {
104 self.remaining = self.remaining.saturating_sub(delta);
105 true
106 } else {
107 false
108 }
109 }
110}
111
112#[derive(Debug, Clone, Default)]
114pub struct Timer<'a> {
115 format: TimerFormat,
116 style: Style,
117 running_style: Option<Style>,
118 finished_style: Option<Style>,
119 label: Option<&'a str>,
120}
121
122impl<'a> Timer<'a> {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 #[must_use]
130 pub fn format(mut self, format: TimerFormat) -> Self {
131 self.format = format;
132 self
133 }
134
135 #[must_use]
137 pub fn style(mut self, style: Style) -> Self {
138 self.style = style;
139 self
140 }
141
142 #[must_use]
144 pub fn running_style(mut self, style: Style) -> Self {
145 self.running_style = Some(style);
146 self
147 }
148
149 #[must_use]
151 pub fn finished_style(mut self, style: Style) -> Self {
152 self.finished_style = Some(style);
153 self
154 }
155
156 #[must_use]
158 pub fn label(mut self, label: &'a str) -> Self {
159 self.label = Some(label);
160 self
161 }
162}
163
164impl StatefulWidget for Timer<'_> {
165 type State = TimerState;
166
167 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
168 if area.is_empty() || area.height == 0 {
169 return;
170 }
171
172 let deg = frame.buffer.degradation;
173 let style = if deg.apply_styling() {
177 if state.finished() {
178 self.finished_style.unwrap_or(self.style)
179 } else if state.running() {
180 self.running_style.unwrap_or(self.style)
181 } else {
182 self.style
183 }
184 } else {
185 Style::default()
186 };
187
188 clear_text_row(frame, area, style);
189
190 let formatted = crate::stopwatch::format_duration(state.remaining, self.format);
191 let mut x = area.x;
192
193 if let Some(label) = self.label {
194 x = draw_text_span(frame, x, area.y, label, style, area.right());
195 if x < area.right() {
196 x = draw_text_span(frame, x, area.y, " ", style, area.right());
197 }
198 }
199
200 draw_text_span(frame, x, area.y, &formatted, style, area.right());
201 }
202}
203
204impl Widget for Timer<'_> {
205 fn render(&self, area: Rect, frame: &mut Frame) {
206 let mut state = TimerState::new(std::time::Duration::ZERO);
207 StatefulWidget::render(self, area, frame, &mut state);
208 }
209
210 fn is_essential(&self) -> bool {
211 true
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use ftui_render::buffer::Buffer;
219 use ftui_render::cell::Cell;
220 use ftui_render::grapheme_pool::GraphemePool;
221 use std::time::Duration;
222
223 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
224 buf.get(x, y).and_then(|c| c.content.as_char())
225 }
226
227 fn render_to_string(widget: &Timer, state: &mut TimerState, width: u16) -> String {
228 let mut pool = GraphemePool::new();
229 let mut frame = Frame::new(width, 1, &mut pool);
230 let area = Rect::new(0, 0, width, 1);
231 StatefulWidget::render(widget, area, &mut frame, state);
232 (0..width)
233 .filter_map(|x| cell_char(&frame.buffer, x, 0))
234 .collect::<String>()
235 .trim_end()
236 .to_string()
237 }
238
239 #[test]
242 fn state_new() {
243 let state = TimerState::new(Duration::from_secs(60));
244 assert_eq!(state.duration(), Duration::from_secs(60));
245 assert_eq!(state.remaining(), Duration::from_secs(60));
246 assert!(!state.running());
247 assert!(!state.finished());
248 }
249
250 #[test]
251 fn state_start_stop() {
252 let mut state = TimerState::new(Duration::from_secs(10));
253 state.start();
254 assert!(state.running());
255 state.stop();
256 assert!(!state.running());
257 }
258
259 #[test]
260 fn state_toggle() {
261 let mut state = TimerState::new(Duration::from_secs(10));
262 state.toggle();
263 assert!(state.running());
264 state.toggle();
265 assert!(!state.running());
266 }
267
268 #[test]
269 fn state_tick_counts_down() {
270 let mut state = TimerState::new(Duration::from_secs(10));
271 state.start();
272 assert!(state.tick(Duration::from_secs(3)));
273 assert_eq!(state.remaining(), Duration::from_secs(7));
274 }
275
276 #[test]
277 fn state_tick_when_stopped_is_noop() {
278 let mut state = TimerState::new(Duration::from_secs(10));
279 assert!(!state.tick(Duration::from_secs(1)));
280 assert_eq!(state.remaining(), Duration::from_secs(10));
281 }
282
283 #[test]
284 fn state_tick_saturates_at_zero() {
285 let mut state = TimerState::new(Duration::from_secs(2));
286 state.start();
287 state.tick(Duration::from_secs(5));
288 assert_eq!(state.remaining(), Duration::ZERO);
289 assert!(state.finished());
290 }
291
292 #[test]
293 fn state_finished_stops_running() {
294 let mut state = TimerState::new(Duration::from_secs(1));
295 state.start();
296 state.tick(Duration::from_secs(1));
297 assert!(state.finished());
298 assert!(!state.running()); }
300
301 #[test]
302 fn state_tick_after_finished_is_noop() {
303 let mut state = TimerState::new(Duration::from_secs(1));
304 state.start();
305 state.tick(Duration::from_secs(1));
306 assert!(!state.tick(Duration::from_secs(1)));
307 assert_eq!(state.remaining(), Duration::ZERO);
308 }
309
310 #[test]
311 fn state_reset() {
312 let mut state = TimerState::new(Duration::from_secs(60));
313 state.start();
314 state.tick(Duration::from_secs(30));
315 state.reset();
316 assert_eq!(state.remaining(), Duration::from_secs(60));
317 }
318
319 #[test]
320 fn state_set_duration() {
321 let mut state = TimerState::new(Duration::from_secs(60));
322 state.start();
323 state.tick(Duration::from_secs(10));
324 state.set_duration(Duration::from_secs(120));
325 assert_eq!(state.duration(), Duration::from_secs(120));
326 assert_eq!(state.remaining(), Duration::from_secs(120));
327 }
328
329 #[test]
330 fn state_zero_duration_is_finished() {
331 let state = TimerState::new(Duration::ZERO);
332 assert!(state.finished());
333 assert!(!state.running());
334 }
335
336 #[test]
339 fn render_zero_area() {
340 let widget = Timer::new();
341 let area = Rect::new(0, 0, 0, 0);
342 let mut pool = GraphemePool::new();
343 let mut frame = Frame::new(1, 1, &mut pool);
344 let mut state = TimerState::new(Duration::from_secs(60));
345 StatefulWidget::render(&widget, area, &mut frame, &mut state);
346 }
347
348 #[test]
349 fn render_remaining_human() {
350 let widget = Timer::new();
351 let mut state = TimerState::new(Duration::from_secs(125));
352 let text = render_to_string(&widget, &mut state, 20);
353 assert_eq!(text, "2m5s");
354 }
355
356 #[test]
357 fn render_digital_format() {
358 let widget = Timer::new().format(TimerFormat::Digital);
359 let mut state = TimerState::new(Duration::from_secs(3665));
360 let text = render_to_string(&widget, &mut state, 20);
361 assert_eq!(text, "01:01:05");
362 }
363
364 #[test]
365 fn render_seconds_format() {
366 let widget = Timer::new().format(TimerFormat::Seconds);
367 let mut state = TimerState::new(Duration::from_secs(90));
368 let text = render_to_string(&widget, &mut state, 20);
369 assert_eq!(text, "90s");
370 }
371
372 #[test]
373 fn render_with_label() {
374 let widget = Timer::new().label("Remaining:");
375 let mut state = TimerState::new(Duration::from_secs(45));
376 let text = render_to_string(&widget, &mut state, 30);
377 assert_eq!(text, "Remaining: 45s");
378 }
379
380 #[test]
381 fn render_finished_shows_zero() {
382 let widget = Timer::new();
383 let mut state = TimerState::new(Duration::from_secs(1));
384 state.start();
385 state.tick(Duration::from_secs(1));
386 let text = render_to_string(&widget, &mut state, 20);
387 assert_eq!(text, "0s");
388 }
389
390 #[test]
391 fn stateless_render_shows_zero() {
392 let widget = Timer::new();
393 let area = Rect::new(0, 0, 10, 1);
394 let mut pool = GraphemePool::new();
395 let mut frame = Frame::new(10, 1, &mut pool);
396 Widget::render(&widget, area, &mut frame);
397 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
398 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
399 }
400
401 #[test]
402 fn render_clears_stale_suffix_cells() {
403 let widget = Timer::new().format(TimerFormat::Seconds);
404 let area = Rect::new(0, 0, 6, 1);
405 let mut pool = GraphemePool::new();
406 let mut frame = Frame::new(6, 1, &mut pool);
407 frame.buffer.set_fast(4, 0, Cell::from_char('X'));
408 let mut state = TimerState::new(Duration::from_secs(90));
409
410 StatefulWidget::render(&widget, area, &mut frame, &mut state);
411
412 assert_eq!(cell_char(&frame.buffer, 4, 0), Some(' '));
413 }
414
415 #[test]
416 fn is_essential() {
417 let widget = Timer::new();
418 assert!(widget.is_essential());
419 }
420
421 #[test]
424 fn degradation_skeleton_renders_essential_text() {
425 use ftui_render::budget::DegradationLevel;
426
427 let widget = Timer::new();
428 let area = Rect::new(0, 0, 20, 1);
429 let mut pool = GraphemePool::new();
430 let mut frame = Frame::new(20, 1, &mut pool);
431 frame.buffer.degradation = DegradationLevel::Skeleton;
432 let mut state = TimerState::new(Duration::from_secs(60));
433 StatefulWidget::render(&widget, area, &mut frame, &mut state);
434 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('1'));
435 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('m'));
436 }
437
438 #[test]
439 fn skeleton_empty_timer_clears_stale_row() {
440 use ftui_render::budget::DegradationLevel;
441
442 let widget = Timer::new().format(TimerFormat::Seconds);
443 let area = Rect::new(0, 0, 6, 1);
444 let mut pool = GraphemePool::new();
445 let mut frame = Frame::new(6, 1, &mut pool);
446 let mut populated = TimerState::new(Duration::from_secs(90));
447 let mut empty = TimerState::new(Duration::ZERO);
448
449 StatefulWidget::render(&widget, area, &mut frame, &mut populated);
450 frame.buffer.degradation = DegradationLevel::Skeleton;
451 StatefulWidget::render(&widget, area, &mut frame, &mut empty);
452
453 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
454 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
455 assert_eq!(cell_char(&frame.buffer, 2, 0), Some(' '));
456 }
457
458 #[test]
461 fn countdown_progression() {
462 let mut state = TimerState::new(Duration::from_secs(5));
463 state.start();
464
465 for expected in (0..=4).rev() {
466 state.tick(Duration::from_secs(1));
467 assert_eq!(state.remaining(), Duration::from_secs(expected));
468 }
469
470 assert!(state.finished());
471 }
472}