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"); }