1use 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)]
34pub enum TimerDirection {
36 #[default]
37 CountUp,
39 CountDown,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum TimerUnit {
46 Milliseconds,
48 #[default]
49 Seconds,
51 Minutes,
53 Hours,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TimerFormat {
60 Unit,
62 Clock,
64}
65
66impl Default for TimerFormat {
67 fn default() -> Self {
68 Self::Unit
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct TimerSnapshot {
75 pub elapsed: Duration,
77 pub remaining: Option<Duration>,
79 pub finished: bool,
81}
82
83impl TimerSnapshot {
84 pub fn elapsed_as(self, unit: TimerUnit) -> f64 {
86 duration_as(self.elapsed, unit)
87 }
88
89 pub fn remaining_as(self, unit: TimerUnit) -> Option<f64> {
91 self.remaining.map(|remaining| duration_as(remaining, unit))
92 }
93}
94
95#[derive(Clone)]
96pub 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 pub fn count_up(elapsed: Duration) -> Self {
117 Self::new(TimerDirection::CountUp, elapsed, None)
118 }
119
120 pub fn count_down(duration: Duration, elapsed: Duration) -> Self {
122 Self::new(TimerDirection::CountDown, elapsed, Some(duration))
123 }
124
125 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 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
147 self.id = id.into();
148 self
149 }
150
151 pub fn elapsed(mut self, elapsed: Duration) -> Self {
153 self.elapsed = elapsed;
154 self
155 }
156
157 pub fn duration(mut self, duration: Duration) -> Self {
159 self.duration = Some(duration);
160 self
161 }
162
163 pub fn direction(mut self, direction: TimerDirection) -> Self {
165 self.direction = direction;
166 self
167 }
168
169 pub fn countup(mut self) -> Self {
171 self.direction = TimerDirection::CountUp;
172 self
173 }
174
175 pub fn countdown(mut self) -> Self {
177 self.direction = TimerDirection::CountDown;
178 self
179 }
180
181 pub fn display_unit(mut self, unit: TimerUnit) -> Self {
183 self.display_unit = unit;
184 self.format = TimerFormat::Unit;
185 self
186 }
187
188 pub fn format(mut self, format: TimerFormat) -> Self {
190 self.format = format;
191 self
192 }
193
194 pub fn clock_format(mut self) -> Self {
196 self.format = TimerFormat::Clock;
197 self
198 }
199
200 pub fn show_unit(mut self, show: bool) -> Self {
202 self.show_unit = show;
203 self
204 }
205
206 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
208 self.title = Some(title.into());
209 self
210 }
211
212 pub fn prefix(mut self, prefix: impl Into<SharedString>) -> Self {
214 self.prefix = Some(prefix.into());
215 self
216 }
217
218 pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
220 self.suffix = Some(suffix.into());
221 self
222 }
223
224 pub fn compact(mut self) -> Self {
226 self.compact = true;
227 self
228 }
229
230 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 pub fn start(self) -> Self {
241 self.running(true)
242 }
243
244 pub fn paused(self) -> Self {
246 self.running(false)
247 }
248
249 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 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 pub fn elapsed_as(&self, unit: TimerUnit) -> f64 {
280 self.snapshot().elapsed_as(unit)
281 }
282
283 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
449pub 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
459pub 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
475pub 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}