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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
7.3 KiB
Rust
209 lines
7.3 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
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, 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; too narrow for them, the strip scrolls sideways and a focused segment comes into view.
|
|
#[derive(IntoElement)]
|
|
pub struct SegmentedControl {
|
|
id: ElementId,
|
|
segments: Vec<(SharedString, SharedString, Option<IconName>)>,
|
|
selected: SharedString,
|
|
size: ControlSize,
|
|
on_change: Option<OnChange>,
|
|
}
|
|
|
|
impl SegmentedControl {
|
|
pub fn new(id: impl Into<ElementId>, selected: impl Into<SharedString>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
segments: Vec::new(),
|
|
selected: selected.into(),
|
|
size: ControlSize::default(),
|
|
on_change: None,
|
|
}
|
|
}
|
|
|
|
pub fn segment(
|
|
mut self,
|
|
value: impl Into<SharedString>,
|
|
label: impl Into<SharedString>,
|
|
icon: Option<IconName>,
|
|
) -> Self {
|
|
self.segments.push((value.into(), label.into(), icon));
|
|
self
|
|
}
|
|
|
|
pub fn size(mut self, size: ControlSize) -> Self {
|
|
self.size = size;
|
|
self
|
|
}
|
|
|
|
pub fn on_change(
|
|
mut self,
|
|
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.on_change = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for SegmentedControl {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let values: Vec<SharedString> = self
|
|
.segments
|
|
.iter()
|
|
.map(|(value, ..)| value.clone())
|
|
.collect();
|
|
let chosen = values
|
|
.iter()
|
|
.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);
|
|
let duration = motion::duration(motion::SLOW, cx);
|
|
let thumb = marker.map(
|
|
|Marker {
|
|
from,
|
|
to,
|
|
generation,
|
|
}| {
|
|
div()
|
|
.absolute()
|
|
.top_0p5()
|
|
.bottom_0p5()
|
|
.rounded(theme.radius(Radius::Md))
|
|
.bg(colors.surface)
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.shadow(theme.elevation(Elevation::Raised))
|
|
.with_animation(
|
|
("segment-thumb", generation),
|
|
Animation::new(duration),
|
|
move |thumb, t| {
|
|
let (left, width) = glide(from, to, t);
|
|
thumb.left(left).w(width)
|
|
},
|
|
)
|
|
},
|
|
);
|
|
let segments = self
|
|
.segments
|
|
.into_iter()
|
|
.enumerate()
|
|
.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());
|
|
let segment = div()
|
|
.id(("segment", ix))
|
|
.relative()
|
|
.flex()
|
|
.flex_1()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap_1p5()
|
|
.h(theme.control_height(self.size))
|
|
.px(theme.control_padding(self.size))
|
|
.rounded(theme.radius(Radius::Md))
|
|
.border_1()
|
|
.border_color(gpui::transparent_black())
|
|
.text_size(theme.text_size(text))
|
|
.font_weight(if on {
|
|
FontWeight::SEMIBOLD
|
|
} else {
|
|
FontWeight::MEDIUM
|
|
})
|
|
.text_color(fg)
|
|
.debug_selector(move || format!("segment {named}"))
|
|
.cursor_pointer()
|
|
.track_focus(&focus)
|
|
.focus_ring(cx)
|
|
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
|
.on_click(move |_, window, cx| {
|
|
if on {
|
|
return;
|
|
}
|
|
log::info!("segmented control: {value}");
|
|
if let Some(change) = &change {
|
|
change(&value, window, cx);
|
|
}
|
|
})
|
|
.when_some(icon, |segment, icon| {
|
|
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));
|
|
div()
|
|
.relative()
|
|
.flex()
|
|
.flex_1()
|
|
.child(segment)
|
|
.child(reveal)
|
|
});
|
|
let control = div()
|
|
.id(self.id.clone())
|
|
.relative()
|
|
.flex()
|
|
.flex_grow()
|
|
.flex_shrink_0()
|
|
.items_center()
|
|
.p_0p5()
|
|
.rounded(theme.radius(Radius::Lg))
|
|
.bg(colors.sunken)
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.child(measure_origin(state.clone(), Axis::Horizontal))
|
|
.children(thumb)
|
|
.children(segments);
|
|
on_axis(
|
|
div()
|
|
.id((self.id, "strip"))
|
|
.flex()
|
|
.overflow_x_scroll()
|
|
.track_scroll(&scroll),
|
|
)
|
|
.child(control)
|
|
}
|
|
}
|