From 977ba4c33580b08b66aca6ebdb4fc25a35091e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 00:21:23 -0400 Subject: [PATCH] =?UTF-8?q?Misc=20=C2=B7=20Time:=20Lap=20turns=20Reset,=20?= =?UTF-8?q?laps=20and=20tickers=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stopwatch's second button reads Lap while running and Reset once paused, on one focus handle, and Reset hands focus to Start, so no press leaves focus stranded. Lap rows carry selectors; tests hold their order and times, count the tickers' wakes, and a WorldClock without places fails. Co-Authored-By: Claude Opus 5.5 --- examples/gallery/pages/misc/mod.rs | 2 +- src/misc/clock.rs | 21 +++++--- src/misc/stopwatch.rs | 59 ++++++++++++-------- src/misc/tests.rs | 87 +++++++++++++++++++++++++----- 4 files changed, 127 insertions(+), 42 deletions(-) diff --git a/examples/gallery/pages/misc/mod.rs b/examples/gallery/pages/misc/mod.rs index db816ba..d180205 100644 --- a/examples/gallery/pages/misc/mod.rs +++ b/examples/gallery/pages/misc/mod.rs @@ -64,7 +64,7 @@ fn clocks(cx: &App) -> impl IntoElement + use<> { fn stopwatch(cx: &App) -> impl IntoElement + use<> { section( "Stopwatch", - "Time that runs while started and holds while paused, in tenths; Lap marks the time so far, Reset clears it once paused.", + "Time that runs while started and holds while paused, in tenths. Lap marks the time so far; once paused, it reads Reset and clears it.", cx, ) .child(probe("stopwatch-start", div().w(px(320.0)).child(Stopwatch::new("stopwatch")))) diff --git a/src/misc/clock.rs b/src/misc/clock.rs index 58de712..0e97e90 100644 --- a/src/misc/clock.rs +++ b/src/misc/clock.rs @@ -187,7 +187,7 @@ impl RenderOnce for Clock { } } -/// Places and their zones, a row each: a small face, the place, how its day and hour stand against home, and its time. Home is the system zone unless told; it ticks each second, or shows the owner's moment. +/// Places and their zones, a row each: a small face, the place, how its day and hour stand against home, and its time; fails without places. Home is the system zone unless told; it ticks each second, or shows the owner's moment. #[derive(IntoElement)] pub struct WorldClock { id: ElementId, @@ -201,12 +201,15 @@ impl WorldClock { id: impl Into, places: impl IntoIterator, TimeZone)>, ) -> Self { + let id = id.into(); + let places: Vec<_> = places + .into_iter() + .map(|(name, zone)| (name.into(), zone)) + .collect(); + assert!(!places.is_empty(), "world clock {id:?} has no places"); Self { - id: id.into(), - places: places - .into_iter() - .map(|(name, zone)| (name.into(), zone)) - .collect(), + id, + places, home: None, now: None, } @@ -291,6 +294,12 @@ mod tests { assert_eq!(second, 0.25); } + #[test] + #[should_panic(expected = "has no places")] + fn a_world_clock_without_places_fails() { + super::WorldClock::new("world", Vec::<(&str, TimeZone)>::new()); + } + #[test] fn a_zone_stands_against_home_by_day_and_offset() { let now: Timestamp = "2026-09-28T03:00:00Z".parse().expect("a moment"); diff --git a/src/misc/stopwatch.rs b/src/misc/stopwatch.rs index cc69ac5..760569a 100644 --- a/src/misc/stopwatch.rs +++ b/src/misc/stopwatch.rs @@ -1,8 +1,8 @@ use std::time::{Duration, Instant}; use gpui::{ - App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Task, - Window, div, + App, ElementId, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce, + Styled, Task, Window, div, }; use crate::{ @@ -69,7 +69,7 @@ pub(crate) fn tenths(elapsed: Duration) -> String { } } -/// 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. +/// Time that runs while started and holds while paused, in tenths, with Start or Pause under it beside Lap, which reads Reset once paused, 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, @@ -87,7 +87,14 @@ impl RenderOnce for Stopwatch { 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 (running, elapsed) = (run.read(cx).since.is_some(), run.read(cx).elapsed(now)); + let side = tab_stop( + (id.clone(), "side").into(), + running || !elapsed.is_zero(), + window, + cx, + ); + if running && run.read(cx).tick.is_none() { let (view, weak) = (window.current_view(), run.downgrade()); let task = window.spawn(cx, async move |cx| { loop { @@ -103,26 +110,28 @@ impl RenderOnce for Stopwatch { }); 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 laps = run.read(cx).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 act = |name: &'static str, edit: fn(&mut Run, Instant), then: Option<&FocusHandle>| { + let (run, then) = (run.clone(), then.cloned()); + move |_: &gpui::ClickEvent, window: &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(); }); + if let Some(then) = &then { + window.focus(then); + } } }; let rows = laps.iter().enumerate().rev().map(|(ix, total)| { let before = ix.checked_sub(1).map_or(Duration::ZERO, |last| laps[last]); + let own = tenths(*total - before); div() + .debug_selector(|| format!("lap-{}-{own}-{}", ix + 1, tenths(*total))) .flex() .gap_3() .py_1() @@ -134,7 +143,7 @@ impl RenderOnce for Stopwatch { tabular(div()) .flex_none() .text_color(theme.colors.fg) - .child(tenths(*total - before)), + .child(own.clone()), ) .child(tabular(div()).flex_none().child(tenths(*total))) }); @@ -166,19 +175,25 @@ impl RenderOnce for Stopwatch { }) .focus_handle(&toggle) .on_click(match running { - true => act("pause", Run::pause), - false => act("start", Run::start), + true => act("pause", Run::pause, None), + false => act("start", Run::start, None), }), ) .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())), + Button::new( + (id.clone(), "side"), + if running || elapsed.is_zero() { + "Lap" + } else { + "Reset" + }, + ) + .focus_handle(&side) + .disabled(!running && elapsed.is_zero()) + .on_click(match running { + true => act("lap", Run::lap, None), + false => act("reset", |run, _| run.reset(), Some(&toggle)), + }), ), ) .child(div().flex().flex_col().children(rows)) diff --git a/src/misc/tests.rs b/src/misc/tests.rs index 48d88aa..5be6739 100644 --- a/src/misc/tests.rs +++ b/src/misc/tests.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{cell::Cell, rc::Rc, time::Duration}; use gpui::{ AnyElement, Context, FocusHandle, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, @@ -34,7 +34,11 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } -fn stage(part: fn() -> AnyElement, cx: &mut TestAppContext) -> &mut VisualTestContext { +/// Stages `part`, counting the times the stage is told to redraw. +fn stage( + part: fn() -> AnyElement, + cx: &mut TestAppContext, +) -> (Rc>, &mut VisualTestContext) { cx.update(|cx| { Theme::init(cx); cx.bind_keys([ @@ -47,9 +51,15 @@ fn stage(part: fn() -> AnyElement, cx: &mut TestAppContext) -> &mut VisualTestCo part, }); let root = view.read_with(cx, |stage, _| stage.root.clone()); - cx.update(|window, _| window.focus(&root)); + let wakes = Rc::new(Cell::new(0)); + let count = wakes.clone(); + cx.update(|window, cx| { + window.focus(&root); + cx.observe(&view, move |_, _| count.set(count.get() + 1)) + .detach(); + }); settle(cx); - cx + (wakes, cx) } fn press(key: &str, cx: &mut VisualTestContext) { @@ -74,31 +84,80 @@ fn watch() -> AnyElement { Stopwatch::new("watch").into_any_element() } -/// Start runs the time on the executor's clock, Pause holds it, and Reset clears it for the next run. +/// Start runs the time on the executor's clock, Lap lists the laps newest first, Pause holds it, and Reset clears it and hands focus to Start. #[gpui::test] -fn a_stopwatch_runs_holds_and_resets(cx: &mut TestAppContext) { - let cx = stage(watch, cx); +fn a_stopwatch_runs_laps_holds_and_resets(cx: &mut TestAppContext) { + let (_, cx) = stage(watch, cx); press("tab", cx); press("enter", cx); - wait(1_250, cx); - assert!(shown("stopwatch-00:01.2", cx), "running"); + wait(1_100, cx); + assert!(shown("stopwatch-00:01.1", cx), "running"); + press("tab", cx); + press("enter", cx); + wait(1_200, cx); + press("enter", cx); + let row = |selector: &'static str, cx: &mut VisualTestContext| { + cx.debug_bounds(selector) + .unwrap_or_else(|| panic!("no row {selector}")) + }; + assert!( + row("lap-2-00:01.2-00:02.3", cx).origin.y < row("lap-1-00:01.1-00:01.1", cx).origin.y, + "each lap's own time and total, the newest on top" + ); + press("shift-tab", cx); press("enter", cx); wait(2_000, cx); assert!( - shown("stopwatch-00:01.2", cx) && !shown("stopwatch-00:03.2", cx), + shown("stopwatch-00:02.3", cx) && !shown("stopwatch-00:04.3", cx), "Pause, on the same button, holds it" ); press("tab", cx); press("enter", cx); - press("shift-tab", cx); press("enter", cx); wait(300, cx); assert!( shown("stopwatch-00:00.3", cx), - "Reset cleared it, and Start ran it from nothing" + "Lap, now Reset, cleared it and handed focus to Start" ); } +/// The ticker wakes the stopwatch each tenth while it runs, and not at all once paused. +#[gpui::test] +fn a_running_stopwatch_wakes_each_tenth_and_rests_paused(cx: &mut TestAppContext) { + let (wakes, cx) = stage(watch, cx); + press("tab", cx); + press("enter", cx); + let started = wakes.get(); + for _ in 0..12 { + wait(100, cx); + } + assert!( + wakes.get() - started >= 12, + "woke {} times", + wakes.get() - started + ); + press("enter", cx); + let paused = wakes.get(); + for _ in 0..10 { + wait(100, cx); + } + assert_eq!(wakes.get(), paused, "paused, it sleeps"); +} + +/// A clock on the wall clock wakes its view within each second. +#[gpui::test] +fn a_clock_ticks_each_second(cx: &mut TestAppContext) { + let (wakes, cx) = stage( + || Clock::new("clock").zone(TimeZone::UTC).into_any_element(), + cx, + ); + for second in 1..=2 { + let before = wakes.get(); + wait(1_000, cx); + assert!(wakes.get() > before, "no tick in second {second}"); + } +} + fn clocks() -> AnyElement { let now: Timestamp = "2026-09-28T03:00:00Z".parse().expect("a moment"); let zone = |name: &str| TimeZone::get(name).expect("a zone"); @@ -126,6 +185,8 @@ fn clocks() -> AnyElement { /// Clocks shown at the owner's moment draw without a ticker. #[gpui::test] fn clocks_draw_at_the_owners_moment(cx: &mut TestAppContext) { - let cx = stage(clocks, cx); + let (wakes, cx) = stage(clocks, cx); + let before = wakes.get(); wait(5_000, cx); + assert_eq!(wakes.get(), before, "nothing woke them"); }