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:
ZacharyZhang-NY committed 2026-09-27 06:32:50 -04:00
1 parent f70726476a
commit 384872cbf0
13 files changed
+294 -132

No files matched your search

+14 -4
View File
@@ -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
View File
@@ -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)
}
}