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}