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 #[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 if !deg.render_content() {
174 return;
175 }
176
177 let style = if deg.apply_styling() {
178 if state.finished() {
179 self.finished_style.unwrap_or(self.style)
180 } else if state.running() {
181 self.running_style.unwrap_or(self.style)
182 } else {
183 self.style
184 }
185 } else {
186 Style::default()
187 };
188
189 let formatted = crate::stopwatch::format_duration(state.remaining, self.format);
190 let mut x = area.x;
191
192 if let Some(label) = self.label {
193 x = draw_text_span(frame, x, area.y, label, style, area.right());
194 if x < area.right() {
195 x = draw_text_span(frame, x, area.y, " ", style, area.right());
196 }
197 }
198
199 draw_text_span(frame, x, area.y, &formatted, style, area.right());
200 }
201}
202
203impl Widget for Timer<'_> {
204 fn render(&self, area: Rect, frame: &mut Frame) {
205 let mut state = TimerState::new(std::time::Duration::ZERO);
206 StatefulWidget::render(self, area, frame, &mut state);
207 }
208
209 fn is_essential(&self) -> bool {
210 true
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use ftui_render::buffer::Buffer;
218 use ftui_render::grapheme_pool::GraphemePool;
219 use std::time::Duration;
220
221 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
222 buf.get(x, y).and_then(|c| c.content.as_char())
223 }
224
225 fn render_to_string(widget: &Timer, state: &mut TimerState, width: u16) -> String {
226 let mut pool = GraphemePool::new();
227 let mut frame = Frame::new(width, 1, &mut pool);
228 let area = Rect::new(0, 0, width, 1);
229 StatefulWidget::render(widget, area, &mut frame, state);
230 (0..width)
231 .filter_map(|x| cell_char(&frame.buffer, x, 0))
232 .collect::<String>()
233 .trim_end()
234 .to_string()
235 }
236
237 #[test]
240 fn state_new() {
241 let state = TimerState::new(Duration::from_secs(60));
242 assert_eq!(state.duration(), Duration::from_secs(60));
243 assert_eq!(state.remaining(), Duration::from_secs(60));
244 assert!(!state.running());
245 assert!(!state.finished());
246 }
247
248 #[test]
249 fn state_start_stop() {
250 let mut state = TimerState::new(Duration::from_secs(10));
251 state.start();
252 assert!(state.running());
253 state.stop();
254 assert!(!state.running());
255 }
256
257 #[test]
258 fn state_toggle() {
259 let mut state = TimerState::new(Duration::from_secs(10));
260 state.toggle();
261 assert!(state.running());
262 state.toggle();
263 assert!(!state.running());
264 }
265
266 #[test]
267 fn state_tick_counts_down() {
268 let mut state = TimerState::new(Duration::from_secs(10));
269 state.start();
270 assert!(state.tick(Duration::from_secs(3)));
271 assert_eq!(state.remaining(), Duration::from_secs(7));
272 }
273
274 #[test]
275 fn state_tick_when_stopped_is_noop() {
276 let mut state = TimerState::new(Duration::from_secs(10));
277 assert!(!state.tick(Duration::from_secs(1)));
278 assert_eq!(state.remaining(), Duration::from_secs(10));
279 }
280
281 #[test]
282 fn state_tick_saturates_at_zero() {
283 let mut state = TimerState::new(Duration::from_secs(2));
284 state.start();
285 state.tick(Duration::from_secs(5));
286 assert_eq!(state.remaining(), Duration::ZERO);
287 assert!(state.finished());
288 }
289
290 #[test]
291 fn state_finished_stops_running() {
292 let mut state = TimerState::new(Duration::from_secs(1));
293 state.start();
294 state.tick(Duration::from_secs(1));
295 assert!(state.finished());
296 assert!(!state.running()); }
298
299 #[test]
300 fn state_tick_after_finished_is_noop() {
301 let mut state = TimerState::new(Duration::from_secs(1));
302 state.start();
303 state.tick(Duration::from_secs(1));
304 assert!(!state.tick(Duration::from_secs(1)));
305 assert_eq!(state.remaining(), Duration::ZERO);
306 }
307
308 #[test]
309 fn state_reset() {
310 let mut state = TimerState::new(Duration::from_secs(60));
311 state.start();
312 state.tick(Duration::from_secs(30));
313 state.reset();
314 assert_eq!(state.remaining(), Duration::from_secs(60));
315 }
316
317 #[test]
318 fn state_set_duration() {
319 let mut state = TimerState::new(Duration::from_secs(60));
320 state.start();
321 state.tick(Duration::from_secs(10));
322 state.set_duration(Duration::from_secs(120));
323 assert_eq!(state.duration(), Duration::from_secs(120));
324 assert_eq!(state.remaining(), Duration::from_secs(120));
325 }
326
327 #[test]
328 fn state_zero_duration_is_finished() {
329 let state = TimerState::new(Duration::ZERO);
330 assert!(state.finished());
331 assert!(!state.running());
332 }
333
334 #[test]
337 fn render_zero_area() {
338 let widget = Timer::new();
339 let area = Rect::new(0, 0, 0, 0);
340 let mut pool = GraphemePool::new();
341 let mut frame = Frame::new(1, 1, &mut pool);
342 let mut state = TimerState::new(Duration::from_secs(60));
343 StatefulWidget::render(&widget, area, &mut frame, &mut state);
344 }
345
346 #[test]
347 fn render_remaining_human() {
348 let widget = Timer::new();
349 let mut state = TimerState::new(Duration::from_secs(125));
350 let text = render_to_string(&widget, &mut state, 20);
351 assert_eq!(text, "2m5s");
352 }
353
354 #[test]
355 fn render_digital_format() {
356 let widget = Timer::new().format(TimerFormat::Digital);
357 let mut state = TimerState::new(Duration::from_secs(3665));
358 let text = render_to_string(&widget, &mut state, 20);
359 assert_eq!(text, "01:01:05");
360 }
361
362 #[test]
363 fn render_seconds_format() {
364 let widget = Timer::new().format(TimerFormat::Seconds);
365 let mut state = TimerState::new(Duration::from_secs(90));
366 let text = render_to_string(&widget, &mut state, 20);
367 assert_eq!(text, "90s");
368 }
369
370 #[test]
371 fn render_with_label() {
372 let widget = Timer::new().label("Remaining:");
373 let mut state = TimerState::new(Duration::from_secs(45));
374 let text = render_to_string(&widget, &mut state, 30);
375 assert_eq!(text, "Remaining: 45s");
376 }
377
378 #[test]
379 fn render_finished_shows_zero() {
380 let widget = Timer::new();
381 let mut state = TimerState::new(Duration::from_secs(1));
382 state.start();
383 state.tick(Duration::from_secs(1));
384 let text = render_to_string(&widget, &mut state, 20);
385 assert_eq!(text, "0s");
386 }
387
388 #[test]
389 fn stateless_render_shows_zero() {
390 let widget = Timer::new();
391 let area = Rect::new(0, 0, 10, 1);
392 let mut pool = GraphemePool::new();
393 let mut frame = Frame::new(10, 1, &mut pool);
394 Widget::render(&widget, area, &mut frame);
395 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('0'));
396 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('s'));
397 }
398
399 #[test]
400 fn is_essential() {
401 let widget = Timer::new();
402 assert!(widget.is_essential());
403 }
404
405 #[test]
408 fn degradation_skeleton_skips() {
409 use ftui_render::budget::DegradationLevel;
410
411 let widget = Timer::new();
412 let area = Rect::new(0, 0, 20, 1);
413 let mut pool = GraphemePool::new();
414 let mut frame = Frame::new(20, 1, &mut pool);
415 frame.buffer.degradation = DegradationLevel::Skeleton;
416 let mut state = TimerState::new(Duration::from_secs(60));
417 StatefulWidget::render(&widget, area, &mut frame, &mut state);
418 assert!(frame.buffer.get(0, 0).unwrap().is_empty());
419 }
420
421 #[test]
424 fn countdown_progression() {
425 let mut state = TimerState::new(Duration::from_secs(5));
426 state.start();
427
428 for expected in (0..=4).rev() {
429 state.tick(Duration::from_secs(1));
430 assert_eq!(state.remaining(), Duration::from_secs(expected));
431 }
432
433 assert!(state.finished());
434 }
435}