Skip to main content

liora_components/
timer.rs

1use gpui::{
2    App, Component, ElementId, Global, IntoElement, RenderOnce, SharedString, Window, div,
3    prelude::*, px,
4};
5use liora_core::Config;
6use std::{
7    collections::{HashMap, HashSet},
8    sync::{Arc, Mutex, MutexGuard},
9    time::{Duration, Instant},
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum TimerDirection {
14    #[default]
15    CountUp,
16    CountDown,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum TimerUnit {
21    Milliseconds,
22    #[default]
23    Seconds,
24    Minutes,
25    Hours,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TimerFormat {
30    Unit,
31    Clock,
32}
33
34impl Default for TimerFormat {
35    fn default() -> Self {
36        Self::Unit
37    }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct TimerSnapshot {
42    pub elapsed: Duration,
43    pub remaining: Option<Duration>,
44    pub finished: bool,
45}
46
47impl TimerSnapshot {
48    pub fn elapsed_as(self, unit: TimerUnit) -> f64 {
49        duration_as(self.elapsed, unit)
50    }
51
52    pub fn remaining_as(self, unit: TimerUnit) -> Option<f64> {
53        self.remaining.map(|remaining| duration_as(remaining, unit))
54    }
55}
56
57#[derive(Clone)]
58pub struct Timer {
59    id: SharedString,
60    elapsed: Duration,
61    duration: Option<Duration>,
62    direction: TimerDirection,
63    display_unit: TimerUnit,
64    format: TimerFormat,
65    show_unit: bool,
66    title: Option<SharedString>,
67    prefix: Option<SharedString>,
68    suffix: Option<SharedString>,
69    compact: bool,
70    running: bool,
71    started_at: Option<Instant>,
72    tick_interval: Duration,
73}
74
75impl Timer {
76    pub fn count_up(elapsed: Duration) -> Self {
77        Self::new(TimerDirection::CountUp, elapsed, None)
78    }
79
80    pub fn count_down(duration: Duration, elapsed: Duration) -> Self {
81        Self::new(TimerDirection::CountDown, elapsed, Some(duration))
82    }
83
84    pub fn new(direction: TimerDirection, elapsed: Duration, duration: Option<Duration>) -> Self {
85        Self {
86            id: liora_core::unique_id("timer"),
87            elapsed,
88            duration,
89            direction,
90            display_unit: TimerUnit::Seconds,
91            format: TimerFormat::Unit,
92            show_unit: true,
93            title: None,
94            prefix: None,
95            suffix: None,
96            compact: false,
97            running: false,
98            started_at: None,
99            tick_interval: Duration::from_millis(250),
100        }
101    }
102
103    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
104        self.id = id.into();
105        self
106    }
107
108    pub fn elapsed(mut self, elapsed: Duration) -> Self {
109        self.elapsed = elapsed;
110        self
111    }
112
113    pub fn duration(mut self, duration: Duration) -> Self {
114        self.duration = Some(duration);
115        self
116    }
117
118    pub fn direction(mut self, direction: TimerDirection) -> Self {
119        self.direction = direction;
120        self
121    }
122
123    pub fn countup(mut self) -> Self {
124        self.direction = TimerDirection::CountUp;
125        self
126    }
127
128    pub fn countdown(mut self) -> Self {
129        self.direction = TimerDirection::CountDown;
130        self
131    }
132
133    pub fn display_unit(mut self, unit: TimerUnit) -> Self {
134        self.display_unit = unit;
135        self.format = TimerFormat::Unit;
136        self
137    }
138
139    pub fn format(mut self, format: TimerFormat) -> Self {
140        self.format = format;
141        self
142    }
143
144    pub fn clock_format(mut self) -> Self {
145        self.format = TimerFormat::Clock;
146        self
147    }
148
149    pub fn show_unit(mut self, show: bool) -> Self {
150        self.show_unit = show;
151        self
152    }
153
154    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
155        self.title = Some(title.into());
156        self
157    }
158
159    pub fn prefix(mut self, prefix: impl Into<SharedString>) -> Self {
160        self.prefix = Some(prefix.into());
161        self
162    }
163
164    pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
165        self.suffix = Some(suffix.into());
166        self
167    }
168
169    pub fn compact(mut self) -> Self {
170        self.compact = true;
171        self
172    }
173
174    pub fn running(mut self, running: bool) -> Self {
175        self.running = running;
176        if running && self.started_at.is_none() {
177            self.started_at = Some(Instant::now());
178        }
179        self
180    }
181
182    pub fn start(self) -> Self {
183        self.running(true)
184    }
185
186    pub fn paused(self) -> Self {
187        self.running(false)
188    }
189
190    pub fn tick_interval(mut self, interval: Duration) -> Self {
191        self.tick_interval = interval.max(Duration::from_millis(16));
192        self
193    }
194
195    fn effective_elapsed(&self) -> Duration {
196        if self.running {
197            self.started_at
198                .map(|started_at| self.elapsed.saturating_add(started_at.elapsed()))
199                .unwrap_or(self.elapsed)
200        } else {
201            self.elapsed
202        }
203    }
204
205    pub fn snapshot(&self) -> TimerSnapshot {
206        let remaining = self
207            .duration
208            .map(|duration| duration.saturating_sub(self.effective_elapsed()));
209        TimerSnapshot {
210            elapsed: self.effective_elapsed(),
211            remaining,
212            finished: matches!(self.direction, TimerDirection::CountDown)
213                && remaining.is_some_and(|remaining| remaining.is_zero()),
214        }
215    }
216
217    pub fn elapsed_as(&self, unit: TimerUnit) -> f64 {
218        self.snapshot().elapsed_as(unit)
219    }
220
221    pub fn remaining_as(&self, unit: TimerUnit) -> Option<f64> {
222        self.snapshot().remaining_as(unit)
223    }
224
225    fn display_duration(&self) -> Duration {
226        match self.direction {
227            TimerDirection::CountUp => self.effective_elapsed(),
228            TimerDirection::CountDown => self
229                .duration
230                .map(|duration| duration.saturating_sub(self.effective_elapsed()))
231                .unwrap_or_default(),
232        }
233    }
234
235    fn format_value(&self) -> SharedString {
236        match self.format {
237            TimerFormat::Unit => {
238                format_duration(self.display_duration(), self.display_unit, self.show_unit)
239            }
240            TimerFormat::Clock => format_clock(self.display_duration()),
241        }
242    }
243}
244
245impl RenderOnce for Timer {
246    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
247        let theme = cx.global::<Config>().theme.clone();
248        let mut timer = self;
249        if timer.running {
250            ensure_timer_runtime(cx);
251            if let Some(runtime) = cx.try_global::<TimerRuntime>() {
252                timer.started_at = Some(runtime.started_at(timer.id.clone()));
253            }
254        }
255        if timer.running && !timer.snapshot().finished {
256            if let Some(runtime) = cx.try_global::<TimerRuntime>() {
257                runtime.register(window.window_handle(), timer.tick_interval);
258            }
259        }
260        let value = timer.format_value();
261        div()
262            .id(ElementId::from(timer.id))
263            .flex()
264            .flex_col()
265            .gap_1()
266            .when(!timer.compact, |s| {
267                s.p_3()
268                    .rounded_md()
269                    .border_1()
270                    .border_color(theme.neutral.border)
271                    .bg(theme.neutral.card)
272            })
273            .when_some(timer.title, |s, title| {
274                s.child(
275                    div()
276                        .text_xs()
277                        .text_color(theme.neutral.text_3)
278                        .child(title),
279                )
280            })
281            .child(
282                div()
283                    .flex()
284                    .items_baseline()
285                    .gap_1()
286                    .text_color(theme.neutral.text_1)
287                    .when_some(timer.prefix, |s, prefix| {
288                        s.child(
289                            div()
290                                .text_sm()
291                                .text_color(theme.neutral.text_3)
292                                .child(prefix),
293                        )
294                    })
295                    .child(
296                        div()
297                            .text_size(px(24.0))
298                            .font_weight(gpui::FontWeight::BOLD)
299                            .child(value),
300                    )
301                    .when_some(timer.suffix, |s, suffix| {
302                        s.child(
303                            div()
304                                .text_sm()
305                                .text_color(theme.neutral.text_3)
306                                .child(suffix),
307                        )
308                    }),
309            )
310    }
311}
312
313#[derive(Clone)]
314struct TimerRuntime {
315    windows: Arc<Mutex<HashSet<gpui::AnyWindowHandle>>>,
316    starts: Arc<Mutex<HashMap<SharedString, Instant>>>,
317}
318
319impl Global for TimerRuntime {}
320
321impl TimerRuntime {
322    fn new(cx: &mut App) -> Self {
323        let windows = Arc::new(Mutex::new(HashSet::new()));
324        let runtime = Self {
325            windows: windows.clone(),
326            starts: Arc::new(Mutex::new(HashMap::new())),
327        };
328        let executor = cx.background_executor().clone();
329        cx.spawn(async move |cx: &mut gpui::AsyncApp| {
330            loop {
331                executor.timer(Duration::from_millis(250)).await;
332                let handles = lock_timer_windows(&windows)
333                    .iter()
334                    .copied()
335                    .collect::<Vec<_>>();
336                for handle in handles {
337                    let _ = handle.update(cx, |_, window, _| window.refresh());
338                }
339            }
340        })
341        .detach();
342        runtime
343    }
344
345    fn started_at(&self, id: SharedString) -> Instant {
346        let mut starts = lock_timer_starts(&self.starts);
347        *starts.entry(id).or_insert_with(Instant::now)
348    }
349
350    fn register(&self, window: gpui::AnyWindowHandle, _interval: Duration) {
351        lock_timer_windows(&self.windows).insert(window);
352    }
353}
354
355fn lock_timer_windows(
356    windows: &Arc<Mutex<HashSet<gpui::AnyWindowHandle>>>,
357) -> MutexGuard<'_, HashSet<gpui::AnyWindowHandle>> {
358    windows
359        .lock()
360        .unwrap_or_else(|poisoned| poisoned.into_inner())
361}
362
363fn lock_timer_starts(
364    starts: &Arc<Mutex<HashMap<SharedString, Instant>>>,
365) -> MutexGuard<'_, HashMap<SharedString, Instant>> {
366    starts
367        .lock()
368        .unwrap_or_else(|poisoned| poisoned.into_inner())
369}
370
371fn ensure_timer_runtime(cx: &mut App) {
372    if !cx.has_global::<TimerRuntime>() {
373        let runtime = TimerRuntime::new(cx);
374        cx.set_global(runtime);
375    }
376}
377
378impl IntoElement for Timer {
379    type Element = Component<Self>;
380
381    fn into_element(self) -> Self::Element {
382        Component::new(self)
383    }
384}
385
386pub fn duration_as(duration: Duration, unit: TimerUnit) -> f64 {
387    match unit {
388        TimerUnit::Milliseconds => duration.as_secs_f64() * 1000.0,
389        TimerUnit::Seconds => duration.as_secs_f64(),
390        TimerUnit::Minutes => duration.as_secs_f64() / 60.0,
391        TimerUnit::Hours => duration.as_secs_f64() / 3600.0,
392    }
393}
394
395pub fn format_duration(duration: Duration, unit: TimerUnit, show_unit: bool) -> SharedString {
396    let value = duration_as(duration, unit);
397    let text = match unit {
398        TimerUnit::Milliseconds => format!("{value:.0}"),
399        TimerUnit::Seconds => format!("{value:.1}"),
400        TimerUnit::Minutes => format!("{value:.2}"),
401        TimerUnit::Hours => format!("{value:.2}"),
402    };
403    if show_unit {
404        format!("{} {}", text, unit_label(unit)).into()
405    } else {
406        text.into()
407    }
408}
409
410pub fn format_clock(duration: Duration) -> SharedString {
411    let total_seconds = duration.as_secs();
412    let hours = total_seconds / 3600;
413    let minutes = (total_seconds % 3600) / 60;
414    let seconds = total_seconds % 60;
415    format!("{hours:02}:{minutes:02}:{seconds:02}").into()
416}
417
418fn unit_label(unit: TimerUnit) -> &'static str {
419    match unit {
420        TimerUnit::Milliseconds => "ms",
421        TimerUnit::Seconds => "s",
422        TimerUnit::Minutes => "min",
423        TimerUnit::Hours => "h",
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn timer_snapshot_tracks_countdown_remaining() {
433        let timer = Timer::count_down(Duration::from_secs(10), Duration::from_secs(4));
434        let snapshot = timer.snapshot();
435        assert_eq!(snapshot.elapsed, Duration::from_secs(4));
436        assert_eq!(snapshot.remaining, Some(Duration::from_secs(6)));
437        assert!(!snapshot.finished);
438    }
439
440    #[test]
441    fn running_timer_includes_elapsed_since_start() {
442        let timer = Timer::count_up(Duration::from_secs(2)).start();
443        assert!(timer.effective_elapsed() >= Duration::from_secs(2));
444        assert!(timer.running);
445    }
446
447    #[test]
448    fn timer_countdown_saturates_at_zero() {
449        let timer = Timer::count_down(Duration::from_secs(10), Duration::from_secs(12));
450        let snapshot = timer.snapshot();
451        assert_eq!(snapshot.remaining, Some(Duration::ZERO));
452        assert!(snapshot.finished);
453    }
454
455    #[test]
456    fn timer_formats_units() {
457        assert_eq!(
458            format_duration(Duration::from_millis(1500), TimerUnit::Milliseconds, true),
459            SharedString::from("1500 ms")
460        );
461        assert_eq!(
462            format_duration(Duration::from_secs(90), TimerUnit::Minutes, true),
463            SharedString::from("1.50 min")
464        );
465        assert_eq!(
466            Timer::count_up(Duration::from_secs(7200)).elapsed_as(TimerUnit::Hours),
467            2.0
468        );
469    }
470
471    #[test]
472    fn timer_formats_clock() {
473        assert_eq!(
474            format_clock(Duration::from_secs(0)),
475            SharedString::from("00:00:00")
476        );
477        assert_eq!(
478            format_clock(Duration::from_secs(3661)),
479            SharedString::from("01:01:01")
480        );
481        assert_eq!(
482            Timer::count_up(Duration::from_secs(3661))
483                .clock_format()
484                .format_value(),
485            SharedString::from("01:01:01")
486        );
487    }
488}