chore: audit components
This commit is contained in:
+21
-18
@@ -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)))
|
||||
},
|
||||
)
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', "")
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user