Project · focus that follows: segments, the tracker's action, tests
A segmented control too narrow for its segments now scrolls sideways in a strip of its own, and a segment that takes focus comes into view: its focus is a tab_stop handle, and layout::reveal_when_focused, now shared with the board's cards, measures it from a box that holds its ring. The DatabaseView's own strip goes. The time tracker's Start and Stop are one Button that keeps one handle through the new Button::focus_handle. A start moves focus to it, a stop hands focus back to the field, and the field starts on Enter's release, so the release no longer clicks the Stop that took focus. ProgressRing's percent refuses content inside, as content refuses a percent. Tests: Enter starts, Enter stops after 90 seconds, and the field takes the keys again; Skip moves on at once; a focused Timeline segment at 280px comes into view; the ring's refusal.
This commit is contained in:
1 parent
f70726476a
commit
384872cbf0
13 files changed
+294
-132
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
+14
-4
@@ -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<FocusHandle>,
|
||||
pub(crate) slot: Option<Slot>,
|
||||
on_click: Option<ClickHandler>,
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
+54
-11
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+36
-3
@@ -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<Pixels>, 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<ElementId>,
|
||||
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<E: Styled>(mut element: E) -> E {
|
||||
element.style().restrict_scroll_to_axis = Some(true);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+15
-40
@@ -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<Revealed>,
|
||||
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<SharedString, AnyElement> = 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| {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -20,7 +20,7 @@ fn card(key: &str) -> KanbanCard {
|
||||
KanbanCard::new(SharedString::from(key.to_string()), face)
|
||||
}
|
||||
|
||||
fn board(owner: Entity<Desk>) -> AnyElement {
|
||||
fn board(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn narrow(desk: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn labels(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn panel(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn roadmap(_: &Desk, _: Entity<Desk>) -> AnyElement {
|
||||
crate::project::Roadmap::new("roadmap", date(2026, 9, 1), 2)
|
||||
.lane(
|
||||
"Design",
|
||||
|
||||
@@ -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<Desk>) -> AnyElement,
|
||||
part: fn(&Desk, Entity<Desk>) -> AnyElement,
|
||||
heard: Vec<String>,
|
||||
clock: Option<(SharedString, Instant)>,
|
||||
}
|
||||
|
||||
impl Render for Desk {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Desk>) -> AnyElement,
|
||||
part: fn(&Desk, Entity<Desk>) -> AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Desk>, &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<Task> {
|
||||
]
|
||||
}
|
||||
|
||||
fn list(owner: Entity<Desk>) -> AnyElement {
|
||||
fn list(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn status(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn assignee(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn checklist(_: &Desk, owner: Entity<Desk>) -> AnyElement {
|
||||
Checklist::new(
|
||||
"subtasks",
|
||||
"Subtasks",
|
||||
|
||||
@@ -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<Desk>) -> AnyElement {
|
||||
fn pomodoro(_: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> AnyElement {
|
||||
fn tracker(desk: &Desk, owner: Entity<Desk>) -> 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<Desk>) -> 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<Desk>) -> 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<Desk>) -> 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:?}"
|
||||
);
|
||||
}
|
||||
+60
-42
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user