diff --git a/AGENTS.md b/AGENTS.md index c65bd53..3953c8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone). - gpui 0.2.2's Metal renderer starts its instance buffer at 2 MB; a frame that outgrows it logs "failed to render … retrying with larger instance buffer size" and the buffer doubles, up to 256 MB. A world map's first frame grows it to 16 MB, then draws: the log is gpui's recovery, not a failure. - gpui 0.2.2 packs pictures in its atlas without padding and samples them with a linear filter, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`). +- A wall clock reads `Timestamp::now` and wakes its view at each whole second through a keyed task, only while the owner passes no moment; a stopwatch reads the executor's clock, so tests advance it (`misc::Clock`, `misc::Stopwatch`). - A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map. ## Decisions diff --git a/examples/gallery/pages/misc/mod.rs b/examples/gallery/pages/misc/mod.rs new file mode 100644 index 0000000..db816ba --- /dev/null +++ b/examples/gallery/pages/misc/mod.rs @@ -0,0 +1,71 @@ +use ely_gpui_component::misc::{Clock, Stopwatch, WorldClock}; +use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px}; +use jiff::tz::TimeZone; + +use super::Page; +use crate::probe::probe; +use crate::script::Step; +use crate::ui::{section, specimen, specimens}; + +pub const PAGE: Page = Page { + number: 42, + slug: "misc", + title: "Misc", + summary: "Small tools that stand alone: clocks, a stopwatch.", + render, + script: &[ + Step::DownAt("stopwatch-start", 30.0, 62.0), + Step::UpAt("stopwatch-start", 30.0, 62.0), + Step::Wait(1_600), + Step::DownAt("stopwatch-start", 30.0, 62.0), + Step::UpAt("stopwatch-start", 30.0, 62.0), + Step::Wait(300), + Step::Shot("stopwatch"), + ], +}; + +fn zone(name: &str) -> TimeZone { + TimeZone::get(name).unwrap_or_else(|error| panic!("the zone {name}: {error}")) +} + +fn render(_: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(clocks(cx)) + .child(stopwatch(cx)) + .into_any_element() +} + +fn clocks(cx: &App) -> impl IntoElement + use<> { + section( + "Clock / WorldClock", + "The time in a zone, a face over its digits, ticking each second. A world clock lays places in rows: how each one's day and hour stand against home.", + cx, + ) + .child( + specimens() + .child(specimen("clock", Clock::new("clock").label("Here"), cx)) + .child(specimen( + "world clock", + div().w(px(320.0)).child(WorldClock::new( + "world-clock", + [ + ("San Francisco", zone("America/Los_Angeles")), + ("New York", zone("America/New_York")), + ("London", zone("Europe/London")), + ("Mumbai", zone("Asia/Kolkata")), + ("Tokyo", zone("Asia/Tokyo")), + ], + )), + cx, + )), + ) +} + +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.", + cx, + ) + .child(probe("stopwatch-start", div().w(px(320.0)).child(Stopwatch::new("stopwatch")))) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index d7e0fe1..b4dc7f5 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -27,6 +27,7 @@ mod maps; mod media; mod menus; mod messaging; +mod misc; mod motion; mod navigation; mod onboarding; @@ -96,6 +97,7 @@ pub const ALL: &[Page] = &[ theme::PAGE, i18n::PAGE, maps::PAGE, + misc::PAGE, ]; pub use i18n::install as install_i18n; diff --git a/src/lib.rs b/src/lib.rs index 9a7ae7e..536bd25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ pub mod maps; pub mod media; pub mod menus; pub mod messaging; +pub mod misc; pub mod motion; pub mod navigation; pub mod onboarding; diff --git a/src/misc/clock.rs b/src/misc/clock.rs new file mode 100644 index 0000000..58de712 --- /dev/null +++ b/src/misc/clock.rs @@ -0,0 +1,318 @@ +use std::{cmp::Ordering, f32::consts::TAU, time::Duration}; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled, Task, + Window, canvas, div, point, +}; +use jiff::{Timestamp, civil::Time, tz::TimeZone}; + +use crate::{ + canvas::{finish, outline}, + theme::{ActiveTheme, TextSize}, + typography::{Ellipsis, format, tabular}, +}; + +/// Where each hand points, in turns clockwise from twelve: the hour's, the minute's and the second's. +pub(crate) fn hands(time: Time) -> (f32, f32, f32) { + let (hour, minute, second) = ( + f32::from(time.hour() % 12), + f32::from(time.minute()), + f32::from(time.second()), + ); + ( + (hour + minute / 60.0 + second / 3600.0) / 12.0, + (minute + second / 60.0) / 60.0, + second / 60.0, + ) +} + +/// How a zone's day and hour stand against home's at `now`: today, tomorrow or yesterday, and its offset from home. +pub(crate) fn against(now: Timestamp, zone: &TimeZone, home: &TimeZone) -> (&'static str, String) { + let (there, here) = (now.to_zoned(zone.clone()), now.to_zoned(home.clone())); + let day = match there.date().cmp(&here.date()) { + Ordering::Greater => "Tomorrow", + Ordering::Less => "Yesterday", + Ordering::Equal => "Today", + }; + let apart = there.offset().seconds() - here.offset().seconds(); + let (hours, minutes) = (apart.abs() / 3600, apart.abs() / 60 % 60); + let sign = if apart < 0 { format::MINUS } else { '+' }; + let offset = match (hours, minutes) { + (0, 0) => "Same time".to_string(), + (_, 0) => format!("{sign}{hours}h"), + _ => format!("{sign}{hours}h {minutes}m"), + }; + (day, offset) +} + +/// Wakes the view at each whole second of the wall clock while its element renders. +fn each_second(id: impl Into, window: &mut Window, cx: &mut App) { + window.use_keyed_state(id, cx, |window, cx| -> Task<()> { + cx.spawn_in(window, async move |ticker, cx| { + loop { + let past = Timestamp::now().subsec_nanosecond().max(0) as u64; + cx.background_executor() + .timer(Duration::from_nanos(1_000_000_000 - past)) + .await; + if ticker.update(cx, |_, cx| cx.notify()).is_err() { + return; + } + } + }) + }); +} + +/// An analog face `side` wide: twelve hour marks, and hour, minute and second hands. +fn face(time: Time, side: Pixels, cx: &App) -> impl IntoElement + use<> { + let colors = cx.theme().colors.clone(); + let (hour, minute, second) = hands(time); + let marks = colors.fg_subtle; + div() + .flex_none() + .size(side) + .rounded_full() + .bg(colors.surface) + .border_1() + .border_color(colors.border) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let r = f32::from(bounds.size.width) / 2.0; + let at = |turn: f32, reach: f32| { + let angle = turn * TAU; + (r + angle.sin() * r * reach, r - angle.cos() * r * reach) + }; + let width = |share: f32| Pixels::from(r * 2.0 * share); + for mark in 0..12 { + let turn = mark as f32 / 12.0; + let line = [at(turn, 0.78), at(turn, 0.9)]; + finish( + outline(&line, bounds.origin, width(0.02), false), + marks, + window, + ); + } + for (turn, reach, share, ink) in [ + (hour, 0.5, 0.045, colors.fg), + (minute, 0.74, 0.03, colors.fg), + (second, 0.84, 0.012, colors.accent), + ] { + let line = [(r, r), at(turn, reach)]; + finish( + outline(&line, bounds.origin, width(share), false), + ink, + window, + ); + } + let dot = width(0.06); + let middle = bounds.origin + + point(Pixels::from(r) - dot / 2.0, Pixels::from(r) - dot / 2.0); + window.paint_quad( + gpui::fill( + gpui::Bounds::new(middle, gpui::size(dot, dot)), + colors.accent, + ) + .corner_radii(dot / 2.0), + ); + }, + ) + .size_full(), + ) +} + +/// The time in a zone, the system's unless told: an analog face over its digits, with a label under them. It ticks each second, or shows the owner's moment. +#[derive(IntoElement)] +pub struct Clock { + id: ElementId, + zone: Option, + now: Option, + label: Option, +} + +impl Clock { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + zone: None, + now: None, + label: None, + } + } + + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// The moment it shows; the wall clock's otherwise. + pub fn now(mut self, now: Timestamp) -> Self { + self.now = Some(now); + self + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } +} + +impl RenderOnce for Clock { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self.zone.unwrap_or_else(|| format::system_zone("clock")); + if self.now.is_none() { + each_second((self.id.clone(), "tick"), window, cx); + } + let now = self.now.unwrap_or_else(Timestamp::now).to_zoned(zone); + let theme = cx.theme(); + let side = theme.misc().face.to_pixels(window.rem_size()); + div() + .flex() + .flex_col() + .items_center() + .gap_2() + .child(face(now.time(), side, cx)) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Lg)) + .text_color(theme.colors.fg) + .child(now.strftime("%H:%M:%S").to_string()), + ) + .children(self.label.map(|label| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(label) + })) + } +} + +/// 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. +#[derive(IntoElement)] +pub struct WorldClock { + id: ElementId, + places: Vec<(SharedString, TimeZone)>, + home: Option, + now: Option, +} + +impl WorldClock { + pub fn new( + id: impl Into, + places: impl IntoIterator, TimeZone)>, + ) -> Self { + Self { + id: id.into(), + places: places + .into_iter() + .map(|(name, zone)| (name.into(), zone)) + .collect(), + home: None, + now: None, + } + } + + pub fn home(mut self, home: TimeZone) -> Self { + self.home = Some(home); + self + } + + /// The moment it shows; the wall clock's otherwise. + pub fn now(mut self, now: Timestamp) -> Self { + self.now = Some(now); + self + } +} + +impl RenderOnce for WorldClock { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let home = self + .home + .unwrap_or_else(|| format::system_zone("world clock")); + if self.now.is_none() { + each_second((self.id.clone(), "tick"), window, cx); + } + let now = self.now.unwrap_or_else(Timestamp::now); + let theme = cx.theme(); + let side = theme.misc().small_face.to_pixels(window.rem_size()); + let rows = self.places.into_iter().map(|(name, zone)| { + let (day, offset) = against(now, &zone, &home); + let there = now.to_zoned(zone); + div() + .flex() + .items_center() + .gap_3() + .py_2() + .border_b_1() + .border_color(theme.colors.border) + .child(face(there.time(), side, cx)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child(div().text_color(theme.colors.fg).child(Ellipsis::new(name))) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(Ellipsis::new(format!("{day}, {offset}"))), + ), + ) + .child( + tabular(div()) + .flex_none() + .text_size(theme.text_size(TextSize::Lg)) + .text_color(theme.colors.fg) + .child(there.strftime("%H:%M").to_string()), + ) + }); + div().flex().flex_col().children(rows) + } +} + +#[cfg(test)] +mod tests { + use jiff::{Timestamp, civil::time, tz::TimeZone}; + + use super::{against, hands}; + + #[test] + fn hands_turn_with_the_hour_the_minute_and_the_second() { + assert_eq!(hands(time(0, 0, 0, 0)), (0.0, 0.0, 0.0)); + assert_eq!(hands(time(15, 0, 0, 0)), (0.25, 0.0, 0.0), "three o'clock"); + let (hour, minute, second) = hands(time(9, 30, 15, 0)); + assert!( + (hour - (9.5 + 15.0 / 3600.0) / 12.0).abs() < 1e-6, + "the hour hand runs halfway on" + ); + assert!((minute - 30.25 / 60.0).abs() < 1e-6); + assert_eq!(second, 0.25); + } + + #[test] + fn a_zone_stands_against_home_by_day_and_offset() { + let now: Timestamp = "2026-09-28T03:00:00Z".parse().expect("a moment"); + let zone = |name: &str| TimeZone::get(name).expect("a zone in the database"); + let home = zone("America/New_York"); + assert_eq!( + against(now, &zone("Asia/Tokyo"), &home), + ("Tomorrow", "+13h".into()) + ); + assert_eq!( + against(now, &zone("Asia/Kolkata"), &home), + ("Tomorrow", "+9h 30m".into()) + ); + assert_eq!( + against(now, &zone("America/Los_Angeles"), &home), + ("Today", "\u{2212}3h".into()) + ); + assert_eq!(against(now, &home, &home), ("Today", "Same time".into())); + let late: Timestamp = "2026-09-28T23:30:00Z".parse().expect("a moment"); + assert_eq!( + against(late, &zone("Pacific/Honolulu"), &zone("Asia/Tokyo")), + ("Yesterday", "\u{2212}19h".into()) + ); + } +} diff --git a/src/misc/mod.rs b/src/misc/mod.rs new file mode 100644 index 0000000..3a55d5d --- /dev/null +++ b/src/misc/mod.rs @@ -0,0 +1,7 @@ +mod clock; +mod stopwatch; +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use clock::{Clock, WorldClock}; +pub use stopwatch::Stopwatch; diff --git a/src/misc/stopwatch.rs b/src/misc/stopwatch.rs new file mode 100644 index 0000000..cc69ac5 --- /dev/null +++ b/src/misc/stopwatch.rs @@ -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, + laps: Vec, + tick: Option>, +} + +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) -> 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) + ); + } +} diff --git a/src/misc/tests.rs b/src/misc/tests.rs new file mode 100644 index 0000000..48d88aa --- /dev/null +++ b/src/misc/tests.rs @@ -0,0 +1,131 @@ +use std::time::Duration; + +use gpui::{ + AnyElement, Context, FocusHandle, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, + Styled, TestAppContext, VisualTestContext, Window, div, px, +}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::{Clock, Stopwatch, WorldClock}; +use crate::{ + primitives::{FocusNext, FocusPrev, FocusScope}, + theme::Theme, +}; + +struct Stage { + root: FocusHandle, + part: fn() -> AnyElement, +} + +impl Render for Stage { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + FocusScope::new(&self.root) + .root() + .size_full() + .child(div().w(px(400.0)).child((self.part)())) + } +} + +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn stage(part: fn() -> AnyElement, cx: &mut TestAppContext) -> &mut VisualTestContext { + cx.update(|cx| { + Theme::init(cx); + cx.bind_keys([ + gpui::KeyBinding::new("tab", FocusNext, None), + gpui::KeyBinding::new("shift-tab", FocusPrev, None), + ]); + }); + let (view, cx) = cx.add_window_view(|_, cx| Stage { + root: cx.focus_handle(), + part, + }); + let root = view.read_with(cx, |stage, _| stage.root.clone()); + cx.update(|window, _| window.focus(&root)); + settle(cx); + cx +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn wait(ms: u64, cx: &mut VisualTestContext) { + cx.executor().advance_clock(Duration::from_millis(ms)); + settle(cx); +} + +fn shown(selector: &'static str, cx: &mut VisualTestContext) -> bool { + cx.debug_bounds(selector).is_some() +} + +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. +#[gpui::test] +fn a_stopwatch_runs_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"); + press("enter", cx); + wait(2_000, cx); + assert!( + shown("stopwatch-00:01.2", cx) && !shown("stopwatch-00:03.2", 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" + ); +} + +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"); + div() + .child( + Clock::new("clock") + .zone(zone("Europe/Paris")) + .now(now) + .label("Paris"), + ) + .child( + WorldClock::new( + "world", + [ + ("Tokyo", zone("Asia/Tokyo")), + ("Kolkata", zone("Asia/Kolkata")), + ], + ) + .home(zone("America/New_York")) + .now(now), + ) + .into_any_element() +} + +/// 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); + wait(5_000, cx); +} diff --git a/src/theme/misc.rs b/src/theme/misc.rs new file mode 100644 index 0000000..a285122 --- /dev/null +++ b/src/theme/misc.rs @@ -0,0 +1,21 @@ +use gpui::Rems; + +use super::{Theme, tokens::px_to_rems}; + +/// The misc chapter's measures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MiscSizes { + /// A clock's face. + pub face: Rems, + /// A world clock row's face. + pub small_face: Rems, +} + +impl Theme { + pub fn misc(&self) -> MiscSizes { + MiscSizes { + face: px_to_rems(112.0), + small_face: px_to_rems(32.0), + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 35028ad..5a0afdc 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -8,6 +8,7 @@ mod interaction; mod maps; mod media; mod messaging; +mod misc; mod onboarding; mod palette; mod platform; @@ -33,6 +34,7 @@ pub use interaction::InteractionSizes; pub use maps::MapSizes; pub use media::MediaSizes; pub use messaging::MessagingSizes; +pub use misc::MiscSizes; pub use onboarding::OnboardingSizes; pub use palette::{HUE_NAMES, Mix, Palette, Syntax}; pub use platform::Platform; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 4442044..4ebbf34 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -310,8 +310,8 @@ ## T42a Misc · Time — `src/misc` -- [ ] Clock / WorldClock -- [ ] Stopwatch +- [x] Clock / WorldClock → `misc::Clock`, a face over its digits ticking each second; `misc::WorldClock`, places in rows by day and offset against home +- [x] Stopwatch → `misc::Stopwatch`, on the executor's clock: Start or Pause on one button, Lap and Reset, laps newest first ## T42b Misc · Numbers