chore: audit components

This commit is contained in:
2026-11-10 15:06:16 +07:00
parent af2177c6c8
commit 03369f501e
92 changed files with 2744 additions and 1947 deletions
+21 -18
View File
@@ -3,13 +3,16 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, deferred, div, ease_out_quint, px,
IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, deferred, div, ease_out_quint,
};
use gpui_native_icons::{Icon, IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, ENTER_SLIDE_DISTANCE, Size};
use gpui_native_utils::ResultLogErr as _;
use crate::button::{Button, ButtonVariant};
use crate::common::DismissHandler;
use crate::common::Sizable as _;
use crate::common::v_flex;
/// How alarming an [`Alert`] is; picks the icon and the accent color.
@@ -28,6 +31,7 @@ pub enum AlertSeverity {
/// A static callout banner.
#[derive(Clone, IntoElement)]
#[must_use]
pub struct Alert {
id: ElementId,
title: Option<SharedString>,
@@ -129,7 +133,6 @@ impl RenderOnce for Alert {
let foreground = theme.colors.foreground;
let muted_foreground = theme.colors.muted_foreground;
let muted = theme.colors.muted;
let surface = theme.colors.surface;
let border = if theme.borders.overlays {
@@ -145,6 +148,8 @@ impl RenderOnce for Alert {
let caption_size = metrics.caption_size;
div()
.id(self.id.clone())
.role(Role::Alert)
.w_full()
.flex()
.items_start()
@@ -197,18 +202,11 @@ impl RenderOnce for Alert {
)
.when_some(self.on_dismiss, |alert, on_dismiss| {
alert.child(
div()
.id((self.id, "alert-close"))
.flex_none()
.cursor_pointer()
.p_1()
.rounded(theme.radius.sm)
.hover(|style| style.bg(muted))
.child(
icon(IconRole::Close)
.size(theme.sizes.small.dense_icon_size)
.color(theme.colors.muted_foreground),
)
Button::new((self.id.clone(), "alert-close"))
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.accessibility_label("Dismiss")
.on_click(move |_, _window, cx| on_dismiss(cx)),
)
})
@@ -240,7 +238,9 @@ impl Alerts {
let stack = cx.entity().downgrade();
alert.on_dismiss = Some(Rc::new(move |cx: &mut App| {
stack.update(cx, |alerts, cx| alerts.dismiss(id, cx)).ok();
stack
.update(cx, |alerts, cx| alerts.dismiss(id, cx))
.log_err();
}));
self.entries.push(AlertEntry {
@@ -294,6 +294,7 @@ impl Alerts {
/// The floating stack of pushed alerts, topmost right.
#[derive(IntoElement)]
#[must_use]
pub struct AlertsView {
alerts: Entity<Alerts>,
id: ElementId,
@@ -341,7 +342,9 @@ impl RenderOnce for AlertsView {
.with_animation(
("alert-enter", id),
Animation::new(enter_duration),
move |layer, delta| layer.left(px(24. * (1. - ease_slide(delta)))),
move |layer, delta| {
layer.left(ENTER_SLIDE_DISTANCE * (1. - ease_slide(delta)))
},
)
})),
)
+4 -3
View File
@@ -24,6 +24,7 @@ const MIN_FILLED: usize = 5;
/// An avatar for a user or other entity.
#[derive(IntoElement)]
#[must_use]
pub struct Avatar {
id: SharedString,
name: Option<SharedString>,
@@ -210,9 +211,9 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
break;
}
let ix = (start + offset) % total;
let row = MARGIN + ix / art_columns;
let col = MARGIN + ix % art_columns;
let flat_index = (start + offset) % total;
let row = MARGIN + flat_index / art_columns;
let col = MARGIN + flat_index % art_columns;
if pattern[row * GRID_SIZE + col] == 0 {
set_cell(&mut pattern, row, col, 1);
+1
View File
@@ -71,6 +71,7 @@ impl BadgeVariant {
/// A small pill for a count or a state.
#[derive(IntoElement)]
#[must_use]
pub struct Badge {
id: ElementId,
label: SharedString,
+1
View File
@@ -35,6 +35,7 @@ pub enum ButtonVariant {
/// A push button: pointer, Enter, and Space all fire the single `on_click`.
#[derive(IntoElement)]
#[must_use]
pub struct Button {
id: ElementId,
label: Option<SharedString>,
+1
View File
@@ -16,6 +16,7 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
/// A controlled checkbox component.
#[derive(IntoElement)]
#[must_use]
pub struct Checkbox {
id: ElementId,
label: Option<SharedString>,
+4 -2
View File
@@ -10,6 +10,7 @@ use gpui::{
};
use gpui_native_icons::IconRole;
use gpui_native_theme::Size;
use gpui_native_utils::ResultLogErr as _;
use crate::button::{Button, ButtonVariant};
use crate::common::Sizable;
@@ -25,6 +26,7 @@ type CopiedHandler = Box<dyn Fn(&mut App)>;
/// A quiet icon button that copies a value to the system clipboard.
#[derive(IntoElement)]
#[must_use]
pub struct ClipboardButton {
id: ElementId,
value: SharedString,
@@ -153,9 +155,9 @@ impl RenderOnce for ClipboardButton {
cx.notify();
}
})
.ok();
.log_err();
})
.ok();
.log_err();
});
copied.update(cx, |state, _| {
+33 -17
View File
@@ -16,7 +16,9 @@ use gpui::{
Window, actions, anchored, canvas, deferred, div, point, px,
};
use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
use gpui_native_theme::ActiveTheme as _;
use gpui_native_theme::{BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
use gpui_native_utils::ResultLogErr as _;
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
use crate::input::{Input, InputState, InputStateEvent};
@@ -67,6 +69,7 @@ pub type ComboboxCreateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>
pub type ComboboxFilter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
/// Builder for a combobox. Pass it to [`ComboboxState::new`].
#[must_use]
pub struct Combobox<T>
where
T: Clone + PartialEq + 'static,
@@ -109,7 +112,10 @@ where
}
/// Sets the initially selected value; its label seeds the field.
pub fn selected(mut self, selected: impl Into<Option<T>>) -> Self {
pub fn selected<S>(mut self, selected: S) -> Self
where
S: Into<Option<T>>,
{
self.selected = selected.into();
self
}
@@ -337,7 +343,8 @@ where
if let Some(on_change) = self.on_change.clone() {
let value = value.clone();
let task = window.spawn(cx, async move |cx| {
cx.update(|window, cx| on_change(value, window, cx)).ok();
cx.update(|window, cx| on_change(value, window, cx))
.log_err();
});
self.tasks.push(task);
}
@@ -404,7 +411,8 @@ where
{
let text = SharedString::from(text);
let task = window.spawn(cx, async move |cx| {
cx.update(|window, cx| on_create(text, window, cx)).ok();
cx.update(|window, cx| on_create(text, window, cx))
.log_err();
});
self.tasks.push(task);
}
@@ -418,17 +426,20 @@ where
if self.visible.is_empty() {
return;
}
let current = self.highlight.filter(|row| *row < self.visible.len());
let count = self.visible.len();
let current = self.highlight.filter(|row| *row < count);
let next = match current {
Some(row) => {
let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32);
row as usize
}
Some(row) => match row.checked_add_signed(isize::try_from(delta).unwrap_or(0)) {
Some(next) if next < count => next,
Some(_) => 0,
None => count - 1,
},
None => {
if delta > 0 {
0
} else {
self.visible.len() - 1
count - 1
}
}
};
@@ -581,19 +592,24 @@ where
.visible
.iter()
.enumerate()
.map(|(row, option_ix)| {
let (value, label) = &self.options[*option_ix];
.map(|(row, option_index)| {
let Some((value, label)) = self.options.get(*option_index) else {
return div().into_any_element();
};
let highlighted = self.highlight == Some(row);
let checked = self.selected.as_ref() == Some(value);
let state = state.clone();
let row_ix = row;
let row_index = row;
div()
.id(("option", row))
.id(("option", *option_index))
.debug_selector({
let id = id.clone();
move || format!("{id}-option-{row}")
})
.role(Role::ListBoxOption)
.aria_label(label.clone())
.flex()
.items_center()
.gap_2()
@@ -605,9 +621,9 @@ where
.on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
if state.read(cx).highlight != Some(row_ix) {
if state.read(cx).highlight != Some(row_index) {
state.update(cx, |state, cx| {
state.highlight = Some(row_ix);
state.highlight = Some(row_index);
cx.notify();
});
}
@@ -617,7 +633,7 @@ where
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.choose_row(row_ix, window, cx)
state.choose_row(row_index, window, cx)
});
}
})
+25 -9
View File
@@ -39,8 +39,9 @@ pub fn bind_command_palette_keys(cx: &mut App) {
]);
}
/// One action the palette can run: a stable id and the label the matcher sees.
/// A command the palette can run: a stable id and the label the matcher sees.
#[derive(Clone)]
#[must_use]
pub struct Command {
/// The stable domain id handed to `on_execute`.
pub id: SharedString,
@@ -54,7 +55,11 @@ pub struct Command {
impl Command {
/// Creates a command without a hint or icon.
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
pub fn new<I, L>(id: I, label: L) -> Self
where
I: Into<SharedString>,
L: Into<SharedString>,
{
Self {
id: id.into(),
label: label.into(),
@@ -73,7 +78,10 @@ impl Command {
}
/// Sets the row's right-edge hint.
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
pub fn hint<H>(mut self, hint: H) -> Self
where
H: Into<SharedString>,
{
self.hint = Some(hint.into());
self
}
@@ -85,6 +93,7 @@ pub type CommandMatcher = Rc<dyn Fn(&str, &[Command]) -> Vec<Command>>;
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
#[must_use]
pub struct CommandPalette {
id: ElementId,
name: SharedString,
@@ -261,10 +270,15 @@ impl CommandPaletteState {
if self.matches.is_empty() {
return;
}
let count = self.matches.len();
let next = match self.highlight {
Some(row) => (row as i32 + delta).rem_euclid(self.matches.len() as i32) as usize,
Some(row) => match row.checked_add_signed(isize::try_from(delta).unwrap_or(0)) {
Some(next) if next < count => next,
Some(_) => 0,
None => count - 1,
},
None if delta > 0 => 0,
None => self.matches.len() - 1,
None => count - 1,
};
self.highlight = Some(next);
cx.notify();
@@ -333,7 +347,7 @@ impl Render for CommandPaletteState {
.map(|(row, command)| {
let highlighted = self.highlight == Some(row);
let state = state.clone();
let row_ix = row;
let row_index = row;
h_flex()
.id((ElementId::from(command.id.clone()), "command"))
@@ -341,6 +355,8 @@ impl Render for CommandPaletteState {
let id = id.clone();
move || format!("{id}-command-{row}")
})
.role(Role::ListBoxOption)
.aria_label(command.label.clone())
.gap_2()
.px_2()
.py_1()
@@ -350,9 +366,9 @@ impl Render for CommandPaletteState {
.on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
if state.read(cx).highlight != Some(row_ix) {
if state.read(cx).highlight != Some(row_index) {
state.update(cx, |state, cx| {
state.highlight = Some(row_ix);
state.highlight = Some(row_index);
cx.notify();
});
}
@@ -361,7 +377,7 @@ impl Render for CommandPaletteState {
.on_click({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
state.update(cx, |state, cx| state.choose_row(row_index, window, cx));
}
})
.when_some(command.icon.clone(), |row, icon| {
+3
View File
@@ -12,7 +12,10 @@ type TargetFn = Box<dyn Fn(&mut Window, &mut App) -> gpui::AnyElement>;
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
/// A menu that opens at the pointer when its target is right-clicked.
///
/// Open state is owned internally by design, unlike the controlled [`Menu`].
#[derive(IntoElement)]
#[must_use]
pub struct ContextMenu {
id: ElementId,
target: Option<TargetFn>,
+1
View File
@@ -19,6 +19,7 @@ type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
/// A centred panel over a dim scrim: the platform's modal.
#[derive(IntoElement)]
#[must_use]
pub struct Dialog {
id: ElementId,
open: bool,
+1
View File
@@ -26,6 +26,7 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
/// A push button with an attached dropdown menu.
#[derive(IntoElement)]
#[must_use]
pub struct DropdownMenu {
id: ElementId,
label: Option<SharedString>,
+1
View File
@@ -10,6 +10,7 @@ use crate::common::{StyledExt, v_flex};
/// The centered "nothing here yet" panel for lists, tables, and searches.
#[derive(IntoElement)]
#[must_use]
pub struct EmptyState {
icon: Option<Icon>,
headline: SharedString,
+1
View File
@@ -21,6 +21,7 @@ pub enum FieldVariant {
/// A labeled control: the label, description, and error around any element.
#[derive(IntoElement)]
#[must_use]
pub struct Field {
id: ElementId,
label: SharedString,
+11 -4
View File
@@ -570,7 +570,10 @@ impl InputState {
}
/// Creates the state entity with initial text, caret at the end.
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
pub fn new_with_text<T>(text: T, cx: &mut App) -> Entity<Self>
where
T: Into<String>,
{
let text = sanitize(text.into(), false);
cx.new(|cx| {
let mut state = Self::new_inner(cx);
@@ -1006,7 +1009,7 @@ impl InputState {
start = end + '\n'.len_utf8();
}
}
Err(err) => eprintln!("shaping input text failed: {err}"),
Err(err) => log::warn!("shaping input text failed: {err}"),
}
ShapedText { lines, mask_map }
@@ -1186,7 +1189,7 @@ impl InputState {
window,
cx,
) {
eprintln!("painting input text failed: {err}");
log::warn!("painting input text failed: {err}");
}
line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32;
}
@@ -1838,6 +1841,7 @@ impl EntityInputHandler for InputState {
/// A text field bound to an application-owned [`InputState`].
#[derive(IntoElement)]
#[must_use]
pub struct Input {
id: ElementId,
state: Entity<InputState>,
@@ -2470,7 +2474,10 @@ fn real_index(mask_map: &Option<Vec<(usize, usize)>>, display: usize) -> usize {
/// Newlines have no meaning in a single-line field; multiline fields keep
/// them and drop carriage returns.
fn sanitize(text: impl Into<String>, multiline: bool) -> String {
fn sanitize<T>(text: T, multiline: bool) -> String
where
T: Into<String>,
{
let text = text.into();
if multiline {
text.replace('\r', "")
+1
View File
@@ -13,6 +13,7 @@ type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
/// Link component.
#[derive(IntoElement)]
#[must_use]
pub struct Link {
id: ElementId,
label: Option<SharedString>,
+63 -44
View File
@@ -12,7 +12,7 @@ use gpui::{
canvas, deferred, div, ease_out_quint, point, px, relative,
};
use gpui_native_icons::{Icon, IconRole, icon as icon_glyph};
use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, POPUP_MAX_HEIGHT};
use gpui_native_utils::focus_handle;
use crate::common::v_flex;
@@ -23,6 +23,8 @@ pub type ItemHandler = Rc<dyn Fn(&mut Window, &mut App)>;
/// Reports the next open state of a menu: `true` to open, `false` to close.
pub(crate) type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// Flips a panel's flyout open state; `true` arms the first row for keyboard.
type FlyoutSetter = Rc<dyn Fn(Option<usize>, bool, &mut App)>;
/// Bounds captured during the last paint, shared with the dismissal listener.
pub(crate) type TriggerBoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
type TriggerFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
@@ -30,7 +32,9 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
/// One row of a [`Menu`]: an action, a checkable action, a submenu, or a separator.
#[derive(Clone)]
#[must_use]
pub struct MenuItem {
id: Option<ElementId>,
kind: MenuItemKind,
label: SharedString,
icon: Option<Icon>,
@@ -55,6 +59,7 @@ impl MenuItem {
L: Into<SharedString>,
{
Self {
id: None,
kind: MenuItemKind::Action,
label: label.into(),
icon: None,
@@ -69,6 +74,7 @@ impl MenuItem {
/// A divider between groups of actions.
pub fn separator() -> Self {
Self {
id: None,
kind: MenuItemKind::Separator,
label: SharedString::default(),
icon: None,
@@ -80,6 +86,15 @@ impl MenuItem {
}
}
/// Overrides the row's element id, for stable identity across reorderings.
pub fn id<I>(mut self, id: I) -> Self
where
I: Into<ElementId>,
{
self.id = Some(id.into());
self
}
/// Runs this action when the item is activated. The menu closes first.
pub fn handler<F>(mut self, handler: F) -> Self
where
@@ -176,6 +191,7 @@ pub(crate) struct MenuChrome {
/// A floating menu of actions anchored below its trigger.
#[derive(IntoElement)]
#[must_use]
pub struct Menu {
id: ElementId,
open: bool,
@@ -256,7 +272,10 @@ impl Menu {
}
/// Positions the panel at this window point when it opens.
pub fn open_at(mut self, position: impl Into<Option<Point<Pixels>>>) -> Self {
pub fn open_at<P>(mut self, position: P) -> Self
where
P: Into<Option<Point<Pixels>>>,
{
self.open_at = position.into();
self
}
@@ -461,6 +480,8 @@ struct FlyoutContext<'a> {
state: &'a MenuPanelState,
open: &'a Entity<Option<usize>>,
preselect: &'a Entity<bool>,
/// Opens or closes this level's flyout, re-arming it on every switch.
set_flyout: FlyoutSetter,
/// The open flyout's painted bounds; a parent ignores clicks inside them.
bounds: &'a TriggerBoundsCell,
/// Bounds of every open flyout below this panel, this level's included.
@@ -492,6 +513,8 @@ pub(crate) fn anchored_menu_panel(
) -> Option<AnyElement> {
let was_open = window.use_keyed_state((id.clone(), "menu-was-open"), cx, |_, _| false);
// Render-phase residue: trigger-toggle and owner-driven closes bypass every
// dismissal handler, so this closed frame is the only universal close hook.
if !spec.open {
was_open.update(cx, |was_open, _| *was_open = false);
// A closed menu drops any open flyout; fresh state on the next open.
@@ -551,6 +574,8 @@ fn menu_panel(
selection,
} = state;
// Render-phase residue: panels open from owner state alone, so this mount
// frame is the only place fresh-selection init can read its params.
let selected = if *was_open.read(cx) {
*selection.read(cx)
} else {
@@ -574,8 +599,6 @@ fn menu_panel(
let flyout_open = window.use_keyed_state((params.id.clone(), "submenu-open"), cx, |_, _| None);
let flyout_preselect =
window.use_keyed_state((params.id.clone(), "submenu-preselect"), cx, |_, _| false);
let flyout_last =
window.use_keyed_state((params.id.clone(), "submenu-last-open"), cx, |_, _| None);
let flyout_bounds: TriggerBoundsCell = Rc::new(RefCell::new(None));
let flyout_stack: Rc<RefCell<Vec<TriggerBoundsCell>>> = Rc::new(RefCell::new(Vec::new()));
flyout_stack.borrow_mut().push(flyout_bounds.clone());
@@ -584,25 +607,29 @@ fn menu_panel(
outer_stack.borrow_mut().push(flyout_bounds.clone());
}
// A switched or reopened flyout starts from a clean selection.
let open_flyout = *flyout_open.read(cx);
if *flyout_last.read(cx) != open_flyout {
flyout_state
.was_open
.update(cx, |was_open, _| *was_open = false);
flyout_state
.selection
.update(cx, |selection, _| *selection = None);
flyout_last.update(cx, |last, _| *last = open_flyout);
}
let dismiss_flyout: ChangeHandler = {
// Every open flip re-arms a clean flyout, so render never reconciles one.
let set_flyout: FlyoutSetter = {
let flyout_open = flyout_open.clone();
let flyout_preselect = flyout_preselect.clone();
let flyout_was_open = flyout_state.was_open.clone();
let flyout_selection = flyout_state.selection.clone();
Rc::new(move |index, opens_preselected, cx| {
if index.is_some() && *flyout_open.read(cx) == index {
return;
}
flyout_open.update(cx, |open, _| *open = index);
flyout_preselect.update(cx, |preselect, _| *preselect = opens_preselected);
flyout_was_open.update(cx, |was_open, _| *was_open = false);
flyout_selection.update(cx, |selection, _| *selection = None);
})
};
let dismiss_flyout: ChangeHandler = {
let set_flyout = set_flyout.clone();
let focus = focus.clone();
Rc::new(move |_, window, cx| {
flyout_open.update(cx, |open, _| *open = None);
flyout_preselect.update(cx, |preselect, _| *preselect = false);
set_flyout(None, false, cx);
window.focus(&focus, cx);
})
};
@@ -611,6 +638,7 @@ fn menu_panel(
state: &flyout_state,
open: &flyout_open,
preselect: &flyout_preselect,
set_flyout: set_flyout.clone(),
bounds: &flyout_bounds,
stack: &flyout_stack,
dismiss: dismiss_flyout,
@@ -693,8 +721,7 @@ fn menu_panel(
.on_key_down({
let items = params.items.clone();
let selection = selection.clone();
let flyout_open = flyout_open.clone();
let flyout_preselect = flyout_preselect.clone();
let set_flyout = set_flyout.clone();
let on_dismiss = params.on_dismiss.clone();
let on_activate = params.on_activate.clone();
let left_dismisses = params.is_flyout;
@@ -704,8 +731,7 @@ fn menu_panel(
MenuKeyEnv {
items: &items,
selection: &selection,
flyout_open: &flyout_open,
flyout_preselect: &flyout_preselect,
set_flyout: &set_flyout,
on_dismiss: &on_dismiss,
on_activate: &on_activate,
left_dismisses,
@@ -764,7 +790,7 @@ fn render_item(
) -> AnyElement {
match &item.kind {
MenuItemKind::Separator => div()
.h(px(1.))
.h(BORDER_WIDTH)
.debug_selector(move || format!("{}-separator-{index}", env.debug_prefix))
.mx(env.chrome.spacing_unit * 2.0)
.my(env.chrome.spacing_unit)
@@ -835,17 +861,13 @@ fn render_item(
row_shell(cx, item, index, selected, env)
.on_click({
let flyout_open = flyout.open.clone();
let flyout_preselect = flyout.preselect.clone();
let set_flyout = flyout.set_flyout.clone();
move |_, _window, cx| {
if disabled {
return;
}
// Hover already opens the flyout; a click never closes it.
if *flyout_open.read(cx) != Some(index) {
flyout_open.update(cx, |open, _| *open = Some(index));
flyout_preselect.update(cx, |preselect, _| *preselect = false);
}
set_flyout(Some(index), false, cx);
}
})
.child(
@@ -914,9 +936,14 @@ fn row_shell(
let selectable = item.selectable();
let opens_submenu = item.submenu_items().is_some();
let flyout_open = *env.flyout.open.read(cx) == Some(index);
let row_id = item
.id
.clone()
.unwrap_or_else(|| ("menu-item", index).into());
div()
.id(("menu-item", index))
.id(row_id)
.role(Role::MenuItem)
.debug_selector(move || format!("{}-item-{index}", env.debug_prefix))
.flex()
.items_center()
@@ -945,7 +972,7 @@ fn row_shell(
.on_mouse_move({
let selection = env.selection.clone();
let flyout_open = env.flyout.open.clone();
let flyout_preselect = env.flyout.preselect.clone();
let set_flyout = env.flyout.set_flyout.clone();
let dismiss = env.flyout.dismiss.clone();
move |_, window, cx| {
if !selectable {
@@ -957,10 +984,7 @@ fn row_shell(
}
if opens_submenu {
if *flyout_open.read(cx) != Some(index) {
flyout_open.update(cx, |open, _| *open = Some(index));
flyout_preselect.update(cx, |preselect, _| *preselect = false);
}
set_flyout(Some(index), false, cx);
} else if flyout_open.read(cx).is_some() {
dismiss(false, window, cx);
}
@@ -996,8 +1020,7 @@ fn row_color(
struct MenuKeyEnv<'a> {
items: &'a [MenuItem],
selection: &'a Entity<Option<usize>>,
flyout_open: &'a Entity<Option<usize>>,
flyout_preselect: &'a Entity<bool>,
set_flyout: &'a FlyoutSetter,
on_dismiss: &'a Option<ChangeHandler>,
on_activate: &'a Option<ChangeHandler>,
left_dismisses: bool,
@@ -1044,9 +1067,7 @@ fn handle_menu_key(event: &KeyDownEvent, env: MenuKeyEnv, window: &mut Window, c
if !opens {
return;
}
env.flyout_open.update(cx, |open, _| *open = current);
env.flyout_preselect
.update(cx, |preselect, _| *preselect = true);
(env.set_flyout)(current, true, cx);
}
"enter" | "space" => {
if let Some(current) = current
@@ -1054,9 +1075,7 @@ fn handle_menu_key(event: &KeyDownEvent, env: MenuKeyEnv, window: &mut Window, c
&& item.selectable()
{
if item.submenu_items().is_some() {
env.flyout_open.update(cx, |open, _| *open = Some(current));
env.flyout_preselect
.update(cx, |preselect, _| *preselect = true);
(env.set_flyout)(Some(current), true, cx);
} else {
if let Some(on_activate) = env.on_activate {
on_activate(false, window, cx);
+3 -2
View File
@@ -6,7 +6,7 @@ use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, DispatchPhase, ElementId,
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point, px,
Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, point,
};
use gpui_native_theme::ActiveTheme as _;
@@ -21,6 +21,7 @@ type BoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
/// A floating panel anchored below its trigger.
#[derive(IntoElement)]
#[must_use]
pub struct Popover {
id: ElementId,
open: bool,
@@ -225,7 +226,7 @@ impl RenderOnce for Popover {
wrapper.child(deferred(
anchored()
.offset(point(px(0.), anchor_gap))
.offset(point(Pixels::ZERO, anchor_gap))
.child(panel.with_animation(
"popover-enter",
Animation::new(enter_duration).with_easing(ease_out_quint()),
+3 -4
View File
@@ -1,19 +1,18 @@
//! The progress bar: determinate fill or an indeterminate sweep.
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement,
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, div, relative,
};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, PROGRESS_ANIMATION_PERIOD, Size};
use crate::common::Sizable;
/// A progress bar. With a `value` in `0..=1` it paints a determinate fill.
#[derive(IntoElement)]
#[must_use]
pub struct Progress {
id: ElementId,
value: f32,
@@ -97,7 +96,7 @@ impl RenderOnce for Progress {
.bg(ramps.primary.base)
.with_animation(
"indeterminate",
Animation::new(Duration::from_millis(1500))
Animation::new(PROGRESS_ANIMATION_PERIOD)
.repeat()
.with_max_fps(30.),
|pill, delta| pill.ml(relative(delta * 0.7)),
+5
View File
@@ -12,6 +12,10 @@ use crate::common::{Sizable, h_flex, v_flex};
type ChangeHandler<V> = std::rc::Rc<dyn Fn(V, &mut Window, &mut App)>;
/// Radio button.
///
/// Each radio is its own tab stop; the full ARIA radiogroup pattern (single
/// stop plus arrow-key roving focus) needs a group owner that does not exist yet.
#[must_use]
pub struct Radio<V> {
id: ElementId,
value: V,
@@ -65,6 +69,7 @@ where
/// Group of radio buttons.
#[derive(IntoElement)]
#[must_use]
pub struct RadioGroup<V>
where
V: Clone + PartialEq + 'static,
+1
View File
@@ -23,6 +23,7 @@ pub enum ScrollAxes {
/// A scrollable container with an overlay scrollbar.
#[derive(IntoElement)]
#[must_use]
pub struct ScrollArea {
id: ElementId,
handle: Option<ScrollHandle>,
+247 -243
View File
@@ -10,7 +10,8 @@ use gpui::{
ScrollHandle, SharedString, Styled as _, Task, UniformListScrollHandle, Window, canvas, fill,
point,
};
use gpui_native_theme::ActiveTheme as _;
use gpui_native_theme::{ActiveTheme as _, SCROLLBAR_AUTOHIDE_DELAY, SCROLLBAR_FADE_DURATION};
use gpui_native_utils::ResultLogErr as _;
/// When an overlay scrollbar shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -22,12 +23,8 @@ pub enum ScrollbarVisibility {
Autohide,
}
/// How long an autohide thumb stays fully visible before its fade begins.
const AUTOHIDE_DELAY: Duration = Duration::from_millis(700);
/// How long the autohide fade runs.
const AUTOHIDE_FADE: Duration = Duration::from_millis(250);
/// How often the fade repaints while it runs.
const AUTOHIDE_FADE_STEP: Duration = Duration::from_millis(50);
/// How often the autohide fade repaints while it runs.
const FADE_STEP: Duration = Duration::from_millis(50);
/// The shared autohide timeline for an overlay scrollbar.
#[derive(Clone)]
@@ -59,21 +56,23 @@ impl Autohide {
let task = window.spawn(cx, async move |cx| {
if reduce_motion {
cx.background_executor()
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
.timer(SCROLLBAR_AUTOHIDE_DELAY + SCROLLBAR_FADE_DURATION)
.await;
cx.update(|_, cx| cx.notify(view)).ok();
cx.update(|_, cx| cx.notify(view)).log_err();
return;
}
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
cx.background_executor().timer(AUTOHIDE_DELAY).await;
let steps = (SCROLLBAR_FADE_DURATION.as_millis() / FADE_STEP.as_millis()).max(1);
cx.background_executor()
.timer(SCROLLBAR_AUTOHIDE_DELAY)
.await;
for _ in 0..steps {
cx.update(|_, cx| cx.notify(view)).ok();
cx.background_executor().timer(AUTOHIDE_FADE_STEP).await;
cx.update(|_, cx| cx.notify(view)).log_err();
cx.background_executor().timer(FADE_STEP).await;
}
cx.update(|_, cx| cx.notify(view)).ok();
cx.update(|_, cx| cx.notify(view)).log_err();
});
self.state.update(cx, |state, _| {
@@ -93,50 +92,19 @@ impl Autohide {
};
let idle = activity.elapsed();
if idle <= AUTOHIDE_DELAY {
if idle <= SCROLLBAR_AUTOHIDE_DELAY {
return 1.0;
}
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
if fade >= AUTOHIDE_FADE {
let fade = idle.saturating_sub(SCROLLBAR_AUTOHIDE_DELAY);
if fade >= SCROLLBAR_FADE_DURATION {
return 0.0;
}
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32()
1.0 - fade.as_secs_f32() / SCROLLBAR_FADE_DURATION.as_secs_f32()
}
}
fn scrollbar_state_key(name: &'static str, id: &ElementId) -> ElementId {
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
}
/// Pure thumb geometry for an overlay scrollbar.
fn thumb_geometry(
viewport: Pixels,
content: Pixels,
offset: Pixels,
track: Pixels,
min_thumb: Pixels,
) -> (Pixels, Pixels) {
if content <= viewport || content <= Pixels::ZERO {
return (track, Pixels::ZERO);
}
let length = (viewport / content * track).max(min_thumb).min(track);
let overflow = content - viewport;
let progress = (-offset / overflow).clamp(0., 1.);
let travel = track - length;
(length, travel * progress)
}
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
(alpha > 0.0).then_some(gpui::Hsla {
a: color.a * alpha,
..color
})
}
/// The scroll state a scrollbar drives, implemented for gpui's scroll handles.
pub trait ScrollbarHandle: 'static {
/// How far the content can scroll, positive along each axis.
@@ -245,8 +213,9 @@ impl Axis {
}
}
/// An overlay scrollbar, the caller renders it over the scrolled surface.
/// An overlay scrollbar, rendered over the scrolled surface.
#[derive(IntoElement)]
#[must_use]
pub struct Scrollbar {
axis: Axis,
handle: Rc<dyn ScrollbarHandle>,
@@ -288,6 +257,37 @@ impl Scrollbar {
self.visibility = visibility;
self
}
fn state_key(name: &'static str, id: &ElementId) -> ElementId {
ElementId::NamedChild(Arc::new(id.clone()), SharedString::from(name))
}
/// Pure thumb geometry for an overlay scrollbar.
fn thumb_geometry(
viewport: Pixels,
content: Pixels,
offset: Pixels,
track: Pixels,
min_thumb: Pixels,
) -> (Pixels, Pixels) {
if content <= viewport || content <= Pixels::ZERO {
return (track, Pixels::ZERO);
}
let length = (viewport / content * track).max(min_thumb).min(track);
let overflow = content - viewport;
let progress = (-offset / overflow).clamp(0., 1.);
let travel = track - length;
(length, travel * progress)
}
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
(alpha > 0.0).then_some(gpui::Hsla {
a: color.a * alpha,
..color
})
}
}
impl RenderOnce for Scrollbar {
@@ -297,7 +297,7 @@ impl RenderOnce for Scrollbar {
let axis = self.axis;
let visibility = self.visibility;
let id = self.id;
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &id, window, cx)
move |_, _, window, cx| Self::paint(&handle, axis, visibility, &id, window, cx)
})
.absolute()
.inset_0()
@@ -307,210 +307,214 @@ impl RenderOnce for Scrollbar {
/// The drag state shared between the scrollbar's mouse handlers.
type Drag = Option<(Pixels, Pixels)>;
fn paint_scrollbar(
handle: &Rc<dyn ScrollbarHandle>,
axis: Axis,
visibility: ScrollbarVisibility,
id: &ElementId,
window: &mut Window,
cx: &mut App,
) {
let view = window.current_view();
let theme = cx.theme();
impl Scrollbar {
fn paint(
handle: &Rc<dyn ScrollbarHandle>,
axis: Axis,
visibility: ScrollbarVisibility,
id: &ElementId,
window: &mut Window,
cx: &mut App,
) {
let view = window.current_view();
let theme = cx.theme();
let color = theme.colors.scrollbar_thumb;
let width = theme.scrollbar.width;
let inset = theme.scrollbar.inset;
let min_thumb = theme.scrollbar.min_thumb;
let color = theme.colors.scrollbar_thumb;
let width = theme.scrollbar.width;
let inset = theme.scrollbar.inset;
let min_thumb = theme.scrollbar.min_thumb;
let viewport = handle.viewport();
let viewport_along = axis.viewport_along(&viewport);
let max_offset = axis.along(handle.max_offset());
let viewport = handle.viewport();
let viewport_along = axis.viewport_along(&viewport);
let max_offset = axis.along(handle.max_offset());
if max_offset <= Pixels::ZERO || viewport_along <= Pixels::ZERO {
return;
}
let autohide = match visibility {
ScrollbarVisibility::Platform => theme.scrollbar.autohide,
ScrollbarVisibility::Always => false,
ScrollbarVisibility::Autohide => true,
};
let autohide_state =
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", id), window, cx));
let track_length = viewport_along - inset * 2.0;
let (thumb_length, thumb_travel) = thumb_geometry(
viewport_along,
viewport_along + max_offset,
axis.along(handle.offset()),
track_length,
min_thumb,
);
// The track strip is the thumb's rail plus the padding the thumb rides inside.
let (thumb_bounds, track_bounds) = match axis {
Axis::Vertical => {
let thumb_x = viewport.right() - width - inset;
(
Bounds::from_corners(
point(thumb_x, viewport.top() + inset + thumb_travel),
point(
thumb_x + width,
viewport.top() + inset + thumb_travel + thumb_length,
),
),
Bounds::from_corners(
point(viewport.right() - width, viewport.top() + inset),
point(viewport.right(), viewport.top() + inset + track_length),
),
)
if max_offset <= Pixels::ZERO || viewport_along <= Pixels::ZERO {
return;
}
Axis::Horizontal => {
let thumb_y = viewport.bottom() - width - inset;
(
Bounds::from_corners(
point(viewport.left() + inset + thumb_travel, thumb_y),
point(
viewport.left() + inset + thumb_travel + thumb_length,
thumb_y + width,
let autohide = match visibility {
ScrollbarVisibility::Platform => theme.scrollbar.autohide,
ScrollbarVisibility::Always => false,
ScrollbarVisibility::Autohide => true,
};
let autohide_state =
autohide.then(|| Autohide::new(Self::state_key("autohide", id), window, cx));
let track_length = viewport_along - inset * 2.0;
let (thumb_length, thumb_travel) = Self::thumb_geometry(
viewport_along,
viewport_along + max_offset,
axis.along(handle.offset()),
track_length,
min_thumb,
);
// The track strip is the thumb's rail plus the padding the thumb rides inside.
let (thumb_bounds, track_bounds) = match axis {
Axis::Vertical => {
let thumb_x = viewport.right() - width - inset;
(
Bounds::from_corners(
point(thumb_x, viewport.top() + inset + thumb_travel),
point(
thumb_x + width,
viewport.top() + inset + thumb_travel + thumb_length,
),
),
),
Bounds::from_corners(
point(viewport.left() + inset, viewport.bottom() - width),
point(viewport.left() + inset + track_length, viewport.bottom()),
),
)
Bounds::from_corners(
point(viewport.right() - width, viewport.top() + inset),
point(viewport.right(), viewport.top() + inset + track_length),
),
)
}
Axis::Horizontal => {
let thumb_y = viewport.bottom() - width - inset;
(
Bounds::from_corners(
point(viewport.left() + inset + thumb_travel, thumb_y),
point(
viewport.left() + inset + thumb_travel + thumb_length,
thumb_y + width,
),
),
Bounds::from_corners(
point(viewport.left() + inset, viewport.bottom() - width),
point(viewport.left() + inset + track_length, viewport.bottom()),
),
)
}
};
let drag = window.use_keyed_state(Self::state_key("thumb-drag", id), cx, |_, _| Drag::None);
let dragging = drag.read(cx).is_some();
let alpha = match &autohide_state {
Some(state) => state.alpha(cx, dragging),
None => 1.0,
};
if let Some(color) = Self::thumb_style(color, alpha) {
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
}
};
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", id), cx, |_, _| Drag::None);
let dragging = drag.read(cx).is_some();
if let Some(state) = &autohide_state {
window.on_mouse_event({
let state = state.clone();
move |event: &ScrollEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || !viewport.contains(&event.position) {
return;
}
state.touch(window, cx);
}
});
}
let alpha = match &autohide_state {
Some(state) => state.alpha(cx, dragging),
None => 1.0,
};
if let Some(color) = thumb_style(color, alpha) {
window.paint_quad(fill(thumb_bounds, color).corner_radii(width / 2.0));
}
if let Some(state) = &autohide_state {
window.on_mouse_event({
let state = state.clone();
move |event: &ScrollEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || !viewport.contains(&event.position) {
let handle = handle.clone();
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || event.button != MouseButton::Left {
return;
}
state.touch(window, cx);
let in_thumb = thumb_bounds.contains(&event.position);
let in_track = !in_thumb && track_bounds.contains(&event.position);
if !in_thumb && !in_track {
return;
}
let start_offset = if in_thumb {
axis.along(handle.offset())
} else if track_length > thumb_length {
let thumb_start = ((axis.along(event.position)
- axis.along(track_bounds.origin))
- thumb_length / 2.0)
.clamp(Pixels::ZERO, track_length - thumb_length);
let offset = -(thumb_start / (track_length - thumb_length)) * max_offset;
handle.set_offset(axis.with_along(handle.offset(), offset));
cx.notify(view);
offset
} else {
return;
};
drag.update(cx, |drag, _| {
*drag = Some((axis.along(event.position), start_offset));
});
handle.drag_started();
if let Some(state) = &autohide_state {
state.touch(window, cx);
}
cx.stop_propagation();
}
});
window.on_mouse_event({
let handle = handle.clone();
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if let Some(state) = &autohide_state
&& track_bounds.contains(&event.position)
&& drag.read(cx).is_none()
{
state.touch(window, cx);
}
if event.pressed_button != Some(MouseButton::Left) {
return;
}
let Some((start_position, start_offset)) = *drag.read(cx) else {
return;
};
if track_length <= thumb_length {
return;
}
let scale = max_offset / (track_length - thumb_length);
let offset = (start_offset - (axis.along(event.position) - start_position) * scale)
.clamp(-max_offset, Pixels::ZERO);
handle.set_offset(axis.with_along(handle.offset(), offset));
cx.notify(view);
}
});
window.on_mouse_event({
let handle = handle.clone();
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseUpEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || event.button != MouseButton::Left {
return;
}
if drag.read(cx).is_none() {
return;
}
drag.update(cx, |drag, _| {
*drag = None;
});
handle.drag_ended();
// Unfreezing a ListState's height can change the thumb's geometry.
cx.notify(view);
if let Some(state) = &autohide_state {
state.touch(window, cx);
}
}
});
}
window.on_mouse_event({
let handle = handle.clone();
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || event.button != MouseButton::Left {
return;
}
let in_thumb = thumb_bounds.contains(&event.position);
let in_track = !in_thumb && track_bounds.contains(&event.position);
if !in_thumb && !in_track {
return;
}
let start_offset = if in_thumb {
axis.along(handle.offset())
} else if track_length > thumb_length {
let thumb_start = ((axis.along(event.position) - axis.along(track_bounds.origin))
- thumb_length / 2.0)
.clamp(Pixels::ZERO, track_length - thumb_length);
let offset = -(thumb_start / (track_length - thumb_length)) * max_offset;
handle.set_offset(axis.with_along(handle.offset(), offset));
cx.notify(view);
offset
} else {
return;
};
drag.update(cx, |drag, _| {
*drag = Some((axis.along(event.position), start_offset));
});
handle.drag_started();
if let Some(state) = &autohide_state {
state.touch(window, cx);
}
cx.stop_propagation();
}
});
window.on_mouse_event({
let handle = handle.clone();
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if let Some(state) = &autohide_state
&& track_bounds.contains(&event.position)
&& drag.read(cx).is_none()
{
state.touch(window, cx);
}
if event.pressed_button != Some(MouseButton::Left) {
return;
}
let Some((start_position, start_offset)) = *drag.read(cx) else {
return;
};
if track_length <= thumb_length {
return;
}
let scale = max_offset / (track_length - thumb_length);
let offset = (start_offset - (axis.along(event.position) - start_position) * scale)
.clamp(-max_offset, Pixels::ZERO);
handle.set_offset(axis.with_along(handle.offset(), offset));
cx.notify(view);
}
});
window.on_mouse_event({
let handle = handle.clone();
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseUpEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || event.button != MouseButton::Left {
return;
}
if drag.read(cx).is_none() {
return;
}
drag.update(cx, |drag, _| {
*drag = None;
});
handle.drag_ended();
// Unfreezing a ListState's height can change the thumb's geometry.
cx.notify(view);
if let Some(state) = &autohide_state {
state.touch(window, cx);
}
}
});
}
+7 -7
View File
@@ -4,11 +4,10 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnimationExt as _, App, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement,
KeyDownEvent, ParentElement as _, Point, RenderOnce, Role, SharedString, SpringAnimation,
SpringConfig, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window,
canvas, div,
StatefulInteractiveElement as _, StyleRefinement, Styled as _, Toggled, Window, canvas, div,
};
use gpui_native_icons::Icon;
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, CONTROL_SPRING, Size};
use gpui_native_utils::{focus_handle, focus_ring};
use crate::common::{Sizable, StyledExt, h_flex};
@@ -18,6 +17,7 @@ type ChangeHandler<T> = Rc<dyn Fn(T, &mut Window, &mut App)>;
/// Segment option for a segmented control.
#[derive(Clone)]
#[must_use]
pub struct SegmentedOption<T> {
value: T,
label: SharedString,
@@ -62,6 +62,7 @@ where
/// A pill strip of mutually exclusive choices.
#[derive(IntoElement)]
#[must_use]
pub struct Segmented<T>
where
T: Clone + PartialEq + 'static,
@@ -187,7 +188,6 @@ where
window.use_keyed_state("segmented-track-origin", cx, |_, _| None);
let focus = focus_handle(&self.id, window, cx);
let anim_config = SpringConfig::new(500., 35., 1.);
let track_point = *track_origin.read(cx);
let plate_target = geometry
@@ -214,7 +214,7 @@ where
};
h_flex()
.id(("segment", index))
.id((ElementId::from("segment"), segment.label.clone()))
.gap_1p5()
.justify_center()
.cursor_pointer()
@@ -309,12 +309,12 @@ where
.shadow(plate_shadow.clone())
.with_spring(
"segmented-plate-x",
SpringAnimation::new(anim_config).to(x),
SpringAnimation::new(CONTROL_SPRING).to(x),
|plate, x| plate.left(x),
)
.with_spring(
"segmented-plate-w",
SpringAnimation::new(anim_config).to(width),
SpringAnimation::new(CONTROL_SPRING).to(width),
|spring_plate, width| {
spring_plate.map_element(move |plate| plate.w(width))
},
+105 -41
View File
@@ -2,12 +2,13 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window,
App, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
Window, canvas,
};
use gpui_native_icons::{Icon, IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
use gpui_native_utils::focus_ring;
use gpui_native_utils::{focus_handle, focus_ring};
use crate::common::{Sizable, StyledExt, h_flex};
use crate::menu::{Menu, MenuItem};
@@ -17,6 +18,7 @@ pub type SelectHandler<T> = Rc<dyn Fn(Option<T>, &mut Window, &mut App)>;
/// Select option for a [`Select`] control.
#[derive(Clone)]
#[must_use]
pub struct SelectOption<T> {
value: T,
label: SharedString,
@@ -61,6 +63,7 @@ where
/// A control that shows the chosen value and opens a menu of the choices.
#[derive(IntoElement)]
#[must_use]
pub struct Select<T>
where
T: Clone + PartialEq + 'static,
@@ -107,7 +110,10 @@ where
}
/// Sets the current value. `None` shows the placeholder.
pub fn selected(mut self, selected: impl Into<Option<T>>) -> Self {
pub fn selected<S>(mut self, selected: S) -> Self
where
S: Into<Option<T>>,
{
self.selected = selected.into();
self
}
@@ -187,6 +193,16 @@ where
.clone()
});
let focus = focus_handle(&self.id, window, cx);
// Set when the popup closes without an activation; the trigger claims
// focus at paint, leaving keyboard activations to drop focus in place.
let refocus_pending = window.with_element_namespace(self.id.clone(), |window| {
window
.use_keyed_state("select-refocus", cx, |_, _| false)
.clone()
});
let is_open = *open.read(cx);
let open_for_items = open.clone();
@@ -220,54 +236,94 @@ where
let trigger_label = label.clone();
let trigger_style = self.style;
let trigger = move |_window: &mut Window, _cx: &mut App| {
h_flex()
.id((trigger_id.clone(), "select-trigger"))
.debug_selector(|| "select-trigger".to_string())
.role(Role::ComboBox)
.aria_label(trigger_label.clone())
.cursor_pointer()
.justify_between()
.gap_2()
.w_full()
.h(metrics.input_height + BORDER_WIDTH * 2.0)
.px(metrics.dense_padding_x)
.rounded(radius)
.border_1()
.border_color(rest_border)
.bg(input)
.text_size(metrics.text_size)
.text_color(if trigger_has_value {
foreground
} else {
muted_foreground
})
.when(is_open && focus_ring_on, |trigger| {
trigger.shadow(focus_ring(ring))
})
.hover(|style| style.bg(muted).border_color(hover_border))
.child(trigger_text.clone())
.child(
icon(IconRole::ChevronDown)
.size(icon_size)
.color(muted_foreground),
)
.refine_style(&trigger_style)
.into_any_element()
let trigger = {
let open = open.clone();
let focus = focus.clone();
let refocus_for_trigger = refocus_pending.clone();
move |_window: &mut Window, _cx: &mut App| {
h_flex()
.id((trigger_id.clone(), "select-trigger"))
.debug_selector(|| "select-trigger".to_string())
.role(Role::ComboBox)
.aria_label(trigger_label.clone())
.aria_expanded(is_open)
.track_focus(&focus)
.tab_stop(true)
.focus_visible(move |style| style.shadow(focus_ring(ring)))
.on_key_down({
let open = open.clone();
move |event: &KeyDownEvent, _window, cx| {
if !matches!(event.keystroke.key.as_str(), "enter" | "space") {
return;
}
open.update(cx, |open, _| *open = !*open);
cx.stop_propagation();
}
})
.cursor_pointer()
.justify_between()
.gap_2()
.w_full()
.h(metrics.input_height + BORDER_WIDTH * 2.0)
.px(metrics.dense_padding_x)
.rounded(radius)
.border_1()
.border_color(rest_border)
.bg(input)
.text_size(metrics.text_size)
.text_color(if trigger_has_value {
foreground
} else {
muted_foreground
})
.when(is_open && focus_ring_on, |trigger| {
trigger.shadow(focus_ring(ring))
})
.hover(|style| style.bg(muted).border_color(hover_border))
.child(trigger_text.clone())
.child(
icon(IconRole::ChevronDown)
.size(icon_size)
.color(muted_foreground),
)
.child(
canvas(|_, _, _| {}, {
let focus = focus.clone();
let refocus_pending = refocus_for_trigger.clone();
move |_, _, window, cx| {
if *refocus_pending.read(cx) {
refocus_pending.update(cx, |pending, _| *pending = false);
window.focus(&focus, cx);
}
}
})
.absolute()
.inset_0(),
)
.refine_style(&trigger_style)
.into_any_element()
}
};
let select_id = self.id.clone();
let refocus_for_items = refocus_pending.clone();
let items = move |_window: &mut Window, _cx: &mut App| {
self.options
.iter()
.map(|option| {
.enumerate()
.map(|(index, option)| {
let is_selected = self.selected.as_ref() == Some(&option.value);
let handler = {
let open = open_for_items.clone();
let refocus_pending = refocus_for_items.clone();
let value = option.value.clone();
let on_change = self.on_change.clone();
move |window: &mut Window, cx: &mut App| {
open.update(cx, |open, _| *open = false);
// Activations own the close, so no focus returns to the trigger.
refocus_pending.update(cx, |pending, _| *pending = false);
if let Some(on_change) = &on_change {
on_change(Some(value.clone()), window, cx);
}
@@ -275,6 +331,7 @@ where
};
MenuItem::new(option.label.clone())
.id((select_id.clone(), format!("option-{index}")))
.when_some(option.icon.clone(), |row, icon| row.icon(icon))
.checked(is_selected)
.handler(handler)
@@ -284,8 +341,15 @@ where
let menu = Menu::new(self.id)
.open(is_open)
.on_change(move |next, _window, cx| {
open.update(cx, |open, _| *open = next);
.on_change({
let open = open.clone();
let refocus_pending = refocus_pending.clone();
move |next, _window, cx| {
if !next {
refocus_pending.update(cx, |pending, _| *pending = true);
}
open.update(cx, |open, _| *open = next);
}
})
.trigger(trigger)
.items(items);
+1
View File
@@ -20,6 +20,7 @@ type DebugSelector = Rc<dyn Fn() -> String>;
/// A hairline rule that divides sibling content.
#[derive(IntoElement)]
#[must_use]
pub struct Separator {
orientation: SeparatorOrientation,
debug_selector: Option<DebugSelector>,
+25 -10
View File
@@ -18,18 +18,24 @@ type ChangeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// The pointer→value setter shared by the click and drag listeners.
type ValueSetter = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
let clamped = value.clamp(min, max);
if step > 0.0 {
(min + ((clamped - min) / step).round() * step).clamp(min, max)
} else {
clamped
/// The arrow-key stride as a share of the range when the slider is continuous.
const FALLBACK_STEP_FRACTION: f32 = 0.05;
impl Slider {
/// Clamps `value` into `[min, max]` and snaps it to `step` when stepped.
fn constrain(min: f32, max: f32, step: f32, value: f32) -> f32 {
let clamped = value.clamp(min, max);
if step > 0.0 {
(min + ((clamped - min) / step).round() * step).clamp(min, max)
} else {
clamped
}
}
}
/// A controlled slider over `[min, max]`.
#[derive(IntoElement)]
#[must_use]
pub struct Slider {
id: ElementId,
min: f32,
@@ -146,7 +152,7 @@ impl RenderOnce for Slider {
{
let fraction = ((x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
on_change(
constrain(min, max, step, min + fraction * (max - min)),
Self::constrain(min, max, step, min + fraction * (max - min)),
window,
cx,
);
@@ -184,20 +190,29 @@ impl RenderOnce for Slider {
if !focus_handle.contains_focused(window, cx) {
return;
}
let single_step = if self.step > 0.0 {
self.step
} else {
(self.max - self.min) / 20.0
(self.max - self.min) * FALLBACK_STEP_FRACTION
};
let next = match event.keystroke.key.as_str() {
"left" | "down" => Some(value - single_step),
"right" | "up" => Some(value + single_step),
"home" => Some(self.min),
"end" => Some(self.max),
_ => None,
};
if let Some(next) = next
&& let Some(on_change) = self.on_change.as_ref()
{
on_change(constrain(self.min, self.max, self.step, next), window, cx);
on_change(
Self::constrain(self.min, self.max, self.step, next),
window,
cx,
);
cx.stop_propagation();
}
})
+4 -6
View File
@@ -3,7 +3,6 @@
use std::cell::Cell;
use std::f32::consts::{PI, TAU};
use std::rc::Rc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
@@ -13,12 +12,10 @@ use gpui::{
Window, canvas, div, percentage, point, px,
};
use gpui_native_icons::{Icon, IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, SPINNER_PERIOD, Size};
use crate::common::Sizable;
/// One revolution takes this long, the system loaders' cadence.
const SPIN_PERIOD: Duration = Duration::from_millis(800);
/// The share of the circle the spinning arc paints.
const SPIN_SWEEP: f32 = 0.75;
/// The ring stroke as a share of the side.
@@ -36,6 +33,7 @@ pub enum SpinnerVariant {
/// A loading ring.
#[derive(IntoElement)]
#[must_use]
pub struct Spinner {
id: ElementId,
value: Option<f32>,
@@ -178,7 +176,7 @@ fn spinning_ring(side: Pixels, stroke: Pixels, accent: Hsla) -> AnimationElement
.size(side)
.with_animation(
"spin",
Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.),
Animation::new(SPINNER_PERIOD).repeat().with_max_fps(60.),
move |ring, delta| {
angle.set(delta * TAU);
ring
@@ -192,7 +190,7 @@ fn spinning_icon(side: Pixels, accent: Hsla) -> AnimationElement<Icon> {
.color(accent)
.with_animation(
"spin",
Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.),
Animation::new(SPINNER_PERIOD).repeat().with_max_fps(60.),
|glyph, delta| glyph.with_transformation(Transformation::rotate(percentage(delta))),
)
}
+5 -2
View File
@@ -2,7 +2,7 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, SharedString,
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
};
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
@@ -34,6 +34,7 @@ impl StatusDotVariant {
/// A small circular bead marking a row's state.
#[derive(IntoElement)]
#[must_use]
pub struct StatusDot {
id: ElementId,
variant: StatusDotVariant,
@@ -99,7 +100,9 @@ impl RenderOnce for StatusDot {
.size(side)
.rounded(theme.radius.full)
.bg(color)
.when_some(self.label, |dot, label| dot.aria_label(label))
.when_some(self.label, |dot, label| {
dot.role(Role::Status).aria_label(label)
})
.refine_style(&self.style)
}
}
+6 -5
View File
@@ -3,10 +3,10 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnimationExt as _, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
RenderOnce, Role, SharedString, SpringAnimation, SpringConfig, StatefulInteractiveElement as _,
Styled as _, Toggled, Window, div, px,
RenderOnce, Role, SharedString, SpringAnimation, StatefulInteractiveElement as _, Styled as _,
Toggled, Window, div, px,
};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_theme::{ActiveTheme as _, CONTROL_SPRING, Size};
use gpui_native_utils::focus_ring;
use crate::common::{Sizable, h_flex};
@@ -16,6 +16,7 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
/// Switch component.
#[derive(IntoElement)]
#[must_use]
pub struct Switch {
id: ElementId,
label: Option<SharedString>,
@@ -108,8 +109,8 @@ impl RenderOnce for Switch {
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
let id = self.id.clone();
let anim_config = SpringConfig::new(500., 35., 1.);
let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) });
let anim =
SpringAnimation::new(CONTROL_SPRING).to(if self.checked { travel } else { px(0.) });
h_flex()
.id(self.id)
+89 -87
View File
@@ -6,11 +6,12 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Task, Window, deferred, div, ease_out_quint, px,
IntoElement, ParentElement as _, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, Styled as _, Task, Window, deferred, div, ease_out_quint, px,
};
use gpui_native_icons::{Icon, IconRole, icon};
use gpui_native_theme::ActiveTheme as _;
use gpui_native_icons::{Icon, IconRole};
use gpui_native_theme::{ActiveTheme as _, TOAST_MAX_WIDTH};
use gpui_native_utils::ResultLogErr as _;
use crate::button::{Button, ButtonVariant};
use crate::common::{DismissHandler, Sizable as _};
@@ -41,6 +42,7 @@ impl ToastAction {
/// A toast notification.
#[derive(Clone)]
#[must_use]
pub struct Toast {
message: SharedString,
icon: Option<Icon>,
@@ -133,7 +135,8 @@ impl Toasts {
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
this.update(cx, |toasts, cx| toasts.dismiss(id, cx)).ok();
this.update(cx, |toasts, cx| toasts.dismiss(id, cx))
.log_err();
});
self.tasks.push(task);
}
@@ -147,8 +150,7 @@ impl Toasts {
self.finish_exit(cx);
return;
}
if self.current.as_ref().is_some_and(|entry| entry.id == id) {
let entry = self.current.take().expect("current checked above");
if let Some(entry) = self.current.take_if(|entry| entry.id == id) {
self.start_exit(entry, cx);
}
}
@@ -177,7 +179,7 @@ impl Toasts {
if let Some(previous) = self.exiting.take()
&& let Some(on_dismiss) = &previous.toast.on_dismiss
{
(on_dismiss.clone())(cx);
on_dismiss(cx);
}
self.exiting = Some(entry);
@@ -186,7 +188,8 @@ impl Toasts {
let fade = cx.theme().motion.overlay;
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(fade).await;
this.update(cx, |toasts, cx| toasts.finish_exit(cx)).ok();
this.update(cx, |toasts, cx| toasts.finish_exit(cx))
.log_err();
});
self.tasks.push(task);
}
@@ -195,13 +198,15 @@ impl Toasts {
if let Some(entry) = self.exiting.take() {
cx.notify();
if let Some(on_dismiss) = &entry.toast.on_dismiss {
(on_dismiss.clone())(cx);
on_dismiss(cx);
}
}
}
}
/// The floating stack of toasts, bottom center.
#[derive(IntoElement)]
#[must_use]
pub struct ToastsView {
toasts: Entity<Toasts>,
id: ElementId,
@@ -239,7 +244,7 @@ impl RenderOnce for ToastsView {
.items_center()
.when_some(exiting, |column, entry| {
let id = entry.id;
column.child(pill(&entry, &self.toasts, cx).with_animation(
column.child(entry.pill(&self.toasts, cx).with_animation(
("toast-exit", id),
Animation::new(fade),
|pill, delta| pill.opacity(1. - delta),
@@ -247,7 +252,7 @@ impl RenderOnce for ToastsView {
})
.when_some(current, |column, entry| {
let id = entry.id;
column.child(pill(&entry, &self.toasts, cx).with_animation(
column.child(entry.pill(&self.toasts, cx).with_animation(
("toast-enter", id),
Animation::new(fade).with_easing(ease_out_quint()),
|pill, delta| pill.opacity(delta),
@@ -257,90 +262,87 @@ impl RenderOnce for ToastsView {
}
}
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
let theme = cx.theme();
let surface = theme.colors.surface;
let border = if theme.borders.overlays {
theme.colors.border
} else {
surface
};
let foreground = theme.colors.foreground;
let muted = theme.colors.muted;
let radius = theme.radius.full;
let shadows = theme.elevations.popover.shadows();
let icon_size = theme.sizes.small.icon_size;
let dense_icon_size = theme.sizes.small.dense_icon_size;
let text_size = theme.sizes.small.text_size;
let toast = &entry.toast;
let toasts = toasts.clone();
let id = entry.id;
impl ToastEntry {
/// The rendered pill for this toast.
fn pill(&self, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
let theme = cx.theme();
let surface = theme.colors.surface;
let border = if theme.borders.overlays {
theme.colors.border
} else {
surface
};
let foreground = theme.colors.foreground;
let radius = theme.radius.full;
let shadows = theme.elevations.popover.shadows();
let icon_size = theme.sizes.small.icon_size;
let text_size = theme.sizes.small.text_size;
let toast = &self.toast;
let toasts = toasts.clone();
let id = self.id;
div()
.id(("toast", id))
.max_w(px(480.))
.flex()
.items_center()
.py_1p5()
.px_3()
.bg(surface)
.border_1()
.border_color(border)
.rounded(radius)
.shadow(shadows.clone())
.when_some(toast.icon.clone(), |pill, icon| {
pill.child(
div()
.id(("toast", id))
.role(Role::Alert)
.aria_label(toast.message.clone())
.max_w(TOAST_MAX_WIDTH)
.flex()
.items_center()
.py_1p5()
.px_3()
.bg(surface)
.border_1()
.border_color(border)
.rounded(radius)
.shadow(shadows.clone())
.when_some(toast.icon.clone(), |pill, icon| {
pill.child(
div()
.flex_none()
.mr_2()
.flex()
.items_center()
.child(icon.size(icon_size).color(foreground)),
)
})
.child(
div()
.flex_none()
.mr_2()
.flex()
.items_center()
.child(icon.size(icon_size).color(foreground)),
.min_w(px(0.))
.truncate()
.text_size(text_size)
.text_color(foreground)
.child(toast.message.clone()),
)
})
.child(
div()
.min_w(px(0.))
.truncate()
.text_size(text_size)
.text_color(foreground)
.child(toast.message.clone()),
)
.when_some(toast.action.clone(), |pill, action| {
let toasts = toasts.clone();
pill.child(
div().flex_none().ml_2p5().child(
Button::new(("toast-action", id))
.label(action.label.clone())
.variant(ButtonVariant::Primary)
.when_some(toast.action.clone(), |pill, action| {
let toasts = toasts.clone();
pill.child(
div().flex_none().ml_2p5().child(
Button::new(("toast-action", id))
.label(action.label.clone())
.variant(ButtonVariant::Primary)
.small()
.on_click(move |_, _window, cx| {
(action.handler)(cx);
toasts.update(cx, |toasts, cx| {
toasts.dismiss(id, cx);
});
}),
),
)
})
.child(
div().flex_none().ml_4().child(
Button::new(("toast-close", id))
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.accessibility_label("Dismiss")
.on_click(move |_, _window, cx| {
(action.handler.clone())(cx);
toasts.update(cx, |toasts, cx| {
toasts.dismiss(id, cx);
});
}),
),
)
})
.child(
div()
.id(("toast-close", id))
.flex_none()
.ml_4()
.cursor_pointer()
.p_1()
.rounded(theme.radius.sm)
.hover(|style| style.bg(muted))
.child(
icon(IconRole::Close)
.size(dense_icon_size)
.color(theme.colors.muted_foreground),
)
.on_click(move |_, _window, cx| {
toasts.update(cx, |toasts, cx| {
toasts.dismiss(id, cx);
});
}),
)
}
}
+1
View File
@@ -13,6 +13,7 @@ type ChangeHandler = Box<dyn Fn(bool, &mut Window, &mut App)>;
/// A toggle component.
#[derive(IntoElement)]
#[must_use]
pub struct Toggle {
id: ElementId,
label: Option<SharedString>,
+10 -2
View File
@@ -1,7 +1,8 @@
use gpui::AppContext as _;
use gpui::{
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point,
ParentElement as _, Pixels, Render, Role, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, canvas, div, point,
};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, TOOLTIP_MAX_WIDTH, TOOLTIP_OFFSET};
use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
@@ -27,7 +28,12 @@ impl Tooltip {
}
/// Sets the anchor element ID for the tooltip.
pub fn anchored<L, I>(text: L, anchor_id: I) -> impl Fn(&mut Window, &mut App) -> AnyView
///
/// The `.tooltip(...)` closure pinning the panel next to `anchor_id`.
pub fn anchored<L, I>(
text: L,
anchor_id: I,
) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static
where
L: Into<SharedString>,
I: Into<ElementId>,
@@ -73,6 +79,8 @@ impl Render for Tooltip {
let build_panel = || {
div()
.id("tooltip-panel")
.role(Role::Tooltip)
.debug_selector(|| "tooltip-panel".to_string())
.px_2()
.py_1()