diff --git a/AGENTS.md b/AGENTS.md index d0a7d26..a1366bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens. - gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`). The press itself bubbles, so a container that takes Space leaves a focused button its own: `overlays::media_button` stops it, and a button in a `SelectableList` row stops Space and Enter at its box (`mail::MailItem`'s star, `messaging::PinnedMessages`' Unpin). - A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`). +- A button whose words and action change with a mode keeps one handle through `Button::focus_handle`, and the component moves focus to it or back to its field as the mode turns (`project::TimeTracker`). - A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip). - A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay. - Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike. @@ -94,7 +95,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. `taken` adds file kinds by extension, for drops and the dialog alike (`DropZone::kinds`). - A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`, `calendar::TimeGrid`'s drag on empty time). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. -- `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view` along the box's axis; the canvas measures the stop with its ring (`messaging::GifPicker`). Never poll layout with `request_animation_frame`. +- `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view` along the box's axis; the canvas measures the stop with its ring (`messaging::GifPicker`). `layout::reveal_when_focused` is that canvas; it lies over a box that holds the stop's ring (`buttons::SegmentedControl`, `project::KanbanBoard`). Never poll layout with `request_animation_frame`. - gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. - Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them. - A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too. diff --git a/TASKS.md b/TASKS.md index e23191b..faf4295 100644 --- a/TASKS.md +++ b/TASKS.md @@ -368,3 +368,4 @@ Per-component lines live in `tasks/`. Tags there: | T31b | 2 | PASS | | | T31c | 1 | FAIL | a roadmap bar's end and the workload bar's cap went untested | | T31c | 2 | PASS | | +| T31d | 1 | FAIL | a focused segment past the strip's fold stayed hidden; the tracker lost focus as Start and Stop swapped; Stop and Skip went untested; percent did not refuse content | diff --git a/src/buttons/button.rs b/src/buttons/button.rs index 82a62db..a840ad5 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -1,6 +1,6 @@ use gpui::{ - App, ClickEvent, ElementId, FontWeight, Hsla, IntoElement, Keystroke, MouseButton, RenderOnce, - SharedString, Window, div, prelude::*, transparent_black, + App, ClickEvent, ElementId, FocusHandle, FontWeight, Hsla, IntoElement, Keystroke, MouseButton, + RenderOnce, SharedString, Window, div, prelude::*, transparent_black, }; use crate::{ @@ -131,6 +131,7 @@ pub struct Button { disabled: bool, loading: bool, full_width: bool, + focus: Option, pub(crate) slot: Option, on_click: Option, } @@ -148,6 +149,7 @@ impl Button { disabled: false, loading: false, full_width: false, + focus: None, slot: None, on_click: None, } @@ -185,6 +187,12 @@ impl Button { self } + /// Takes focus through the owner's handle, one that outlives a mode where the button changes; a Tab stop when built with `tab_stop`. + pub fn focus_handle(mut self, handle: &FocusHandle) -> Self { + self.focus = Some(handle.clone()); + self + } + pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self @@ -277,14 +285,16 @@ impl RenderOnce for Button { if self.disabled { return el.opacity(0.45).cursor_not_allowed(); } + let el = match &self.focus { + Some(handle) => el.track_focus(handle), + None => el.tab_index(0), + }; if self.loading { return el - .tab_index(0) .focus_ring(cx) .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()); } el.cursor_pointer() - .tab_index(0) .hover(|style| { let style = style.bg(tone.hover); if link { style.underline() } else { style } diff --git a/src/buttons/segmented.rs b/src/buttons/segmented.rs index 5a6ee49..f34745a 100644 --- a/src/buttons/segmented.rs +++ b/src/buttons/segmented.rs @@ -1,21 +1,22 @@ use std::rc::Rc; use gpui::{ - Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement, - MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, - Window, div, prelude::*, + Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, FontWeight, + InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use super::button::label_size; use crate::{ + layout::{on_axis, reveal_when_focused}, motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide}, - primitives::{FocusRing, Icon, IconName}, + primitives::{FocusRing, Icon, IconName, tab_stop}, theme::{ActiveTheme, ControlSize, Elevation, Radius}, }; type OnChange = Rc; -/// Mutually exclusive segments. A thumb slides to the chosen one. +/// Mutually exclusive segments. A thumb slides to the chosen one; too narrow for them, the strip scrolls sideways and a focused segment comes into view. #[derive(IntoElement)] pub struct SegmentedControl { id: ElementId, @@ -72,6 +73,29 @@ impl RenderOnce for SegmentedControl { .position(|value| *value == self.selected) .unwrap_or_else(|| panic!("segmented control has no segment {}", self.selected)); let (state, marker) = slide(self.id.clone(), &values, &self.selected, window, cx); + let scroll = window + .use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) + .read(cx) + .clone(); + let mut stops: Vec<(FocusHandle, AnyElement)> = (0..values.len()) + .map(|ix| { + let focus = tab_stop( + (self.id.clone(), format!("segment-{ix}")).into(), + true, + window, + cx, + ); + let reveal = reveal_when_focused( + (self.id.clone(), format!("reveal-{ix}")), + &scroll, + &focus, + gpui::Axis::Horizontal, + window, + cx, + ); + (focus, reveal.into_any_element()) + }) + .collect(); let theme = cx.theme(); let colors = &theme.colors; let (text, icon_size) = label_size(self.size); @@ -108,8 +132,10 @@ impl RenderOnce for SegmentedControl { .map(|(ix, (value, label, icon))| { let on = ix == chosen; let fg = if on { colors.fg } else { colors.fg_muted }; + let (focus, reveal) = stops.remove(0); + let named = value.clone(); let (change, measure) = (self.on_change.clone(), state.clone()); - div() + let segment = div() .id(("segment", ix)) .relative() .flex() @@ -129,8 +155,9 @@ impl RenderOnce for SegmentedControl { FontWeight::MEDIUM }) .text_color(fg) + .debug_selector(move || format!("segment {named}")) .cursor_pointer() - .tab_index(0) + .track_focus(&focus) .focus_ring(cx) .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) .on_click(move |_, window, cx| { @@ -146,12 +173,20 @@ impl RenderOnce for SegmentedControl { segment.child(Icon::new(icon).size(icon_size).color(fg)) }) .when(!label.is_empty(), |segment| segment.child(label)) - .child(measure_item(measure, ix, Axis::Horizontal)) + .child(measure_item(measure, ix, Axis::Horizontal)); + div() + .relative() + .flex() + .flex_1() + .child(segment) + .child(reveal) }); - div() - .id(self.id) + let control = div() + .id(self.id.clone()) .relative() .flex() + .flex_grow() + .flex_shrink_0() .items_center() .p_0p5() .rounded(theme.radius(Radius::Lg)) @@ -160,6 +195,14 @@ impl RenderOnce for SegmentedControl { .border_color(colors.border) .child(measure_origin(state.clone(), Axis::Horizontal)) .children(thumb) - .children(segments) + .children(segments); + on_axis( + div() + .id((self.id, "strip")) + .flex() + .overflow_x_scroll() + .track_scroll(&scroll), + ) + .child(control) } } diff --git a/src/layout/mod.rs b/src/layout/mod.rs index f03db1b..b19afce 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -22,8 +22,8 @@ pub use grid::{Masonry, SimpleGrid}; pub(crate) use grid::{columns_for, fit}; pub use page::{AppShell, MasterDetail, Page}; pub use panes::{PaneGroup, PaneId, PaneLayout}; -pub(crate) use scroll::bring_into_view; pub use scroll::{ScrollArea, Scrollbar, on_axis}; +pub(crate) use scroll::{bring_into_view, reveal_when_focused}; pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader}; pub use sheet::{Drawer, Sheet}; pub use sidebar::Sidebar; diff --git a/src/layout/scroll.rs b/src/layout/scroll.rs index 1a2eae4..c49d1dc 100644 --- a/src/layout/scroll.rs +++ b/src/layout/scroll.rs @@ -5,9 +5,10 @@ use std::{ }; use gpui::{ - AnyElement, App, Axis, Bounds, Div, ElementId, EmptyView, Entity, EntityId, InteractiveElement, - IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, - StatefulInteractiveElement, StyleRefinement, Styled, Window, div, point, prelude::*, + AnyElement, App, Axis, Bounds, Canvas, Div, ElementId, EmptyView, Entity, EntityId, + FocusHandle, InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, + ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div, point, + prelude::*, }; use smallvec::SmallVec; @@ -39,6 +40,38 @@ pub(crate) fn bring_into_view(scroll: &ScrollHandle, item: Bounds, axis: shift != Pixels::ZERO } +/// A canvas over a Tab stop in a scroll box: when the stop takes focus, it brings its painted box into view along `axis`, once per focus. A new `id` reveals again. +pub(crate) fn reveal_when_focused( + id: impl Into, + scroll: &ScrollHandle, + focus: &FocusHandle, + axis: Axis, + window: &mut Window, + cx: &mut App, +) -> Canvas<()> { + let shown = window.use_keyed_state(id, cx, |_, _| false); + let (scroll, focus) = (scroll.clone(), focus.clone()); + canvas( + move |bounds, window, cx| { + let (focused, held) = (focus.is_focused(window), *shown.read(cx)); + if focused && !held { + shown.update(cx, |shown, _| *shown = true); + if bring_into_view(&scroll, bounds, axis) { + log::info!("scroll: a focused stop came into view"); + window.request_animation_frame(); + } + } else if !focused && held { + shown.update(cx, |shown, _| *shown = false); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + /// Keeps a scroll box's wheel to its own axes; gpui turns a wheel along the other axis onto a box that scrolls one way, while the page scrolls too. pub fn on_axis(mut element: E) -> E { element.style().restrict_scroll_to_axis = Some(true); diff --git a/src/motion/progress.rs b/src/motion/progress.rs index d6fdb7f..2e83a00 100644 --- a/src/motion/progress.rs +++ b/src/motion/progress.rs @@ -197,6 +197,11 @@ impl ProgressRing { /// Shows the value as a percent inside the ring. pub fn percent(mut self) -> Self { + assert!( + self.inside.is_none(), + "progress ring {:?}: a percent or content inside, not both", + self.id + ); self.percent = true; self } @@ -278,3 +283,16 @@ impl RenderOnce for ProgressRing { .children(self.inside) } } + +#[cfg(test)] +mod tests { + use gpui::div; + + use super::ProgressRing; + + #[test] + #[should_panic(expected = "a percent or content inside, not both")] + fn a_ring_holds_a_percent_or_content_not_both() { + let _ = ProgressRing::new("ring", 0.5).inside(div()).percent(); + } +} diff --git a/src/project/board.rs b/src/project/board.rs index dacec76..cdec4fe 100644 --- a/src/project/board.rs +++ b/src/project/board.rs @@ -1,14 +1,14 @@ use std::{collections::HashMap, rc::Rc}; use gpui::{ - AnyElement, App, Axis, Bounds, ElementId, EmptyView, Entity, EntityId, FocusHandle, - InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, - SharedString, StatefulInteractiveElement, Styled, Window, anchored, canvas, div, prelude::*, + AnyElement, App, Axis, Bounds, ElementId, EmptyView, EntityId, FocusHandle, InteractiveElement, + IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, SharedString, + StatefulInteractiveElement, Styled, Window, anchored, canvas, div, prelude::*, }; use super::moves::{landing, stepped}; use crate::{ - layout::{bring_into_view, on_axis}, + layout::{on_axis, reveal_when_focused}, motion::Flip, primitives::{FocusRing, raise, tab_stop}, theme::{ActiveTheme, Elevation, Radius, TextSize}, @@ -106,39 +106,6 @@ fn measure( .size_full() } -/// The card shown after focus, by key and column. -type Revealed = Option<(SharedString, SharedString)>; - -/// Scrolls the board so a card that takes focus shows, once per focus and again in a new column. -fn reveal( - scroll: &ScrollHandle, - revealed: &Entity, - focus: &FocusHandle, - place: (SharedString, SharedString), -) -> impl IntoElement { - let (scroll, revealed, focus) = (scroll.clone(), revealed.clone(), focus.clone()); - canvas( - move |bounds, window, cx| { - let focused = focus.is_focused(window); - let held = revealed.read(cx).clone(); - if focused && held.as_ref() != Some(&place) { - revealed.update(cx, |revealed, _| *revealed = Some(place.clone())); - if bring_into_view(&scroll, bounds, Axis::Horizontal) { - log::info!("board: {} scrolled into view", place.0); - window.request_animation_frame(); - } - } else if !focused && held.is_some_and(|(key, _)| key == place.0) { - revealed.update(cx, |revealed, _| *revealed = None); - } - }, - |_, _, _, _| {}, - ) - .absolute() - .top_0() - .left_0() - .size_full() -} - /// Columns of cards: a card drags to a place in any column, the others gliding aside, and Option with an arrow moves the focused one. Each move asks the owner through `on_move(card, column, index)`, the index counted without the card. #[derive(IntoElement)] pub struct KanbanBoard { @@ -203,7 +170,6 @@ impl RenderOnce for KanbanBoard { .use_keyed_state((id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) .read(cx) .clone(); - let revealed = window.use_keyed_state((id.clone(), "revealed"), cx, |_, _| None); let owner = hold.entity_id(); let stale = hold .read(cx) @@ -244,6 +210,15 @@ impl RenderOnce for KanbanBoard { .map(|column| column.cards.len()) .collect(), ); + let mut reveals: HashMap = places + .iter() + .map(|(key, (column, _))| { + let place = (id.clone(), format!("reveal-{key}-{}", keys[*column])); + let reveal = + reveal_when_focused(place, &scroll, &focus[key], Axis::Horizontal, window, cx); + (key.clone(), reveal.into_any_element()) + }) + .collect(); let theme = cx.theme(); let colors = &theme.colors; let width = theme.project().column; @@ -345,12 +320,12 @@ impl RenderOnce for KanbanBoard { measures.cards.insert(key.clone(), bounds) != Some(bounds) } })); - let place = (key.clone(), column.key.clone()); + let reveal = reveals.remove(&key).expect("each card has a reveal"); let row = div() .relative() .pb_2() .child(face) - .child(reveal(&scroll, &revealed, &handle, place)) + .child(reveal) .child(measure(&row_measures, { let key = key.clone(); move |measures, bounds| { diff --git a/src/project/database.rs b/src/project/database.rs index 25635d6..c62618b 100644 --- a/src/project/database.rs +++ b/src/project/database.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, + AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, }; use jiff::civil::Date; @@ -18,7 +18,7 @@ use crate::{ calendar::{CalendarMonthView, Event}, data_display::Tone, forms::OnValue, - layout::{on_axis, seeded::use_seeded}, + layout::seeded::use_seeded, primitives::IconName, tables::{Cell, Column, DataTable, Row}, theme::{ActiveTheme, ControlSize, TextSize}, @@ -328,11 +328,7 @@ impl RenderOnce for DatabaseView { .items_center() .justify_between() .gap_2() - .child( - on_axis(div().id((id.clone(), "switch")).overflow_x_scroll()) - .min_w_0() - .child(switch), - ) + .child(switch) .child( div() .text_size(theme.text_size(TextSize::Sm)) diff --git a/src/project/tests/boards.rs b/src/project/tests/boards.rs index c37fc2f..4f9e914 100644 --- a/src/project/tests/boards.rs +++ b/src/project/tests/boards.rs @@ -20,7 +20,7 @@ fn card(key: &str) -> KanbanCard { KanbanCard::new(SharedString::from(key.to_string()), face) } -fn board(owner: Entity) -> AnyElement { +fn board(_: &Desk, owner: Entity) -> AnyElement { let (moved, opened) = (owner.clone(), owner); KanbanBoard::new("board") .column( @@ -92,10 +92,10 @@ fn option_arrows_move_the_focused_card_and_enter_opens_it(cx: &mut TestAppContex ); } -fn narrow(owner: Entity) -> AnyElement { +fn narrow(desk: &Desk, owner: Entity) -> AnyElement { gpui::div() .w(px(300.0)) - .child(board(owner)) + .child(board(desk, owner)) .into_any_element() } @@ -111,7 +111,7 @@ fn a_focused_card_past_the_fold_scrolls_into_view(cx: &mut TestAppContext) { ); } -fn labels(owner: Entity) -> AnyElement { +fn labels(_: &Desk, owner: Entity) -> AnyElement { let (renamed, made) = (owner.clone(), owner); LabelManager::new( "labels", @@ -149,7 +149,7 @@ fn a_label_keeps_a_name_of_its_own(cx: &mut TestAppContext) { assert_eq!(heard(&host, cx), ["rename bug Defect", "make bug report"]); } -fn panel(owner: Entity) -> AnyElement { +fn panel(_: &Desk, owner: Entity) -> AnyElement { let task = Task::new("brief", "Write the brief").status(Status::Todo); TaskDetailPanel::new("panel", task, [Person::new("ana", "Ana Lima")]) .on_change(move |task, _, cx| { @@ -187,7 +187,7 @@ fn the_panel_hands_on_the_task_after_each_edit(cx: &mut TestAppContext) { ); } -fn roadmap(_: Entity) -> AnyElement { +fn roadmap(_: &Desk, _: Entity) -> AnyElement { crate::project::Roadmap::new("roadmap", date(2026, 9, 1), 2) .lane( "Design", diff --git a/src/project/tests/mod.rs b/src/project/tests/mod.rs index 1019f83..e3377a0 100644 --- a/src/project/tests/mod.rs +++ b/src/project/tests/mod.rs @@ -2,6 +2,9 @@ use gpui::{ AnyElement, Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, }; +use std::time::Instant; + +use gpui::SharedString; use jiff::civil::date; use super::{AssigneePicker, IssueId, Person, Priority, Status, StatusSelect, Task, TaskList}; @@ -12,13 +15,14 @@ mod time; /// A view that shows one project part and keeps what it heard. struct Desk { - part: fn(Entity) -> AnyElement, + part: fn(&Desk, Entity) -> AnyElement, heard: Vec, + clock: Option<(SharedString, Instant)>, } impl Render for Desk { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - div().w(px(640.0)).child((self.part)(cx.entity())) + div().w(px(640.0)).child((self.part)(self, cx.entity())) } } @@ -31,7 +35,7 @@ fn settle(cx: &mut VisualTestContext) { } fn desk( - part: fn(Entity) -> AnyElement, + part: fn(&Desk, Entity) -> AnyElement, cx: &mut TestAppContext, ) -> (Entity, &mut VisualTestContext) { cx.update(|cx| { @@ -43,6 +47,7 @@ fn desk( let (host, cx) = cx.add_window_view(|_, _| Desk { part, heard: Vec::new(), + clock: None, }); cx.update(|window, _| window.activate_window()); settle(cx); @@ -92,7 +97,7 @@ fn tasks() -> Vec { ] } -fn list(owner: Entity) -> AnyElement { +fn list(_: &Desk, owner: Entity) -> AnyElement { let (opened, ticked) = (owner.clone(), owner); TaskList::new("tasks", tasks()) .today(date(2026, 9, 27)) @@ -128,7 +133,7 @@ fn enter_opens_the_task_under_the_cursor(cx: &mut TestAppContext) { assert_eq!(heard(&host, cx), ["open brief", "open specs"]); } -fn status(owner: Entity) -> AnyElement { +fn status(_: &Desk, owner: Entity) -> AnyElement { StatusSelect::new("status", Status::Todo) .on_change(move |status, _, cx| note(&owner, status.words().into(), cx)) .into_any_element() @@ -143,7 +148,7 @@ fn a_status_picked_by_key_hands_on_its_status(cx: &mut TestAppContext) { assert_eq!(heard(&host, cx), ["In progress"]); } -fn assignee(owner: Entity) -> AnyElement { +fn assignee(_: &Desk, owner: Entity) -> AnyElement { let people = [ Person::new("ana", "Ana Lima"), Person::new("ben", "Ben Ito"), @@ -169,7 +174,7 @@ fn the_picker_hands_on_a_person_or_no_one(cx: &mut TestAppContext) { assert_eq!(heard(&host, cx), ["Ben Ito", "no one"]); } -fn checklist(owner: Entity) -> AnyElement { +fn checklist(_: &Desk, owner: Entity) -> AnyElement { Checklist::new( "subtasks", "Subtasks", diff --git a/src/project/tests/time.rs b/src/project/tests/time.rs index 4bacc82..8fa2c28 100644 --- a/src/project/tests/time.rs +++ b/src/project/tests/time.rs @@ -1,12 +1,14 @@ use std::time::Duration; -use gpui::{AnyElement, Entity, IntoElement, TestAppContext}; +use gpui::{ + AnyElement, Entity, InteractiveElement, IntoElement, ParentElement, Styled, TestAppContext, +}; use jiff::civil::date; use super::{Desk, desk, heard, note, press, settle, tab_to}; use crate::project::{DatabaseView, PomodoroTimer, Status, Task, TimeEntry, TimeTracker}; -fn pomodoro(owner: Entity) -> AnyElement { +fn pomodoro(_: &Desk, owner: Entity) -> AnyElement { PomodoroTimer::new("pomodoro") .minutes(2, 1, 3) .rounds(2) @@ -34,24 +36,62 @@ fn a_paused_phase_keeps_its_time_and_one_run_out_hands_on_the_next(cx: &mut Test assert_eq!(heard(&host, cx), ["Short break"]); } -fn tracker(owner: Entity) -> AnyElement { +fn tracker(desk: &Desk, owner: Entity) -> AnyElement { let entry = TimeEntry::new("e1", "Brief", Duration::from_secs(600)); - TimeTracker::new("tracker", [entry]) - .on_start(move |task, _, _, cx| note(&owner, format!("start {task}"), cx)) - .into_any_element() + let (started, stopped) = (owner.clone(), owner); + let tracker = TimeTracker::new("tracker", [entry]) + .on_start(move |task, at, _, cx| { + started.update(cx, |desk, cx| { + desk.heard.push(format!("start {task}")); + desk.clock = Some((task.clone(), at)); + cx.notify(); + }) + }) + .on_stop(move |at, _, cx| { + stopped.update(cx, |desk, cx| { + let (task, since) = desk.clock.take().expect("a task runs"); + desk.heard.push(format!( + "stop {task} {}s", + at.duration_since(since).as_secs() + )); + cx.notify(); + }) + }); + match &desk.clock { + Some((task, since)) => tracker.running(task.clone(), *since), + None => tracker, + } + .into_any_element() } #[gpui::test] -fn a_named_task_starts_on_enter_and_a_blank_one_does_not(cx: &mut TestAppContext) { +fn enter_starts_and_stops_and_the_field_takes_the_keys_again(cx: &mut TestAppContext) { let (host, cx) = desk(tracker, cx); tab_to(1, cx); press("enter", cx); cx.simulate_input(" Site photos "); press("enter", cx); - assert_eq!(heard(&host, cx), ["start Site photos"]); + cx.executor().advance_clock(Duration::from_secs(90)); + settle(cx); + press("enter", cx); + cx.simulate_input("Budget"); + press("enter", cx); + assert_eq!( + heard(&host, cx), + ["start Site photos", "stop Site photos 90s", "start Budget"], + "a blank start does nothing, and focus follows each turn" + ); } -fn database(owner: Entity) -> AnyElement { +#[gpui::test] +fn skip_moves_on_at_once(cx: &mut TestAppContext) { + let (host, cx) = desk(pomodoro, cx); + tab_to(3, cx); + press("space", cx); + assert_eq!(heard(&host, cx), ["Short break"]); +} + +fn database(_: &Desk, owner: Entity) -> AnyElement { let tasks = [ Task::new("a", "Alpha") .starts(date(2026, 9, 20)) @@ -84,3 +124,25 @@ fn the_switch_shows_each_way_and_the_board_moves_a_record(cx: &mut TestAppContex press("alt-right", cx); assert_eq!(heard(&host, cx), ["a In progress"]); } + +fn narrow_database(desk: &Desk, owner: Entity) -> AnyElement { + gpui::div() + .debug_selector(|| "narrow".into()) + .w(gpui::px(280.0)) + .child(database(desk, owner)) + .into_any_element() +} + +#[gpui::test] +fn a_focused_segment_past_the_fold_comes_into_view(cx: &mut TestAppContext) { + let (_, cx) = desk(narrow_database, cx); + tab_to(5, cx); + let frame = cx.debug_bounds("narrow").expect("the box draws"); + let segment = cx + .debug_bounds("segment Timeline") + .expect("the segment draws"); + assert!( + frame.left() <= segment.left() && segment.right() <= frame.right(), + "Timeline at {segment:?} inside {frame:?}" + ); +} diff --git a/src/project/tracker.rs b/src/project/tracker.rs index 3a801f1..7aeefb2 100644 --- a/src/project/tracker.rs +++ b/src/project/tracker.rs @@ -11,7 +11,7 @@ use gpui::{ use crate::{ buttons::{Button, ButtonVariant}, forms::{Enter, Input, Run, TextInput}, - primitives::IconName, + primitives::{IconName, tab_stop}, theme::{ActiveTheme, TextSize}, typography::{Ellipsis, format, tabular}, }; @@ -50,7 +50,7 @@ pub(crate) fn spent(duration: Duration) -> String { } } -/// A timer against a task: a field names it, Start or Enter starts it and Stop ends it, the running time counting up; under it, the entries made and their total. The owner keeps what runs and what was spent. +/// A timer against a task: a field names it, Start or Enter's release starts it and Stop ends it, the running time counting up; under it, the entries made and their total. The owner keeps what runs and what was spent. #[derive(IntoElement)] pub struct TimeTracker { id: ElementId, @@ -116,8 +116,10 @@ impl RenderOnce for TimeTracker { .running .as_ref() .map_or(Duration::ZERO, |(_, since)| now.duration_since(*since)); + let action = tab_stop((id.clone(), "action").into(), true, window, cx); let start: Run = { - let (id, field, on_start) = (id.clone(), field.clone(), self.on_start); + let (id, field, on_start, action) = + (id.clone(), field.clone(), self.on_start, action.clone()); Rc::new(move |window, cx| { let task = SharedString::from(field.read(cx).text().trim().to_string()); if task.is_empty() { @@ -127,54 +129,70 @@ impl RenderOnce for TimeTracker { let now = cx.background_executor().now(); log::info!("time tracker {id:?}: start {task}"); field.update(cx, |input, cx| input.set_text(String::new(), cx)); + window.focus(&action); if let Some(on_start) = &on_start { on_start(&task, now, window, cx); } }) }; - let theme = cx.theme(); - let colors = &theme.colors; - let (task, action) = match &self.running { - Some((task, _)) => { - let (id, on_stop) = (id.clone(), self.on_stop.clone()); - ( - div() - .flex_1() - .min_w_0() - .child(Ellipsis::new(task.clone())) - .into_any_element(), - Button::new((self.id.clone(), "stop"), "Stop") - .icon(IconName::Square) - .on_click(move |_, window, cx| { - let now = cx.background_executor().now(); - log::info!("time tracker {id:?}: stop"); - if let Some(on_stop) = &on_stop { - on_stop(now, window, cx); - } - }) - .into_any_element(), - ) - } + let stop: Run = { + let (id, on_stop, typing) = ( + id.clone(), + self.on_stop.clone(), + field.read(cx).focus().clone(), + ); + Rc::new(move |window, cx| { + let now = cx.background_executor().now(); + log::info!("time tracker {id:?}: stop"); + window.focus(&typing); + if let Some(on_stop) = &on_stop { + on_stop(now, window, cx); + } + }) + }; + let running = self.running.is_some(); + let task = match &self.running { + Some((task, _)) => div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(task.clone())) + .into_any_element(), None => { - let (typed, pressed) = (start.clone(), start); - ( - div() - .flex_1() - .min_w_0() - .capture_action(move |_: &Enter, window, cx| { - cx.stop_propagation(); + let typed = start.clone(); + div() + .flex_1() + .min_w_0() + .capture_action(|_: &Enter, _, cx| cx.stop_propagation()) + .on_key_up(move |event, window, cx| { + if event.keystroke.key == "enter" { typed(window, cx) - }) - .child(Input::new(&field)) - .into_any_element(), - Button::new((self.id.clone(), "start"), "Start") - .variant(ButtonVariant::Primary) - .icon(IconName::Play) - .on_click(move |_, window, cx| pressed(window, cx)) - .into_any_element(), - ) + } + }) + .child(Input::new(&field)) + .into_any_element() } }; + let action = Button::new( + (id.clone(), "action"), + if running { "Stop" } else { "Start" }, + ) + .focus_handle(&action) + .variant(if running { + ButtonVariant::default() + } else { + ButtonVariant::Primary + }) + .icon(if running { + IconName::Square + } else { + IconName::Play + }) + .on_click(move |_, window, cx| match running { + true => stop(window, cx), + false => start(window, cx), + }); + let theme = cx.theme(); + let colors = &theme.colors; let clock = tabular(div()) .flex_none() .text_size(theme.text_size(TextSize::Lg))