Misc · Time: clocks and a stopwatch
misc::Clock shows the time in a zone, the system's unless told, as an analog face with hour marks and three hands over its digits and a label; it wakes its view at each whole second of the wall clock, and only while the owner passes no moment. misc::WorldClock lays places in rows: a small face, the place, how its day and hour stand against home, and its time. misc::Stopwatch runs on the executor's clock in tenths, holds while paused, marks laps while running and clears once paused; Start and Pause share one button and its focus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7cce4ab3fc
commit
6918370b08
11 files changed
+790
-2
No files matched your search
@@ -0,0 +1,234 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Task,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
primitives::tab_stop,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
const STEP: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Time run while started and held while paused, the laps taken, and the task that wakes the face while it runs.
|
||||
#[derive(Default)]
|
||||
struct Run {
|
||||
held: Duration,
|
||||
since: Option<Instant>,
|
||||
laps: Vec<Duration>,
|
||||
tick: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn elapsed(&self, now: Instant) -> Duration {
|
||||
self.held
|
||||
+ self
|
||||
.since
|
||||
.map_or(Duration::ZERO, |since| now.saturating_duration_since(since))
|
||||
}
|
||||
|
||||
fn start(&mut self, now: Instant) {
|
||||
self.since.get_or_insert(now);
|
||||
}
|
||||
|
||||
fn pause(&mut self, now: Instant) {
|
||||
if let Some(since) = self.since.take() {
|
||||
self.held += now.saturating_duration_since(since);
|
||||
}
|
||||
self.tick = None;
|
||||
}
|
||||
|
||||
/// Marks the time so far; only while running.
|
||||
fn lap(&mut self, now: Instant) {
|
||||
if self.since.is_some() {
|
||||
self.laps.push(self.elapsed(now));
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
*self = Run::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Minutes, seconds and tenths, as 01:02.3; hours ahead of them once there are any.
|
||||
pub(crate) fn tenths(elapsed: Duration) -> String {
|
||||
let tenths = elapsed.as_millis() / 100;
|
||||
let (hours, minutes, seconds, tenth) = (
|
||||
tenths / 36_000,
|
||||
tenths / 600 % 60,
|
||||
tenths / 10 % 60,
|
||||
tenths % 10,
|
||||
);
|
||||
match hours {
|
||||
0 => format!("{minutes:02}:{seconds:02}.{tenth}"),
|
||||
_ => format!("{hours}:{minutes:02}:{seconds:02}.{tenth}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Time that runs while started and holds while paused, in tenths, with Start or Pause, Lap and Reset under it and the laps below, newest first: each lap's own time and the total at its end. It reads the executor's clock.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Stopwatch {
|
||||
id: ElementId,
|
||||
}
|
||||
|
||||
impl Stopwatch {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self { id: id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Stopwatch {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id.clone();
|
||||
let run = window.use_keyed_state((id.clone(), "run"), cx, |_, _| Run::default());
|
||||
let toggle = tab_stop((id.clone(), "toggle").into(), true, window, cx);
|
||||
let now = cx.background_executor().now();
|
||||
if run.read(cx).since.is_some() && run.read(cx).tick.is_none() {
|
||||
let (view, weak) = (window.current_view(), run.downgrade());
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(STEP).await;
|
||||
let woke = cx.update(|_, cx| {
|
||||
cx.notify(view);
|
||||
weak.read_with(cx, |run, _| run.since.is_some())
|
||||
});
|
||||
if !matches!(woke, Ok(Ok(true))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
run.update(cx, |run, _| run.tick = Some(task));
|
||||
}
|
||||
let (running, elapsed, laps) = {
|
||||
let run = run.read(cx);
|
||||
(run.since.is_some(), run.elapsed(now), run.laps.clone())
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let shown = tenths(elapsed);
|
||||
let act = |name: &'static str, edit: fn(&mut Run, Instant)| {
|
||||
let run = run.clone();
|
||||
move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
|
||||
let now = cx.background_executor().now();
|
||||
run.update(cx, |run, cx| {
|
||||
edit(run, now);
|
||||
log::info!("stopwatch: {name} at {}", tenths(run.elapsed(now)));
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
};
|
||||
let rows = laps.iter().enumerate().rev().map(|(ix, total)| {
|
||||
let before = ix.checked_sub(1).map_or(Duration::ZERO, |last| laps[last]);
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.py_1()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(div().flex_1().child(format!("Lap {}", ix + 1)))
|
||||
.child(
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.text_color(theme.colors.fg)
|
||||
.child(tenths(*total - before)),
|
||||
)
|
||||
.child(tabular(div()).flex_none().child(tenths(*total)))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
tabular(div())
|
||||
.debug_selector(|| format!("stopwatch-{shown}"))
|
||||
.text_size(theme.text_size(TextSize::Xxl))
|
||||
.text_color(theme.colors.fg)
|
||||
.child(shown.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new(
|
||||
(id.clone(), "start"),
|
||||
if running { "Pause" } else { "Start" },
|
||||
)
|
||||
.variant(if running {
|
||||
ButtonVariant::Secondary
|
||||
} else {
|
||||
ButtonVariant::Primary
|
||||
})
|
||||
.focus_handle(&toggle)
|
||||
.on_click(match running {
|
||||
true => act("pause", Run::pause),
|
||||
false => act("start", Run::start),
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new((id.clone(), "lap"), "Lap")
|
||||
.disabled(!running)
|
||||
.on_click(act("lap", Run::lap)),
|
||||
)
|
||||
.child(
|
||||
Button::new((id.clone(), "reset"), "Reset")
|
||||
.disabled(running || elapsed.is_zero())
|
||||
.on_click(act("reset", |run, _| run.reset())),
|
||||
),
|
||||
)
|
||||
.child(div().flex().flex_col().children(rows))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{Run, tenths};
|
||||
|
||||
#[test]
|
||||
fn a_readout_shows_minutes_seconds_and_tenths() {
|
||||
assert_eq!(tenths(Duration::ZERO), "00:00.0");
|
||||
assert_eq!(tenths(Duration::from_millis(62_345)), "01:02.3");
|
||||
assert_eq!(tenths(Duration::from_millis(3_723_450)), "1:02:03.4");
|
||||
assert_eq!(tenths(Duration::from_millis(99)), "00:00.0", "tenths floor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_holds_while_paused_and_laps_only_while_running() {
|
||||
let start = Instant::now();
|
||||
let at = |ms: u64| start + Duration::from_millis(ms);
|
||||
let mut run = Run::default();
|
||||
run.start(at(0));
|
||||
run.lap(at(1_200));
|
||||
run.pause(at(2_000));
|
||||
assert_eq!(
|
||||
run.elapsed(at(9_000)),
|
||||
Duration::from_millis(2_000),
|
||||
"paused, it holds"
|
||||
);
|
||||
run.lap(at(9_000));
|
||||
assert_eq!(
|
||||
run.laps,
|
||||
[Duration::from_millis(1_200)],
|
||||
"no lap while paused"
|
||||
);
|
||||
run.start(at(10_000));
|
||||
run.start(at(10_500));
|
||||
assert_eq!(
|
||||
run.elapsed(at(11_000)),
|
||||
Duration::from_millis(3_000),
|
||||
"a second start keeps the first"
|
||||
);
|
||||
run.reset();
|
||||
assert_eq!(
|
||||
(run.elapsed(at(12_000)), run.laps.len()),
|
||||
(Duration::ZERO, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user