Skip to main content

liora_components/
timer.rs

1//! Timer module.
2//!
3//! This public module implements the Liora timer/countdown display component. It keeps the reusable
4//! component logic inside `liora-components` rather than Gallery or Docs so
5//! downstream GPUI applications can compose the same behavior with their own
6//! app state, assets, and release policy.
7//!
8//! ## Usage model
9//!
10//! Components in this module render native GPUI element trees. Stateless builder
11//! values can be constructed inline, while controls with focus, selection,
12//! popup, drag, or editing state should be stored as `gpui::Entity<T>` fields in
13//! the parent view so state survives GPUI render passes.
14//!
15//! ## Design contract
16//!
17//! The implementation should use Liora theme tokens from `liora-core` and
18//! `liora-theme`, keep accessibility-oriented keyboard/pointer behavior close to
19//! the component, and avoid app-specific Gallery/Docs resources in this SDK
20//! crate.
21
22use gpui::{
23    App, Component, ElementId, Global, IntoElement, RenderOnce, SharedString, Window, div,
24    prelude::*, px,
25};
26use liora_core::Config;
27use std::{
28    collections::{HashMap, HashSet},
29    sync::{Arc, Mutex, MutexGuard},
30    time::{Duration, Instant},
31};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34/// Options that control timer direction behavior.
35pub enum TimerDirection {
36    #[default]
37    /// Measures elapsed time upward from the starting duration.
38    CountUp,
39    /// Counts down toward zero from the supplied duration.
40    CountDown,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44/// Options that control timer unit behavior.
45pub enum TimerUnit {
46    /// Uses the `Milliseconds` option for `TimerUnit`.
47    Milliseconds,
48    #[default]
49    /// Uses the `Seconds` option for `TimerUnit`.
50    Seconds,
51    /// Uses the `Minutes` option for `TimerUnit`.
52    Minutes,
53    /// Uses the `Hours` option for `TimerUnit`.
54    Hours,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58/// Options that control timer format behavior.
59pub enum TimerFormat {
60    /// Uses the `Unit` option for `TimerFormat`.
61    Unit,
62    /// Uses the `Clock` option for `TimerFormat`.
63    Clock,
64}
65
66impl Default for TimerFormat {
67    fn default() -> Self {
68        Self::Unit
69    }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73/// Fluent native GPUI component for rendering Liora timer snapshot.
74pub struct TimerSnapshot {
75    /// Elapsed duration tracked by a timer.
76    pub elapsed: Duration,
77    /// Remaining duration before a timer completes.
78    pub remaining: Option<Duration>,
79    /// Whether a countdown timer has reached completion.
80    pub finished: bool,
81}
82
83impl TimerSnapshot {
84    /// Performs the elapsed as operation used by this component.
85    pub fn elapsed_as(self, unit: TimerUnit) -> f64 {
86        duration_as(self.elapsed, unit)
87    }
88
89    /// Performs the remaining as operation used by this component.
90    pub fn remaining_as(self, unit: TimerUnit) -> Option<f64> {
91        self.remaining.map(|remaining| duration_as(remaining, unit))
92    }
93}
94
95#[derive(Clone)]
96/// Fluent native GPUI component for rendering Liora timer.
97pub struct Timer {
98    id: SharedString,
99    elapsed: Duration,
100    duration: Option<Duration>,
101    direction: TimerDirection,
102    display_unit: TimerUnit,
103    format: TimerFormat,
104    show_unit: bool,
105    title: Option<SharedString>,
106    prefix: Option<SharedString>,
107    suffix: Option<SharedString>,
108    compact: bool,
109    running: bool,
110    started_at: Option<Instant>,
111    tick_interval: Duration,
112}
113
114impl Timer {
115    /// Sets the count up value used by the component.
116    pub fn count_up(elapsed: Duration) -> Self {
117        Self::new(TimerDirection::CountUp, elapsed, None)
118    }
119
120    /// Sets the count down value used by the component.
121    pub fn count_down(duration: Duration, elapsed: Duration) -> Self {
122        Self::new(TimerDirection::CountDown, elapsed, Some(duration))
123    }
124
125    /// Creates `Timer` initialized from the supplied direction, elapsed, and duration.
126    pub fn new(direction: TimerDirection, elapsed: Duration, duration: Option<Duration>) -> Self {
127        Self {
128            id: liora_core::unique_id("timer"),
129            elapsed,
130            duration,
131            direction,
132            display_unit: TimerUnit::Seconds,
133            format: TimerFormat::Unit,
134            show_unit: true,
135            title: None,
136            prefix: None,
137            suffix: None,
138            compact: false,
139            running: false,
140            started_at: None,
141            tick_interval: Duration::from_millis(250),
142        }
143    }
144
145    /// Assigns a stable element id used by GPUI state, hit testing, and automated interaction tests.
146    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
147        self.id = id.into();
148        self
149    }
150
151    /// Sets the elapsed value used by the component.
152    pub fn elapsed(mut self, elapsed: Duration) -> Self {
153        self.elapsed = elapsed;
154        self
155    }
156
157    /// Sets the duration value used by the component.
158    pub fn duration(mut self, duration: Duration) -> Self {
159        self.duration = Some(duration);
160        self
161    }
162
163    /// Selects the layout or animation direction.
164    pub fn direction(mut self, direction: TimerDirection) -> Self {
165        self.direction = direction;
166        self
167    }
168
169    /// Sets the countup value used by the component.
170    pub fn countup(mut self) -> Self {
171        self.direction = TimerDirection::CountUp;
172        self
173    }
174
175    /// Sets the countdown value used by the component.
176    pub fn countdown(mut self) -> Self {
177        self.direction = TimerDirection::CountDown;
178        self
179    }
180
181    /// Sets the display unit value used by the component.
182    pub fn display_unit(mut self, unit: TimerUnit) -> Self {
183        self.display_unit = unit;
184        self.format = TimerFormat::Unit;
185        self
186    }
187
188    /// Sets the format displayed or consumed by the component.
189    pub fn format(mut self, format: TimerFormat) -> Self {
190        self.format = format;
191        self
192    }
193
194    /// Sets the clock format value used by the component.
195    pub fn clock_format(mut self) -> Self {
196        self.format = TimerFormat::Clock;
197        self
198    }
199
200    /// Configures whether unit is visible in the rendered component.
201    pub fn show_unit(mut self, show: bool) -> Self {
202        self.show_unit = show;
203        self
204    }
205
206    /// Sets the primary title text displayed by the component.
207    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
208        self.title = Some(title.into());
209        self
210    }
211
212    /// Sets the prefix value used by the component.
213    pub fn prefix(mut self, prefix: impl Into<SharedString>) -> Self {
214        self.prefix = Some(prefix.into());
215        self
216    }
217
218    /// Sets the suffix value used by the component.
219    pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
220        self.suffix = Some(suffix.into());
221        self
222    }
223
224    /// Sets the compact value used by the component.
225    pub fn compact(mut self) -> Self {
226        self.compact = true;
227        self
228    }
229
230    /// Sets the running value used by the component.
231    pub fn running(mut self, running: bool) -> Self {
232        self.running = running;
233        if running && self.started_at.is_none() {
234            self.started_at = Some(Instant::now());
235        }
236        self
237    }
238
239    /// Sets the start value used by the component.
240    pub fn start(self) -> Self {
241        self.running(true)
242    }
243
244    /// Sets the paused value used by the component.
245    pub fn paused(self) -> Self {
246        self.running(false)
247    }
248
249    /// Sets the tick interval value used by the component.
250    pub fn tick_interval(mut self, interval: Duration) -> Self {
251        self.tick_interval = interval.max(Duration::from_millis(16));
252        self
253    }
254
255    fn effective_elapsed(&self) -> Duration {
256        if self.running {
257            self.started_at
258                .map(|started_at| self.elapsed.saturating_add(started_at.elapsed()))
259                .unwrap_or(self.elapsed)
260        } else {
261            self.elapsed
262        }
263    }
264
265    /// Performs the snapshot operation used by this component.
266    pub fn snapshot(&self) -> TimerSnapshot {
267        let remaining = self
268            .duration
269            .map(|duration| duration.saturating_sub(self.effective_elapsed()));
270        TimerSnapshot {
271            elapsed: self.effective_elapsed(),
272            remaining,
273            finished: matches!(self.direction, TimerDirection::CountDown)
274                && remaining.is_some_and(|remaining| remaining.is_zero()),
275        }
276    }
277
278    /// Performs the elapsed as operation used by this component.
279    pub fn elapsed_as(&self, unit: TimerUnit) -> f64 {
280        self.snapshot().elapsed_as(unit)
281    }
282
283    /// Performs the remaining as operation used by this component.
284    pub fn remaining_as(&self, unit: TimerUnit) -> Option<f64> {
285        self.snapshot().remaining_as(unit)
286    }
287
288    fn display_duration(&self) -> Duration {
289        match self.direction {
290            TimerDirection::CountUp => self.effective_elapsed(),
291            TimerDirection::CountDown => self
292                .duration
293                .map(|duration| duration.saturating_sub(self.effective_elapsed()))
294                .unwrap_or_default(),
295        }
296    }
297
298    fn format_value(&self) -> SharedString {
299        match self.format {
300            TimerFormat::Unit => {
301                format_duration(self.display_duration(), self.display_unit, self.show_unit)
302            }
303            TimerFormat::Clock => format_clock(self.display_duration()),
304        }
305    }
306}
307
308impl RenderOnce for Timer {
309    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
310        let theme = cx.global::<Config>().theme.clone();
311        let mut timer = self;
312        if timer.running {
313            ensure_timer_runtime(cx);
314            if let Some(runtime) = cx.try_global::<TimerRuntime>() {
315                timer.started_at = Some(runtime.started_at(timer.id.clone()));
316            }
317        }
318        if timer.running && !timer.snapshot().finished {
319            if let Some(runtime) = cx.try_global::<TimerRuntime>() {
320                runtime.register(window.window_handle(), timer.tick_interval);
321            }
322        }
323        let value = timer.format_value();
324        div()
325            .id(ElementId::from(timer.id))
326            .flex()
327            .flex_col()
328            .gap_1()
329            .when(!timer.compact, |s| {
330                s.p_3()
331                    .rounded_md()
332                    .border_1()
333                    .border_color(theme.neutral.border)
334                    .bg(theme.neutral.card)
335            })
336            .when_some(timer.title, |s, title| {
337                s.child(
338                    div()
339                        .text_xs()
340                        .text_color(theme.neutral.text_3)
341                        .child(title),
342                )
343            })
344            .child(
345                div()
346                    .flex()
347                    .items_baseline()
348                    .gap_1()
349                    .text_color(theme.neutral.text_1)
350                    .when_some(timer.prefix, |s, prefix| {
351                        s.child(
352                            div()
353                                .text_sm()
354                                .text_color(theme.neutral.text_3)
355                                .child(prefix),
356                        )
357                    })
358                    .child(
359                        div()
360                            .text_size(px(24.0))
361                            .font_weight(gpui::FontWeight::BOLD)
362                            .child(value),
363                    )
364                    .when_some(timer.suffix, |s, suffix| {
365                        s.child(
366                            div()
367                                .text_sm()
368                                .text_color(theme.neutral.text_3)
369                                .child(suffix),
370                        )
371                    }),
372            )
373    }
374}
375
376#[derive(Clone)]
377struct TimerRuntime {
378    windows: Arc<Mutex<HashSet<gpui::AnyWindowHandle>>>,
379    starts: Arc<Mutex<HashMap<SharedString, Instant>>>,
380}
381
382impl Global for TimerRuntime {}
383
384impl TimerRuntime {
385    fn new(cx: &mut App) -> Self {
386        let windows = Arc::new(Mutex::new(HashSet::new()));
387        let runtime = Self {
388            windows: windows.clone(),
389            starts: Arc::new(Mutex::new(HashMap::new())),
390        };
391        let executor = cx.background_executor().clone();
392        cx.spawn(async move |cx: &mut gpui::AsyncApp| {
393            loop {
394                executor.timer(Duration::from_millis(250)).await;
395                let handles = lock_timer_windows(&windows)
396                    .iter()
397                    .copied()
398                    .collect::<Vec<_>>();
399                for handle in handles {
400                    let _ = handle.update(cx, |_, window, _| window.refresh());
401                }
402            }
403        })
404        .detach();
405        runtime
406    }
407
408    fn started_at(&self, id: SharedString) -> Instant {
409        let mut starts = lock_timer_starts(&self.starts);
410        *starts.entry(id).or_insert_with(Instant::now)
411    }
412
413    fn register(&self, window: gpui::AnyWindowHandle, _interval: Duration) {
414        lock_timer_windows(&self.windows).insert(window);
415    }
416}
417
418fn lock_timer_windows(
419    windows: &Arc<Mutex<HashSet<gpui::AnyWindowHandle>>>,
420) -> MutexGuard<'_, HashSet<gpui::AnyWindowHandle>> {
421    windows
422        .lock()
423        .unwrap_or_else(|poisoned| poisoned.into_inner())
424}
425
426fn lock_timer_starts(
427    starts: &Arc<Mutex<HashMap<SharedString, Instant>>>,
428) -> MutexGuard<'_, HashMap<SharedString, Instant>> {
429    starts
430        .lock()
431        .unwrap_or_else(|poisoned| poisoned.into_inner())
432}
433
434fn ensure_timer_runtime(cx: &mut App) {
435    if !cx.has_global::<TimerRuntime>() {
436        let runtime = TimerRuntime::new(cx);
437        cx.set_global(runtime);
438    }
439}
440
441impl IntoElement for Timer {
442    type Element = Component<Self>;
443
444    fn into_element(self) -> Self::Element {
445        Component::new(self)
446    }
447}
448
449/// Performs the duration as operation used by this component.
450pub fn duration_as(duration: Duration, unit: TimerUnit) -> f64 {
451    match unit {
452        TimerUnit::Milliseconds => duration.as_secs_f64() * 1000.0,
453        TimerUnit::Seconds => duration.as_secs_f64(),
454        TimerUnit::Minutes => duration.as_secs_f64() / 60.0,
455        TimerUnit::Hours => duration.as_secs_f64() / 3600.0,
456    }
457}
458
459/// Formats duration for display.
460pub fn format_duration(duration: Duration, unit: TimerUnit, show_unit: bool) -> SharedString {
461    let value = duration_as(duration, unit);
462    let text = match unit {
463        TimerUnit::Milliseconds => format!("{value:.0}"),
464        TimerUnit::Seconds => format!("{value:.1}"),
465        TimerUnit::Minutes => format!("{value:.2}"),
466        TimerUnit::Hours => format!("{value:.2}"),
467    };
468    if show_unit {
469        format!("{} {}", text, unit_label(unit)).into()
470    } else {
471        text.into()
472    }
473}
474
475/// Formats clock for display.
476pub fn format_clock(duration: Duration) -> SharedString {
477    let total_seconds = duration.as_secs();
478    let hours = total_seconds / 3600;
479    let minutes = (total_seconds % 3600) / 60;
480    let seconds = total_seconds % 60;
481    format!("{hours:02}:{minutes:02}:{seconds:02}").into()
482}
483
484fn unit_label(unit: TimerUnit) -> &'static str {
485    match unit {
486        TimerUnit::Milliseconds => "ms",
487        TimerUnit::Seconds => "s",
488        TimerUnit::Minutes => "min",
489        TimerUnit::Hours => "h",
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn timer_snapshot_tracks_countdown_remaining() {
499        let timer = Timer::count_down(Duration::from_secs(10), Duration::from_secs(4));
500        let snapshot = timer.snapshot();
501        assert_eq!(snapshot.elapsed, Duration::from_secs(4));
502        assert_eq!(snapshot.remaining, Some(Duration::from_secs(6)));
503        assert!(!snapshot.finished);
504    }
505
506    #[test]
507    fn running_timer_includes_elapsed_since_start() {
508        let timer = Timer::count_up(Duration::from_secs(2)).start();
509        assert!(timer.effective_elapsed() >= Duration::from_secs(2));
510        assert!(timer.running);
511    }
512
513    #[test]
514    fn timer_countdown_saturates_at_zero() {
515        let timer = Timer::count_down(Duration::from_secs(10), Duration::from_secs(12));
516        let snapshot = timer.snapshot();
517        assert_eq!(snapshot.remaining, Some(Duration::ZERO));
518        assert!(snapshot.finished);
519    }
520
521    #[test]
522    fn timer_formats_units() {
523        assert_eq!(
524            format_duration(Duration::from_millis(1500), TimerUnit::Milliseconds, true),
525            SharedString::from("1500 ms")
526        );
527        assert_eq!(
528            format_duration(Duration::from_secs(90), TimerUnit::Minutes, true),
529            SharedString::from("1.50 min")
530        );
531        assert_eq!(
532            Timer::count_up(Duration::from_secs(7200)).elapsed_as(TimerUnit::Hours),
533            2.0
534        );
535    }
536
537    #[test]
538    fn timer_formats_clock() {
539        assert_eq!(
540            format_clock(Duration::from_secs(0)),
541            SharedString::from("00:00:00")
542        );
543        assert_eq!(
544            format_clock(Duration::from_secs(3661)),
545            SharedString::from("01:01:01")
546        );
547        assert_eq!(
548            Timer::count_up(Duration::from_secs(3661))
549                .clock_format()
550                .format_value(),
551            SharedString::from("01:01:01")
552        );
553    }
554}