1 Commits
Author SHA1 Message Date
reya b75968ee0f switch gpui family to gpui-pre 0.3.10 and re-export editor crate 2026-11-09 21:17:50 +07:00
25 changed files with 1554 additions and 2453 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
# Coding guidelines
- Prefer existing components (e.g. build a close affordance out of `Button`) over custom elements; build a custom element only when no existing component fits.
- Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
- Do not write organizational or comments that summarize the code. Comments should only be written to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
- Keep doc comments and comments simple, one line, straightforward, and unbloated.
@@ -64,7 +63,7 @@ GPUI is a UI framework which also provides primitives for state and concurrency
## Rendering and state
- `Render` builds an element tree from entity state; `RenderOnce` (with `#[derive(IntoElement)]`) is for value-like components whose `render` takes `self` and `&mut App`.
- Keep `render` declarative: build elements as one chained expression, using `.when(...)` / `.when_some(...)` / `.map(...)` for conditional refinements instead of `let mut` element variables reassigned under `if` / `if let`.
- Keep `render` declarative; use `.when(...)` / `.when_some(...)` for small conditional refinements.
- Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth.
- Call `cx.notify()` after a mutation that changes rendering; notify the narrowest owning entity. Never notify from `render`.
- `render` is side-effect free: no `cx.notify()`, no `entity.update(...)`, no writing derived text or state back into other entities during render. A write there forces another frame, and a write that emits an event re-enters the event handler — an unbounded redraw loop. Reconcile state in event handlers instead, with silent setters (`set_value_silent`-style) so a control's own write does not re-enter the events it mirrors.
Generated
+688 -709
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -24,9 +24,9 @@ gpui-native-icons = { path = "crates/icons" }
gpui-native-theme = { path = "crates/theme" }
[workspace.dependencies]
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit"] }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
gpui = { package = "gpui-pre", version = "0.3.10" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.10", features = ["font-kit"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.10" }
gpui-native-core = { path = "crates/core", default-features = false }
gpui-native-data = { path = "crates/data", default-features = false }
+3 -3
View File
@@ -63,8 +63,8 @@ badge = []
input = ["button"]
clipboard = ["button"]
popover = []
scroll_area = ["scrollbar"]
scrollbar = []
scroll_area = []
scrollbar = ["scroll_area"]
segmented = []
separator = []
spinner = []
@@ -76,7 +76,7 @@ tooltip = []
menu = ["scroll_area"]
dropdown_menu = ["menu", "button"]
select = ["menu"]
dialog = ["button"]
dialog = []
empty_state = []
alert = []
toast = []
+9 -76
View File
@@ -1,19 +1,11 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyView, App, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _,
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _,
Window, transparent_black,
App, Hsla, IntoElement, ParentElement as _, RenderOnce, SharedString, StyleRefinement,
Styled as _, Window,
};
use gpui_native_icons::Icon;
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
use crate::common::{Sizable, StyledExt, h_flex};
/// The tooltip view builder gpui's `.tooltip(...)` expects.
type TooltipBuilder = Rc<dyn Fn(&mut Window, &mut App) -> AnyView>;
/// How loud a badge is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BadgeVariant {
@@ -28,42 +20,18 @@ pub enum BadgeVariant {
Warning,
/// The destructive ramp.
Danger,
/// No fill, just a border; the quietest badge.
Outline,
}
impl BadgeVariant {
fn tokens(self, theme: &Theme) -> (Hsla, Hsla, Hsla) {
fn tokens(self, theme: &Theme) -> (Hsla, Hsla) {
match self {
Self::Neutral => (
theme.colors.muted,
theme.colors.muted_foreground,
transparent_black(),
),
Self::Accent => (
theme.ramps.primary.base,
theme.colors.primary_foreground,
transparent_black(),
),
Self::Success => (
theme.ramps.success.base,
theme.colors.success_foreground,
transparent_black(),
),
Self::Warning => (
theme.ramps.warning.base,
theme.colors.warning_foreground,
transparent_black(),
),
Self::Neutral => (theme.colors.muted, theme.colors.muted_foreground),
Self::Accent => (theme.ramps.primary.base, theme.colors.primary_foreground),
Self::Success => (theme.ramps.success.base, theme.colors.success_foreground),
Self::Warning => (theme.ramps.warning.base, theme.colors.warning_foreground),
Self::Danger => (
theme.ramps.destructive.base,
theme.colors.destructive_foreground,
transparent_black(),
),
Self::Outline => (
transparent_black(),
theme.colors.foreground,
theme.colors.border,
),
}
}
@@ -72,29 +40,22 @@ impl BadgeVariant {
/// A small pill for a count or a state.
#[derive(IntoElement)]
pub struct Badge {
id: ElementId,
label: SharedString,
icon: Option<Icon>,
variant: BadgeVariant,
size: Size,
tooltip: Option<TooltipBuilder>,
style: StyleRefinement,
}
impl Badge {
/// Creates a badge showing `label` — a count or state word the caller formats.
pub fn new<I, L>(id: I, label: L) -> Self
pub fn new<L>(label: L) -> Self
where
I: Into<ElementId>,
L: Into<SharedString>,
{
Self {
id: id.into(),
label: label.into(),
icon: None,
variant: BadgeVariant::default(),
size: Size::default(),
tooltip: None,
style: StyleRefinement::default(),
}
}
@@ -104,24 +65,6 @@ impl Badge {
self.variant = variant;
self
}
/// Shows an icon before the label.
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<Icon>,
{
self.icon = Some(icon.into());
self
}
/// Shows a tooltip view next to the badge after it is hovered.
pub fn tooltip<F>(mut self, tooltip: F) -> Self
where
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
self.tooltip = Some(Rc::new(tooltip));
self
}
}
impl Sizable for Badge {
@@ -141,28 +84,18 @@ impl RenderOnce for Badge {
let theme = cx.theme();
let metrics = theme.sizes.resolve(self.size);
let label_size = metrics.caption_size;
let (background, foreground, border) = self.variant.tokens(theme);
let (background, foreground) = self.variant.tokens(theme);
h_flex()
.id(self.id.clone())
.flex_none()
.justify_center()
.gap_1()
.rounded(theme.radius.full)
.border_1()
.border_color(border)
.bg(background)
.text_color(foreground)
.text_size(label_size)
.px(metrics.dense_padding_x)
.py_0p5()
.when_some(self.icon, |badge, icon| {
badge.child(icon.size(metrics.dense_icon_size).color(foreground))
})
.child(self.label)
.refine_style(&self.style)
.when_some(self.tooltip, |badge, tooltip| {
badge.tooltip(move |window, cx| tooltip(window, cx))
})
}
}
+148 -151
View File
@@ -518,7 +518,7 @@ where
let wrapper_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
div()
let mut wrapper = div()
.id(id.clone())
.key_context(COMBOBOX_CONTEXT)
.relative()
@@ -575,162 +575,159 @@ where
.right(metrics.dense_padding_x)
.top((metrics.input_height + BORDER_WIDTH * 2.0 - icon_size) / 2.)
.child(chevron),
)
.when(open, |wrapper| {
let rows: Vec<gpui::AnyElement> = self
.visible
.iter()
.enumerate()
.map(|(row, option_ix)| {
let (value, label) = &self.options[*option_ix];
let highlighted = self.highlight == Some(row);
let checked = self.selected.as_ref() == Some(value);
let state = state.clone();
let row_ix = row;
);
div()
.id(("option", row))
.debug_selector({
let id = id.clone();
move || format!("{id}-option-{row}")
})
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(radius.sm)
.when(highlighted, |row| row.bg(colors.muted))
.when(!highlighted, |row| row.hover(|row| row.bg(colors.muted)))
.on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
if state.read(cx).highlight != Some(row_ix) {
if open {
let rows: Vec<gpui::AnyElement> = self
.visible
.iter()
.enumerate()
.map(|(row, option_ix)| {
let (value, label) = &self.options[*option_ix];
let highlighted = self.highlight == Some(row);
let checked = self.selected.as_ref() == Some(value);
let state = state.clone();
let row_ix = row;
div()
.id(("option", row))
.debug_selector({
let id = id.clone();
move || format!("{id}-option-{row}")
})
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(radius.sm)
.when(highlighted, |row| row.bg(colors.muted))
.when(!highlighted, |row| row.hover(|row| row.bg(colors.muted)))
.on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
if state.read(cx).highlight != Some(row_ix) {
state.update(cx, |state, cx| {
state.highlight = Some(row_ix);
cx.notify();
});
}
}
})
.on_click({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| state.choose_row(row_ix, window, cx));
}
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(label.clone()),
)
.when(checked, |row| {
row.child(
icon(IconRole::Check)
.size(icon_size)
.color(colors.foreground),
)
})
.into_any_element()
})
.collect();
let rows: gpui::AnyElement = if rows.is_empty() {
div()
.px_2()
.py_1()
.text_size(metrics.caption_size)
.text_color(colors.muted_foreground)
.child("No matches")
.into_any_element()
} else {
let scroll = window
.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new());
ScrollArea::new((id.clone(), "rows"))
.handle(scroll.read(cx).clone())
.axes(crate::scroll_area::ScrollAxes::Vertical)
.max_h(POPUP_MAX_HEIGHT)
.children(rows)
.into_any_element()
};
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let popup_width = wrapper_bounds
.borrow()
.map(|bounds| bounds.size.width)
.unwrap_or_else(|| window.rem_size() * 12.0);
let panel = v_flex()
.id((id.clone(), "panel"))
.debug_selector({
let id = id.clone();
move || format!("{id}-panel")
})
.w(popup_width)
.p_1()
.bg(colors.surface)
.text_color(colors.foreground)
.rounded(radius.overlay)
.border_1()
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.child(rows)
.child(
canvas(
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let state = state.clone();
let panel_bounds = panel_bounds.clone();
let wrapper_bounds = wrapper_bounds.clone();
move |_, _, window, _cx| {
window.on_mouse_event({
let state = state.clone();
let panel_bounds = panel_bounds.clone();
let wrapper_bounds = wrapper_bounds.clone();
move |event: &MouseDownEvent, phase, _window, cx| {
if phase != gpui::DispatchPhase::Bubble {
return;
}
let on_panel = panel_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
let on_wrapper = wrapper_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if on_panel || on_wrapper {
return;
}
state.update(cx, |state, cx| {
state.highlight = Some(row_ix);
state.highlight = None;
cx.notify();
});
}
}
})
.on_click({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.choose_row(row_ix, window, cx)
});
}
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(label.clone()),
)
.when(checked, |row| {
row.child(
icon(IconRole::Check)
.size(icon_size)
.color(colors.foreground),
)
})
.into_any_element()
})
.collect();
});
}
},
)
.absolute()
.inset_0(),
);
let rows: gpui::AnyElement = if rows.is_empty() {
div()
.px_2()
.py_1()
.text_size(metrics.caption_size)
.text_color(colors.muted_foreground)
.child("No matches")
.into_any_element()
} else {
let scroll = window.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| {
ScrollHandle::new()
});
wrapper = wrapper.child(deferred(
anchored().offset(point(px(0.), spacing_unit)).child(panel),
));
}
ScrollArea::new((id.clone(), "rows"))
.handle(scroll.read(cx).clone())
.axes(crate::scroll_area::ScrollAxes::Vertical)
.max_h(POPUP_MAX_HEIGHT)
.children(rows)
.into_any_element()
};
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let popup_width = wrapper_bounds
.borrow()
.map(|bounds| bounds.size.width)
.unwrap_or_else(|| window.rem_size() * 12.0);
let panel = v_flex()
.id((id.clone(), "panel"))
.debug_selector({
let id = id.clone();
move || format!("{id}-panel")
})
.w(popup_width)
.p_1()
.bg(colors.surface)
.text_color(colors.foreground)
.rounded(radius.overlay)
.border_1()
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.child(rows)
.child(
canvas(
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let state = state.clone();
let panel_bounds = panel_bounds.clone();
let wrapper_bounds = wrapper_bounds.clone();
move |_, _, window, _cx| {
window.on_mouse_event({
let state = state.clone();
let panel_bounds = panel_bounds.clone();
let wrapper_bounds = wrapper_bounds.clone();
move |event: &MouseDownEvent, phase, _window, cx| {
if phase != gpui::DispatchPhase::Bubble {
return;
}
let on_panel =
panel_bounds.borrow().is_some_and(|bounds| {
bounds.contains(&event.position)
});
let on_wrapper =
wrapper_bounds.borrow().is_some_and(|bounds| {
bounds.contains(&event.position)
});
if on_panel || on_wrapper {
return;
}
state.update(cx, |state, cx| {
state.highlight = None;
cx.notify();
});
}
});
}
},
)
.absolute()
.inset_0(),
);
wrapper.child(deferred(
anchored().offset(point(px(0.), spacing_unit)).child(panel),
))
})
wrapper
}
}
+6 -38
View File
@@ -6,17 +6,14 @@ use gpui::{
IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
};
use gpui_native_icons::IconRole;
use gpui_native_theme::ActiveTheme as _;
use crate::button::{Button, ButtonVariant};
use crate::common::{Sizable, v_flex};
use crate::common::v_flex;
/// Requests the dialog's close: `false` closes.
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
type ContentFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
/// A centred panel over a dim scrim: the platform's modal.
#[derive(IntoElement)]
pub struct Dialog {
@@ -60,7 +57,7 @@ impl Dialog {
self
}
/// Requests close through `on_change` on escape, scrim clicks, and the close button.
/// Requests close through `on_change` on escape and scrim clicks.
pub fn on_change<F>(mut self, handler: F) -> Self
where
F: Fn(bool, &mut Window, &mut App) + 'static,
@@ -157,7 +154,6 @@ impl RenderOnce for Dialog {
let on_change = self.on_change.clone();
let alert = self.alert;
let show_close = !alert && on_change.is_some();
// Keyboard focus rides the panel across frames.
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
@@ -194,40 +190,12 @@ impl RenderOnce for Dialog {
cx.stop_propagation();
}
})
.when(self.title.is_some() || show_close, |panel| {
.when_some(self.title, |panel, title| {
panel.child(
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.when_some(self.title.clone(), |row, title| {
row.child(
div()
.flex_1()
.text_size(title_size)
.font_weight(FontWeight::SEMIBOLD)
.child(title),
)
})
.when(show_close, |row| {
row.child(
Button::new("dialog-close")
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.debug_selector(|| "dialog-close".to_string())
.accessibility_label("Close")
.on_click({
let on_change = on_change.clone();
move |_, window, cx| {
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
}
}),
)
}),
.text_size(title_size)
.font_weight(FontWeight::SEMIBOLD)
.child(title),
)
})
.when_some(content, |panel, content| {
+34 -46
View File
@@ -5,9 +5,9 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ClickEvent, DefiniteLength, DispatchPhase, ElementId, InteractiveElement as _,
IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role,
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
};
use gpui_native_icons::{Icon, IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
@@ -15,9 +15,7 @@ use gpui_native_utils::{focus_handle, focus_ring};
use crate::button::ButtonVariant;
use crate::common::{Sizable, h_flex};
use crate::menu::{
ChangeHandler, MenuChrome, MenuItem, MenuPanelSpec, TriggerBoundsCell, anchored_menu_panel,
};
use crate::menu::{ChangeHandler, MenuChrome, MenuItem, TriggerBoundsCell, anchored_menu_panel};
use crate::spinner::Spinner;
/// The main zone's activation handler, shared by pointer, Enter, and Space.
@@ -40,7 +38,6 @@ pub struct DropdownMenu {
on_click: Option<ClickHandler>,
on_open_change: Option<ChangeHandler>,
items: Option<ItemsFn>,
min_width: Option<DefiniteLength>,
}
impl DropdownMenu {
@@ -63,7 +60,6 @@ impl DropdownMenu {
on_click: None,
on_open_change: None,
items: None,
min_width: None,
}
}
@@ -150,15 +146,6 @@ impl DropdownMenu {
self.items = Some(Box::new(items));
self
}
/// Sets the panel's minimum width, defaults to the standard menu width.
pub fn min_width<W>(mut self, width: W) -> Self
where
W: Into<DefiniteLength>,
{
self.min_width = Some(width.into());
self
}
}
impl Sizable for DropdownMenu {
@@ -379,30 +366,7 @@ impl RenderOnce for DropdownMenu {
trigger
};
let items = if self.open {
self.items
.map(|build| Rc::from(build(window, cx)))
.unwrap_or_else(|| Rc::from([]))
} else {
Rc::from([])
};
let on_close = Some(on_open.clone());
let spec = MenuPanelSpec {
open: self.open,
items,
initial_selection: None,
preselect: *keyboard_open.read(cx),
min_width: self.min_width,
label,
position: None,
trigger_bounds: trigger_bounds.clone(),
on_dismiss: on_close.clone(),
on_activate: on_close,
};
div()
let mut wrapper = div()
.relative()
.child(
canvas(
@@ -457,10 +421,34 @@ impl RenderOnce for DropdownMenu {
.absolute()
.inset_0(),
)
.child(trigger)
.when_some(
anchored_menu_panel(window, cx, &self.id, spec, &panel_chrome, enter_duration),
|wrapper, panel| wrapper.child(panel),
)
.child(trigger);
let items = if self.open {
self.items
.map(|build| Rc::from(build(window, cx)))
.unwrap_or_else(|| Rc::from([]))
} else {
Rc::from([])
};
if let Some(panel) = anchored_menu_panel(
window,
cx,
&self.id,
self.open,
items,
None,
*keyboard_open.read(cx),
Some(on_open),
label,
None,
&trigger_bounds,
&panel_chrome,
enter_duration,
) {
wrapper = wrapper.child(panel);
}
wrapper
}
}
+177 -570
View File
@@ -1,19 +1,16 @@
//! The floating menu panel shared by menus, dropdowns, and context menus.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, DefiniteLength,
DispatchPhase, ElementId, Entity, FocusHandle, InteractiveElement as _, IntoElement,
KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, RenderOnce, Role,
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Window, anchored,
canvas, deferred, div, ease_out_quint, point, px, relative,
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, DispatchPhase, ElementId,
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, anchored, 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_utils::focus_handle;
use crate::common::v_flex;
use crate::scroll_area::{ScrollArea, ScrollAxes};
@@ -28,7 +25,7 @@ pub(crate) type TriggerBoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
type TriggerFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
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.
/// One row of a [`Menu`]: an action, a checkable action, or a separator.
#[derive(Clone)]
pub struct MenuItem {
kind: MenuItemKind,
@@ -45,7 +42,6 @@ pub struct MenuItem {
enum MenuItemKind {
Action,
Separator,
Submenu(Rc<[MenuItem]>),
}
impl MenuItem {
@@ -125,32 +121,12 @@ impl MenuItem {
self
}
/// Opens a submenu with these rows instead of running an action.
pub fn submenu<I>(mut self, items: I) -> Self
where
I: IntoIterator<Item = MenuItem>,
{
self.kind = MenuItemKind::Submenu(items.into_iter().collect::<Vec<_>>().into());
self
}
/// The submenu's rows, when this item opens one.
fn submenu_items(&self) -> Option<&[MenuItem]> {
match &self.kind {
MenuItemKind::Submenu(items) => Some(items),
_ => None,
}
}
/// Whether keyboard navigation and activation may land on this item.
fn selectable(&self) -> bool {
matches!(self.kind, MenuItemKind::Action | MenuItemKind::Submenu(_)) && !self.disabled
matches!(self.kind, MenuItemKind::Action) && !self.disabled
}
}
// MenuItem is a data builder, not an element, adopt the combinators explicitly.
impl FluentBuilder for MenuItem {}
/// Menu chrome styles.
#[derive(Clone)]
pub(crate) struct MenuChrome {
@@ -186,7 +162,6 @@ pub struct Menu {
on_change: Option<ChangeHandler>,
trigger: Option<TriggerFn>,
items: Option<ItemsFn>,
min_width: Option<DefiniteLength>,
}
impl MenuChrome {
@@ -239,7 +214,6 @@ impl Menu {
on_change: None,
trigger: None,
items: None,
min_width: None,
}
}
@@ -302,51 +276,19 @@ impl Menu {
self.a11y_label = Some(label.into());
self
}
/// Sets the panel's minimum width; defaults to the standard menu width.
pub fn min_width<W>(mut self, width: W) -> Self
where
W: Into<DefiniteLength>,
{
self.min_width = Some(width.into());
self
}
}
impl RenderOnce for Menu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (chrome, enter_duration) = MenuChrome::read(cx);
let trigger_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let on_change = self.on_change.clone();
let fallback_label = self
.a11y_label
.clone()
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
let items = if self.open {
match self.items {
Some(build) => Rc::from(build(window, cx)),
None => Rc::from([]),
}
} else {
Rc::from([])
};
let spec = MenuPanelSpec {
open: self.open,
items,
initial_selection: self.initial_selection,
preselect: false,
min_width: self.min_width,
label: fallback_label,
position: self.open_at,
trigger_bounds: trigger_bounds.clone(),
on_dismiss: on_change.clone(),
on_activate: self.on_change,
};
div()
let mut wrapper = div()
.relative()
.child(
canvas(
@@ -355,7 +297,7 @@ impl RenderOnce for Menu {
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let on_change = on_change;
let on_change = self.on_change.clone();
let open = self.open;
let trigger_button = self.trigger_button;
let cell = trigger_bounds.clone();
@@ -393,174 +335,78 @@ impl RenderOnce for Menu {
)
.when_some(self.trigger, |wrapper, trigger| {
wrapper.child(trigger(window, cx))
})
.when_some(
anchored_menu_panel(window, cx, &self.id, spec, &chrome, enter_duration),
|wrapper, panel| wrapper.child(panel),
)
});
let items = if self.open {
match self.items {
Some(build) => Rc::from(build(window, cx)),
None => Rc::from([]),
}
} else {
Rc::from([])
};
if let Some(panel) = anchored_menu_panel(
window,
cx,
&self.id,
self.open,
items,
self.initial_selection,
false,
self.on_change,
fallback_label,
self.open_at,
&trigger_bounds,
&chrome,
enter_duration,
) {
wrapper = wrapper.child(panel);
}
wrapper
}
}
/// Everything the floating menu panel needs from its owner.
pub(crate) struct MenuPanelSpec {
pub open: bool,
pub items: Rc<[MenuItem]>,
pub initial_selection: Option<usize>,
pub preselect: bool,
pub min_width: Option<DefiniteLength>,
pub label: SharedString,
pub position: Option<Point<Pixels>>,
pub trigger_bounds: TriggerBoundsCell,
/// Closes this panel: outside clicks, `escape`, and `left` past a closed flyout.
pub on_dismiss: Option<ChangeHandler>,
/// Closes the whole menu when a row inside activates.
pub on_activate: Option<ChangeHandler>,
}
/// Per-panel state that survives across frames while the panel stays mounted.
struct MenuPanelState {
focus: FocusHandle,
was_open: Entity<bool>,
scroll: Entity<ScrollHandle>,
selection: Entity<Option<usize>>,
}
fn create_menu_panel_state(id: &ElementId, window: &mut Window, cx: &mut App) -> MenuPanelState {
MenuPanelState {
focus: focus_handle(&(id.clone(), "panel").into(), window, cx),
was_open: window.use_keyed_state((id.clone(), "menu-was-open"), cx, |_, _| false),
scroll: window.use_keyed_state((id.clone(), "menu-scroll"), cx, |_, _| ScrollHandle::new()),
selection: window.use_keyed_state((id.clone(), "menu-selection"), cx, |_, _| None),
}
}
struct MenuPanelParams<'a> {
id: &'a ElementId,
items: Rc<[MenuItem]>,
initial_selection: Option<usize>,
preselect: bool,
min_width: Option<DefiniteLength>,
label: SharedString,
/// Clicks here do not dismiss the panel: the trigger or the owning row.
dismiss_bounds: &'a TriggerBoundsCell,
/// Receives the panel's painted bounds, for a parent's dismissal checks.
bounds_sink: Option<&'a TriggerBoundsCell>,
/// This panel's flyout bounds are pushed here.
outer_stack: Option<&'a Rc<RefCell<Vec<TriggerBoundsCell>>>>,
on_dismiss: Option<ChangeHandler>,
on_activate: Option<ChangeHandler>,
/// Flyout panels dismiss themselves on `left`; top-level panels ignore it.
is_flyout: bool,
chrome: &'a MenuChrome,
enter_duration: std::time::Duration,
debug_prefix: &'static str,
}
/// Wiring a panel's rows need to open their submenus.
struct FlyoutContext<'a> {
state: &'a MenuPanelState,
open: &'a Entity<Option<usize>>,
preselect: &'a Entity<bool>,
/// 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.
stack: &'a Rc<RefCell<Vec<TriggerBoundsCell>>>,
/// Closes this level's flyout and returns focus to this panel.
dismiss: ChangeHandler,
on_activate: Option<ChangeHandler>,
id: ElementId,
enter_duration: std::time::Duration,
min_width: Option<DefiniteLength>,
}
struct RowEnv<'a> {
chrome: &'a MenuChrome,
selection: &'a Entity<Option<usize>>,
on_activate: Option<ChangeHandler>,
flyout: &'a FlyoutContext<'a>,
debug_prefix: &'static str,
}
/// Assembles the floating menu panel anchored to its trigger.
/// Assembles the floating menu panel.
#[allow(clippy::too_many_arguments)]
pub(crate) fn anchored_menu_panel(
window: &mut Window,
cx: &mut App,
id: &ElementId,
spec: MenuPanelSpec,
open: bool,
items: Rc<[MenuItem]>,
initial_selection: Option<usize>,
preselect: bool,
on_change: Option<ChangeHandler>,
label: SharedString,
position: Option<Point<Pixels>>,
trigger_bounds: &TriggerBoundsCell,
chrome: &MenuChrome,
enter_duration: std::time::Duration,
) -> Option<AnyElement> {
let was_open = window.use_keyed_state((id.clone(), "menu-was-open"), cx, |_, _| false);
if !spec.open {
if !open {
was_open.update(cx, |was_open, _| *was_open = false);
// A closed menu drops any open flyout; fresh state on the next open.
window
.use_keyed_state::<Option<usize>>((id.clone(), "submenu-open"), cx, |_, _| None)
.update(cx, |open, _| *open = None);
window
.use_keyed_state((id.clone(), "submenu-preselect"), cx, |_, _| false)
.update(cx, |preselect, _| *preselect = false);
return None;
}
let state = create_menu_panel_state(id, window, cx);
let panel = menu_panel(
window,
cx,
&state,
MenuPanelParams {
id,
items: spec.items,
initial_selection: spec.initial_selection,
preselect: spec.preselect,
min_width: spec.min_width,
label: spec.label,
dismiss_bounds: &spec.trigger_bounds,
bounds_sink: None,
outer_stack: None,
on_dismiss: spec.on_dismiss,
on_activate: spec.on_activate,
is_flyout: false,
chrome,
enter_duration,
debug_prefix: "menu",
},
);
let anchored = match spec.position {
Some(position) => anchored().position(position),
None => anchored(),
}
.offset(point(px(0.), chrome.spacing_unit));
Some(deferred(anchored.child(panel)).into_any_element())
}
/// Builds one menu panel: chrome, rows, focus, navigation, and dismissals.
fn menu_panel(
window: &mut Window,
cx: &mut App,
state: &MenuPanelState,
params: MenuPanelParams,
) -> AnyElement {
let MenuPanelState {
focus,
was_open,
scroll,
selection,
} = state;
let on_change = on_change.clone();
// Keyboard focus rides the panel across frames
let focus = gpui_native_utils::focus_handle(&(id.clone(), "panel").into(), window, cx);
let scroll =
window.use_keyed_state((id.clone(), "menu-scroll"), cx, |_, _| ScrollHandle::new());
let selection = window.use_keyed_state((id.clone(), "menu-selection"), cx, |_, _| None);
let selected = if *was_open.read(cx) {
*selection.read(cx)
} else {
let requested = params
.initial_selection
.map(|index| index.min(params.items.len().saturating_sub(1)))
let requested = initial_selection
.map(|index| index.min(items.len().saturating_sub(1)))
.or_else(|| {
params
.preselect
.then(|| params.items.iter().position(MenuItem::selectable))
preselect
.then(|| items.iter().position(|item| item.selectable()))
.flatten()
});
selection.update(cx, |selection, _| *selection = requested);
@@ -568,151 +414,59 @@ fn menu_panel(
};
was_open.update(cx, |was_open, _| *was_open = true);
// Submenu plumbing for this level; a flyout panel recurses with its own.
let flyout_id: ElementId = (params.id.clone(), "submenu").into();
let flyout_state = create_menu_panel_state(&flyout_id, window, cx);
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());
if let Some(outer_stack) = params.outer_stack {
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 = {
let flyout_open = flyout_open.clone();
let flyout_preselect = flyout_preselect.clone();
let focus = focus.clone();
Rc::new(move |_, window, cx| {
flyout_open.update(cx, |open, _| *open = None);
flyout_preselect.update(cx, |preselect, _| *preselect = false);
window.focus(&focus, cx);
})
};
let flyout = FlyoutContext {
state: &flyout_state,
open: &flyout_open,
preselect: &flyout_preselect,
bounds: &flyout_bounds,
stack: &flyout_stack,
dismiss: dismiss_flyout,
on_activate: params.on_activate.clone(),
id: flyout_id,
enter_duration: params.enter_duration,
min_width: params.min_width,
};
let rows: Vec<AnyElement> = params
.items
let rows: Vec<AnyElement> = items
.iter()
.enumerate()
.map(|(index, item)| {
render_item(
window,
cx,
item,
index,
selected == Some(index),
&RowEnv {
chrome: params.chrome,
selection,
on_activate: params.on_activate.clone(),
flyout: &flyout,
debug_prefix: params.debug_prefix,
},
&selection,
&on_change,
chrome,
)
})
.collect();
v_flex()
let panel = v_flex()
.id("menu-panel")
.debug_selector(move || format!("{}-panel", params.debug_prefix))
.debug_selector(|| "menu-panel".to_string())
.occlude()
.track_focus(focus)
.track_focus(&focus)
.role(Role::Menu)
.aria_label(params.label)
.when_some(params.min_width, |panel, width| panel.min_w(width))
.when(params.min_width.is_none(), |panel| panel.min_w_56())
.p(params.chrome.spacing_unit)
.bg(params.chrome.surface)
.text_color(params.chrome.foreground)
.rounded(params.chrome.radius_overlay)
.aria_label(label)
.min_w_56()
.p(chrome.spacing_unit)
.bg(chrome.surface)
.text_color(chrome.foreground)
.rounded(chrome.radius_overlay)
.border_1()
// A flyout overlaps its parent, so it always wears a visible border.
.border_color(if params.is_flyout {
params.chrome.border
} else {
params.chrome.panel_border
})
.shadow(params.chrome.shadow.clone())
.border_color(chrome.panel_border)
.shadow(chrome.shadow.clone())
.overflow_hidden()
.on_mouse_down_out({
let on_dismiss = params.on_dismiss.clone();
let dismiss_bounds = params.dismiss_bounds.clone();
let flyout_stack = flyout_stack.clone();
let on_change = on_change.clone();
let trigger_bounds = trigger_bounds.clone();
move |event, window, cx| {
if dismiss_bounds
if trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position))
{
return;
}
// Clicks inside any open flyout belong to that flyout.
if flyout_stack.borrow().iter().any(|cell| {
cell.borrow()
.is_some_and(|bounds| bounds.contains(&event.position))
}) {
return;
}
if let Some(on_dismiss) = &on_dismiss {
on_dismiss(false, window, cx);
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
}
})
.on_key_down({
let items = params.items.clone();
let items = items.clone();
let on_change = on_change.clone();
let selection = selection.clone();
let flyout_open = flyout_open.clone();
let flyout_preselect = flyout_preselect.clone();
let on_dismiss = params.on_dismiss.clone();
let on_activate = params.on_activate.clone();
let left_dismisses = params.is_flyout;
move |event: &KeyDownEvent, window, cx| {
handle_menu_key(
event,
MenuKeyEnv {
items: &items,
selection: &selection,
flyout_open: &flyout_open,
flyout_preselect: &flyout_preselect,
on_dismiss: &on_dismiss,
on_activate: &on_activate,
left_dismisses,
},
window,
cx,
);
handle_menu_key(event, &items, &selection, &on_change, window, cx);
}
})
.child(
@@ -723,81 +477,110 @@ fn menu_panel(
.child(v_flex().gap_0p5().children(rows)),
)
.child(
canvas(
{
let bounds_sink = params.bounds_sink.cloned();
move |bounds, _window, _cx| {
if let Some(cell) = &bounds_sink {
*cell.borrow_mut() = Some(bounds);
}
canvas(|_, _, _| {}, {
let focus = focus.clone();
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
},
{
let focus = focus.clone();
let flyout_open = flyout_open.clone();
move |_, _, window, cx| {
if flyout_open.read(cx).is_none() && !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
}
},
)
}
})
.absolute()
.inset_0(),
)
.with_animation(
);
let anchored = match position {
Some(position) => anchored().position(position),
None => anchored(),
}
.offset(point(px(0.), chrome.spacing_unit));
Some(
deferred(anchored.child(panel.with_animation(
"menu-enter",
Animation::new(params.enter_duration).with_easing(ease_out_quint()),
Animation::new(enter_duration).with_easing(ease_out_quint()),
|panel, delta| panel.opacity(delta),
)
.into_any_element()
)))
.into_any_element(),
)
}
/// Renders one menu row.
fn render_item(
window: &mut Window,
cx: &mut App,
item: &MenuItem,
index: usize,
selected: bool,
env: &RowEnv,
selection: &Entity<Option<usize>>,
on_change: &Option<ChangeHandler>,
chrome: &MenuChrome,
) -> AnyElement {
match &item.kind {
match item.kind {
MenuItemKind::Separator => div()
.h(px(1.))
.debug_selector(move || format!("{}-separator-{index}", env.debug_prefix))
.mx(env.chrome.spacing_unit * 2.0)
.my(env.chrome.spacing_unit)
.bg(env.chrome.border)
.debug_selector(move || format!("menu-separator-{index}"))
.mx(chrome.spacing_unit * 2.0)
.my(chrome.spacing_unit)
.bg(chrome.border)
.into_any_element(),
MenuItemKind::Action => {
let label_color = row_color(item, selected, false, env.chrome);
let label_color = row_color(item, selected, chrome);
let disabled = item.disabled;
let selectable = item.selectable();
let destructive = item.destructive;
let checked = item.checked;
let shortcut = item.shortcut.clone();
let icon = item.icon.clone();
row_shell(cx, item, index, selected, env)
div()
.id(("menu-item", index))
.debug_selector(move || format!("menu-item-{index}"))
.flex()
.items_center()
.gap_2()
.min_h(chrome.menu_item_height)
.px(chrome.spacing_unit * 2.0)
.text_size(chrome.text_size)
.line_height(relative(1.0))
.rounded(chrome.radius_sm)
.map(|row| {
if disabled {
row
} else if selected {
let highlight = if destructive {
chrome.destructive
} else {
chrome.primary
};
row.bg(highlight)
} else {
row.hover(|row| row.bg(chrome.muted_hover))
}
})
.on_mouse_move({
let selection = selection.clone();
move |_, _window, cx| {
if selectable && *selection.read(cx) != Some(index) {
selection.update(cx, |selection, _| *selection = Some(index));
}
}
})
.on_click({
let handler = item.handler.clone();
let on_activate = env.on_activate.clone();
let on_change = on_change.clone();
move |_, window, cx| {
if disabled {
return;
}
if let Some(on_activate) = &on_activate {
on_activate(false, window, cx);
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
if let Some(handler) = &handler {
handler(window, cx);
}
}
})
.when_some(icon, |row, icon| {
row.child(icon.size(env.chrome.icon_size).color(label_color))
row.child(icon.size(chrome.icon_size).color(label_color))
})
.child(
div()
@@ -809,9 +592,9 @@ fn render_item(
row.child(
div()
.text_color(if selected && !destructive && !disabled {
env.chrome.primary_foreground
chrome.primary_foreground
} else {
env.chrome.muted_foreground
chrome.muted_foreground
})
.child(shortcut),
)
@@ -819,201 +602,51 @@ fn render_item(
.when(checked, |row| {
row.child(
icon_glyph(IconRole::Check)
.size(env.chrome.dense_icon_size)
.size(chrome.dense_icon_size)
.color(label_color),
)
})
.into_any_element()
}
MenuItemKind::Submenu(items) => {
let chrome = env.chrome;
let flyout = env.flyout;
let disabled = item.disabled;
let open = *flyout.open.read(cx) == Some(index);
let label_color = row_color(item, selected, open, chrome);
let row_bounds: TriggerBoundsCell = Rc::new(RefCell::new(None));
row_shell(cx, item, index, selected, env)
.on_click({
let flyout_open = flyout.open.clone();
let flyout_preselect = flyout.preselect.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);
}
}
})
.child(
div()
.flex_1()
.text_color(label_color)
.child(item.label.clone()),
)
.child(
icon_glyph(IconRole::ChevronRight)
.size(chrome.dense_icon_size)
.color(label_color),
)
.aria_expanded(open)
.when(open, |row| {
row.child(
canvas(
{
let cell = row_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
)
.child(deferred(
div().absolute().left_full().top_0().child(menu_panel(
window,
cx,
flyout.state,
MenuPanelParams {
id: &flyout.id,
items: items.clone(),
initial_selection: None,
preselect: *flyout.preselect.read(cx),
min_width: flyout.min_width,
label: item.label.clone(),
dismiss_bounds: &row_bounds,
bounds_sink: Some(flyout.bounds),
outer_stack: Some(flyout.stack),
on_dismiss: Some(flyout.dismiss.clone()),
on_activate: flyout.on_activate.clone(),
is_flyout: true,
chrome,
enter_duration: flyout.enter_duration,
debug_prefix: "submenu",
},
)),
))
})
.into_any_element()
}
}
}
/// The shared row shell: metrics, highlight, and hover-selection.
fn row_shell(
cx: &App,
item: &MenuItem,
index: usize,
selected: bool,
env: &RowEnv,
) -> gpui::Stateful<gpui::Div> {
let chrome = env.chrome;
let selectable = item.selectable();
let opens_submenu = item.submenu_items().is_some();
let flyout_open = *env.flyout.open.read(cx) == Some(index);
div()
.id(("menu-item", index))
.debug_selector(move || format!("{}-item-{index}", env.debug_prefix))
.flex()
.items_center()
.gap_2()
.min_h(chrome.menu_item_height)
.px(chrome.spacing_unit * 2.0)
.text_size(chrome.text_size)
.line_height(relative(1.0))
.rounded(chrome.radius_sm)
.map(|row| {
if item.disabled {
row
} else if selected && flyout_open {
row.bg(chrome.muted_hover)
} else if selected {
let highlight = if item.destructive {
chrome.destructive
} else {
chrome.primary
};
row.bg(highlight)
} else {
row.hover(|row| row.bg(chrome.muted_hover))
}
})
.on_mouse_move({
let selection = env.selection.clone();
let flyout_open = env.flyout.open.clone();
let flyout_preselect = env.flyout.preselect.clone();
let dismiss = env.flyout.dismiss.clone();
move |_, window, cx| {
if !selectable {
return;
}
if *selection.read(cx) != Some(index) {
selection.update(cx, |selection, _| *selection = Some(index));
}
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);
}
} else if flyout_open.read(cx).is_some() {
dismiss(false, window, cx);
}
}
})
}
/// The row's label/icon color.
fn row_color(
item: &MenuItem,
selected: bool,
flyout_open: bool,
chrome: &MenuChrome,
) -> gpui::Hsla {
fn row_color(item: &MenuItem, selected: bool, chrome: &MenuChrome) -> gpui::Hsla {
if item.disabled {
return chrome.muted_foreground;
}
if item.destructive {
return if selected && !flyout_open {
return if selected {
chrome.destructive_foreground
} else {
chrome.destructive
};
}
if selected && !flyout_open {
if selected {
chrome.primary_foreground
} else {
chrome.foreground
}
}
/// Everything key navigation needs from the panel.
struct MenuKeyEnv<'a> {
items: &'a [MenuItem],
selection: &'a Entity<Option<usize>>,
flyout_open: &'a Entity<Option<usize>>,
flyout_preselect: &'a Entity<bool>,
on_dismiss: &'a Option<ChangeHandler>,
on_activate: &'a Option<ChangeHandler>,
left_dismisses: bool,
}
/// Handles the panel's navigation keys.
fn handle_menu_key(event: &KeyDownEvent, env: MenuKeyEnv, window: &mut Window, cx: &mut App) {
let selectable: Vec<usize> = env
.items
fn handle_menu_key(
event: &KeyDownEvent,
items: &[MenuItem],
selection: &Entity<Option<usize>>,
on_change: &Option<ChangeHandler>,
window: &mut Window,
cx: &mut App,
) {
let selectable: Vec<usize> = items
.iter()
.enumerate()
.filter(|(_, item)| item.selectable())
.map(|(index, _)| index)
.collect();
let current = *env.selection.read(cx);
let current = *selection.read(cx);
let position =
current.and_then(|current| selectable.iter().position(|index| *index == current));
@@ -1034,50 +667,24 @@ fn handle_menu_key(event: &KeyDownEvent, env: MenuKeyEnv, window: &mut Window, c
"home" => 0,
_ => selectable.len() - 1,
};
env.selection
.update(cx, |selection, _| *selection = Some(selectable[next]));
}
"right" => {
let opens = current
.and_then(|current| env.items.get(current))
.is_some_and(|item| item.selectable() && item.submenu_items().is_some());
if !opens {
return;
}
env.flyout_open.update(cx, |open, _| *open = current);
env.flyout_preselect
.update(cx, |preselect, _| *preselect = true);
selection.update(cx, |selection, _| *selection = Some(selectable[next]));
}
"enter" | "space" => {
if let Some(current) = current
&& let Some(item) = env.items.get(current)
&& let Some(item) = items.get(current)
&& 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);
} else {
if let Some(on_activate) = env.on_activate {
on_activate(false, window, cx);
}
if let Some(handler) = &item.handler {
handler(window, cx);
}
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
if let Some(handler) = &item.handler {
handler(window, cx);
}
}
}
"escape" => {
if let Some(on_dismiss) = env.on_dismiss {
on_dismiss(false, window, cx);
}
}
"left" => {
if !env.left_dismisses {
return;
}
if let Some(on_dismiss) = env.on_dismiss {
on_dismiss(false, window, cx);
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
}
_ => return,
+67 -64
View File
@@ -114,7 +114,7 @@ impl RenderOnce for Popover {
.clone()
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
div()
let mut wrapper = div()
.relative()
.child(
canvas(
@@ -161,77 +161,80 @@ impl RenderOnce for Popover {
)
.when_some(self.trigger, |wrapper, trigger| {
wrapper.child(trigger(window, cx))
})
.when(self.open, |wrapper| {
let on_change = self.on_change;
let label = fallback_label.clone();
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
let content = self.content.map(|content| content(window, cx));
});
let panel = v_flex()
.id("popover-panel")
.debug_selector(|| "popover-panel".to_string())
.occlude()
.track_focus(&focus)
.role(Role::Dialog)
.aria_label(label)
.min_w_32()
.bg(colors.surface)
.text_color(colors.surface_foreground)
.rounded(radius.overlay)
.border_1()
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.on_mouse_down_out({
let on_change = on_change.clone();
let trigger_bounds = trigger_bounds.clone();
move |event, window, cx| {
if trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position))
{
return;
}
if self.open {
let on_change = self.on_change;
let label = fallback_label.clone();
let focus = gpui_native_utils::focus_handle(&self.id, window, cx);
let content = self.content.map(|content| content(window, cx));
let panel = v_flex()
.id("popover-panel")
.debug_selector(|| "popover-panel".to_string())
.occlude()
.track_focus(&focus)
.role(Role::Dialog)
.aria_label(label)
.min_w_32()
.bg(colors.surface)
.text_color(colors.surface_foreground)
.rounded(radius.overlay)
.border_1()
.border_color(panel_border)
.shadow(shadows)
.overflow_hidden()
.on_mouse_down_out({
let on_change = on_change.clone();
let trigger_bounds = trigger_bounds.clone();
move |event, window, cx| {
if trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position))
{
return;
}
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
}
})
.on_key_down({
let on_change = on_change.clone();
move |event: &KeyDownEvent, window, cx| {
if event.keystroke.key == "escape" {
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
cx.stop_propagation();
}
})
.on_key_down({
let on_change = on_change.clone();
move |event: &KeyDownEvent, window, cx| {
if event.keystroke.key == "escape" {
if let Some(on_change) = &on_change {
on_change(false, window, cx);
}
cx.stop_propagation();
}
})
.when_some(content, |panel, content| panel.child(content))
.child(
canvas(|_, _, _| {}, {
let focus = focus;
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
}
})
.when_some(content, |panel, content| panel.child(content))
.child(
canvas(|_, _, _| {}, {
let focus = focus;
move |_, _, window, cx| {
if !focus.contains_focused(window, cx) {
window.focus(&focus, cx);
}
}
})
.absolute()
.inset_0(),
);
.absolute()
.inset_0(),
);
wrapper.child(deferred(
anchored()
.offset(point(px(0.), anchor_gap))
.child(panel.with_animation(
"popover-enter",
Animation::new(enter_duration).with_easing(ease_out_quint()),
|panel, delta| panel.opacity(delta),
)),
))
})
wrapper = wrapper.child(deferred(
anchored()
.offset(point(px(0.), anchor_gap))
.child(panel.with_animation(
"popover-enter",
Animation::new(enter_duration).with_easing(ease_out_quint()),
|panel, delta| panel.opacity(delta),
)),
));
}
wrapper
}
}
+28 -25
View File
@@ -95,7 +95,7 @@ impl RenderOnce for ScrollArea {
let mut pane_style = self.style.clone();
pane_style.padding = Default::default();
let pane = div()
let mut pane = div()
.id(self.id.clone())
.overflow_hidden()
.when(self.axes != ScrollAxes::Horizontal, |area| {
@@ -112,31 +112,34 @@ impl RenderOnce for ScrollArea {
// Keep wheel events from scrolling an outer scroll area too.
cx.stop_propagation();
})
.refine_style(&pane_style)
.children(self.children);
.refine_style(&pane_style);
div()
.relative()
.refine_style(&self.style)
.child(pane.into_any_element())
.when_some(self.handle, |wrapper, handle| {
let id = self.id.clone();
// Extend with children
pane.extend(self.children);
wrapper
.when(self.axes != ScrollAxes::Horizontal, |wrapper| {
wrapper.child(
Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
.visibility(self.visibility)
.into_any_element(),
)
})
.when(self.axes != ScrollAxes::Vertical, |wrapper| {
wrapper.child(
Scrollbar::horizontal((id, "h-scrollbar"), handle)
.visibility(self.visibility)
.into_any_element(),
)
})
})
let mut wrapper = div().relative().refine_style(&self.style);
wrapper.extend(std::iter::once(pane.into_any_element()));
if let Some(handle) = self.handle {
let id = self.id.clone();
if self.axes != ScrollAxes::Horizontal {
wrapper.extend(std::iter::once(
Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
.visibility(self.visibility)
.into_any_element(),
));
}
if self.axes != ScrollAxes::Vertical {
wrapper.extend(std::iter::once(
Scrollbar::horizontal((id, "h-scrollbar"), handle)
.visibility(self.visibility)
.into_any_element(),
));
}
}
wrapper
}
}
+5 -4
View File
@@ -274,10 +274,11 @@ where
}
};
MenuItem::new(option.label.clone())
.when_some(option.icon.clone(), |row, icon| row.icon(icon))
.checked(is_selected)
.handler(handler)
let mut row = MenuItem::new(option.label.clone());
if let Some(icon) = option.icon.clone() {
row = row.icon(icon);
}
row.checked(is_selected).handler(handler)
})
.collect()
};
-23
View File
@@ -158,26 +158,3 @@ fn clicking_the_panel_does_not_dismiss() {
click(&mut visual, point(px(960.), px(540.)));
assert!(changes.borrow().is_empty());
}
/// Clicking the close button reports close like escape and the scrim; the
/// panel then unmounts the button with the rest of the dialog.
#[test]
fn the_close_button_reports_close_and_disappears() {
let (_cx, mut visual, changes) = dialog_window(true, false);
let bounds = visual
.debug_bounds("dialog-close")
.expect("the close button rendered");
click(&mut visual, bounds.center());
assert_eq!(*changes.borrow(), vec![false]);
draw(&mut visual);
assert!(visual.debug_bounds("dialog-close").is_none());
}
/// An alert answers only through its actions, so it renders no close button.
#[test]
fn an_alert_hides_the_close_button() {
let (_cx, mut visual, _changes) = dialog_window(true, true);
assert!(visual.debug_bounds("dialog-close").is_none());
}
+32 -349
View File
@@ -1,15 +1,14 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, DefiniteLength, IntoElement, Modifiers, MouseButton, ParentElement as _, Render,
Styled as _, TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px,
Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _,
TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px,
};
use gpui_native_core::menu::{Menu, MenuItem};
use gpui_native_theme::Theme;
struct Harness {
open: bool,
min_width: Option<DefiniteLength>,
changes: Rc<RefCell<Vec<bool>>>,
activated: Rc<RefCell<Vec<&'static str>>>,
}
@@ -19,38 +18,36 @@ impl Render for Harness {
let changes = self.changes.clone();
let view = cx.entity();
let activated = self.activated.clone();
let mut menu = Menu::new("test-menu")
.open(self.open)
.on_change(move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open = next;
cx.notify();
});
})
.trigger(|_, _| {
div()
.w(px(100.))
.h(px(32.))
.bg(gpui::black())
.into_any_element()
})
.items(move |_, _| {
vec![
cut_item(activated.clone()),
MenuItem::separator(),
copy_item(activated.clone()),
MenuItem::new("Unavailable").disabled(true).handler({
let activated = activated.clone();
move |_, _| record(&activated, "unavailable")
}),
delete_item(activated.clone()),
]
});
if let Some(width) = self.min_width {
menu = menu.min_width(width);
}
div().size_full().child(menu)
div().size_full().child(
Menu::new("test-menu")
.open(self.open)
.on_change(move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open = next;
cx.notify();
});
})
.trigger(|_, _| {
div()
.w(px(100.))
.h(px(32.))
.bg(gpui::black())
.into_any_element()
})
.items(move |_, _| {
vec![
cut_item(activated.clone()),
MenuItem::separator(),
copy_item(activated.clone()),
MenuItem::new("Unavailable").disabled(true).handler({
let activated = activated.clone();
move |_, _| record(&activated, "unavailable")
}),
delete_item(activated.clone()),
]
}),
)
}
}
@@ -70,61 +67,6 @@ fn delete_item(activated: Rc<RefCell<Vec<&'static str>>>) -> MenuItem {
.handler(move |_, _| record(&activated, "delete"))
}
fn action_item(
label: &'static str,
key: &'static str,
activated: Rc<RefCell<Vec<&'static str>>>,
) -> MenuItem {
MenuItem::new(label).handler(move |_, _| record(&activated, key))
}
fn submenu_item(activated: Rc<RefCell<Vec<&'static str>>>) -> MenuItem {
MenuItem::new("Export").submenu(vec![
action_item("PNG", "png", activated.clone()),
action_item("PDF", "pdf", activated.clone()),
])
}
/// A menu with an Export row whose flyout holds PNG and PDF actions.
struct SubmenuHarness {
open: bool,
changes: Rc<RefCell<Vec<bool>>>,
activated: Rc<RefCell<Vec<&'static str>>>,
}
impl Render for SubmenuHarness {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let changes = self.changes.clone();
let view = cx.entity();
let activated = self.activated.clone();
div().size_full().child(
Menu::new("test-submenu")
.open(self.open)
.on_change(move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open = next;
cx.notify();
});
})
.trigger(|_, _| {
div()
.w(px(100.))
.h(px(32.))
.bg(gpui::black())
.into_any_element()
})
.items(move |_, _| {
vec![
action_item("Cut", "cut", activated.clone()),
submenu_item(activated.clone()),
action_item("Delete", "delete", activated.clone()),
]
}),
)
}
}
fn record(sink: &Rc<RefCell<Vec<&'static str>>>, what: &'static str) {
sink.borrow_mut().push(what);
}
@@ -140,19 +82,6 @@ fn menu_window(
WindowHandle<Harness>,
Changes,
Activated,
) {
menu_window_with(open, None)
}
fn menu_window_with(
open: bool,
min_width: Option<DefiniteLength>,
) -> (
TestAppContext,
VisualTestContext,
WindowHandle<Harness>,
Changes,
Activated,
) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
@@ -162,7 +91,6 @@ fn menu_window_with(
let harness_activated = activated.clone();
let window = cx.add_window(move |_, _| Harness {
open,
min_width,
changes: harness_changes,
activated: harness_activated,
});
@@ -173,25 +101,6 @@ fn menu_window_with(
(cx, visual, window, changes, activated)
}
fn submenu_window(open: bool) -> (TestAppContext, VisualTestContext, Changes, Activated) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let changes = Rc::new(RefCell::new(Vec::new()));
let activated = Rc::new(RefCell::new(Vec::new()));
let harness_changes = changes.clone();
let harness_activated = activated.clone();
let window = cx.add_window(move |_, _| SubmenuHarness {
open,
changes: harness_changes,
activated: harness_activated,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, visual, changes, activated)
}
fn draw(visual: &mut VisualTestContext) {
visual.update(|window, cx| {
window.draw(cx).clear(cx);
@@ -203,17 +112,6 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
}
fn hover(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_move(position, None, Modifiers::default());
}
fn center(bounds: gpui::Bounds<gpui::Pixels>) -> gpui::Point<gpui::Pixels> {
point(
bounds.origin.x + bounds.size.width / 2.,
bounds.origin.y + bounds.size.height / 2.,
)
}
/// Every action row renders at the platform's stated menu row height,
/// whatever its content — the metric is the whole row, not a floor under
/// padding. Item 1 of the harness menu is a separator, so it is skipped.
@@ -306,218 +204,3 @@ fn clicking_an_item_activates_and_closes() {
assert_eq!(*activated.borrow(), vec!["cut"]);
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn right_arrow_opens_the_submenu_and_enter_within_activates_through() {
let (_cx, mut visual, changes, activated) = submenu_window(true);
// down arms Cut, the next down reaches Export, right opens its flyout
// with the first row armed.
visual.simulate_keystrokes("down down right");
draw(&mut visual);
assert!(visual.debug_bounds("submenu-item-0").is_some());
assert!(changes.borrow().is_empty(), "the menu stays open");
assert!(activated.borrow().is_empty());
// Enter inside the flyout fires PNG and closes the whole menu.
visual.simulate_keystrokes("enter");
draw(&mut visual);
assert_eq!(*activated.borrow(), vec!["png"]);
assert_eq!(*changes.borrow(), vec![false]);
assert!(visual.debug_bounds("submenu-item-0").is_none());
}
#[test]
fn enter_on_a_submenu_row_opens_it_and_navigates_within() {
let (_cx, mut visual, changes, activated) = submenu_window(true);
visual.simulate_keystrokes("down down enter");
draw(&mut visual);
assert!(visual.debug_bounds("submenu-item-0").is_some());
assert!(
changes.borrow().is_empty(),
"enter opens, it does not close"
);
// The flyout armed PNG; down reaches PDF and enter fires it.
visual.simulate_keystrokes("down enter");
draw(&mut visual);
assert_eq!(*activated.borrow(), vec!["pdf"]);
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn left_closes_the_submenu_and_returns_to_the_menu() {
let (_cx, mut visual, changes, activated) = submenu_window(true);
visual.simulate_keystrokes("down down right");
draw(&mut visual);
visual.simulate_keystrokes("left");
draw(&mut visual);
assert!(visual.debug_bounds("submenu-item-0").is_none());
assert!(changes.borrow().is_empty(), "the menu stays open");
assert!(activated.borrow().is_empty());
// Navigation resumes in the parent: down reaches Delete, enter fires it.
visual.simulate_keystrokes("down enter");
draw(&mut visual);
assert_eq!(*activated.borrow(), vec!["delete"]);
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn escape_closes_the_submenu_then_the_menu() {
let (_cx, mut visual, changes, activated) = submenu_window(true);
visual.simulate_keystrokes("down down right");
draw(&mut visual);
visual.simulate_keystrokes("escape");
draw(&mut visual);
assert!(visual.debug_bounds("submenu-item-0").is_none());
assert!(
changes.borrow().is_empty(),
"the first escape only closes the flyout"
);
visual.simulate_keystrokes("escape");
draw(&mut visual);
assert_eq!(*changes.borrow(), vec![false]);
assert!(activated.borrow().is_empty());
}
#[test]
fn clicking_a_submenu_row_opens_the_flyout_and_a_flyout_action_closes_the_menu() {
let (_cx, mut visual, changes, activated) = submenu_window(true);
let row = visual
.debug_bounds("menu-item-1")
.unwrap_or_else(|| panic!("the Export row should have been laid out"));
click(
&mut visual,
point(
row.origin.x + row.size.width / 2.,
row.origin.y + row.size.height / 2.,
),
);
draw(&mut visual);
assert!(visual.debug_bounds("submenu-item-0").is_some());
assert!(changes.borrow().is_empty());
let png = visual
.debug_bounds("submenu-item-0")
.unwrap_or_else(|| panic!("the PNG row should have been laid out"));
click(
&mut visual,
point(
png.origin.x + png.size.width / 2.,
png.origin.y + png.size.height / 2.,
),
);
draw(&mut visual);
assert_eq!(*activated.borrow(), vec!["png"]);
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn a_click_inside_the_flyout_padding_does_not_dismiss_the_menu() {
let (_cx, mut visual, changes, _activated) = submenu_window(true);
let row = visual.debug_bounds("menu-item-1").unwrap();
click(
&mut visual,
point(
row.origin.x + row.size.width / 2.,
row.origin.y + row.size.height / 2.,
),
);
draw(&mut visual);
// The flyout's own padding belongs to the flyout, not to the dismissal
// listener of the panel behind it.
let panel = visual.debug_bounds("submenu-panel").unwrap();
click(
&mut visual,
point(
panel.origin.x + px(4.),
panel.size.height + panel.origin.y - px(2.),
),
);
draw(&mut visual);
assert!(changes.borrow().is_empty(), "the flyout stays open");
assert!(visual.debug_bounds("submenu-item-0").is_some());
// A click outside everything still closes the whole menu.
click(&mut visual, point(px(600.), px(600.)));
assert_eq!(*changes.borrow(), vec![false]);
}
#[test]
fn hovering_a_submenu_row_opens_its_flyout_and_an_action_row_closes_it() {
let (_cx, mut visual, changes, _activated) = submenu_window(true);
let export = visual.debug_bounds("menu-item-1").unwrap();
hover(&mut visual, center(export));
draw(&mut visual);
assert!(
visual.debug_bounds("submenu-item-0").is_some(),
"hover opens the flyout"
);
assert!(changes.borrow().is_empty(), "the menu stays open");
let cut = visual.debug_bounds("menu-item-0").unwrap();
hover(&mut visual, center(cut));
draw(&mut visual);
assert!(
visual.debug_bounds("submenu-item-0").is_none(),
"hovering an action row closes the flyout"
);
assert!(changes.borrow().is_empty(), "the menu stays open");
}
#[test]
fn the_flyout_overlaps_the_parent_from_the_rows_right_edge() {
let (_cx, mut visual, _changes, _activated) = submenu_window(true);
let export = visual.debug_bounds("menu-item-1").unwrap();
hover(&mut visual, center(export));
draw(&mut visual);
let parent = visual.debug_bounds("menu-panel").unwrap();
let flyout = visual.debug_bounds("submenu-panel").unwrap();
let parent_right = parent.origin.x + parent.size.width;
let row_right = export.origin.x + export.size.width;
// macOS style: the flyout covers the parent's trailing padding, starting
// at the row's right edge.
assert!(
flyout.origin.x < parent_right - px(1.),
"the flyout starts at {:?}, inside the parent edge at {:?}",
flyout.origin.x,
parent_right
);
assert!(
flyout.origin.x >= row_right - px(1.),
"the flyout starts at {:?}, at or after the row's right edge {:?}",
flyout.origin.x,
row_right
);
}
#[test]
fn a_custom_min_width_overrides_the_default_panel_width() {
let (_cx, mut visual, _window, _changes, _activated) = menu_window(true);
let default_width = visual.debug_bounds("menu-panel").unwrap().size.width;
let (_cx, mut visual, _window, _changes, _activated) =
menu_window_with(true, Some(px(320.).into()));
let width = visual.debug_bounds("menu-panel").unwrap().size.width;
// Painted bounds are snapped to device pixels; allow half a pixel.
assert!(
(width - px(320.)).abs() <= px(0.5),
"the panel is {width:?} wide, expected the 320px minimum"
);
assert!(
width > default_width,
"the default panel is {:?} wide and should be narrower",
default_width
);
}
+58 -64
View File
@@ -10,6 +10,7 @@ use gpui::{
Styled as _, Window, div,
};
use gpui_native_core::scroll_area::ScrollArea;
use gpui_native_icons::Icon;
use gpui_native_theme::ActiveTheme as _;
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
@@ -18,15 +19,15 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>;
/// One entry of a [`Sidebar`], a section header or a selectable row.
#[derive(Clone)]
pub enum SidebarEntry {
/// A non-selectable section label.
Header { label: SharedString },
/// A selectable row.
Row {
label: SharedString,
icon: Option<Icon>,
detail: Option<SharedString>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
disabled: bool,
},
}
@@ -49,13 +50,23 @@ impl SidebarEntry {
{
Self::Row {
label: label.into(),
icon: None,
detail: None,
prefix: None,
suffix: None,
disabled: false,
}
}
/// Shows an icon leading the label, a role or any custom [`IconNamed`].
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<Icon>,
{
if let Self::Row { icon: slot, .. } = &mut self {
*slot = Some(icon.into());
}
self
}
/// Shows muted trailing text.
pub fn detail<D>(mut self, detail: D) -> Self
where
@@ -70,28 +81,6 @@ impl SidebarEntry {
self
}
/// Shows an element leading the row.
pub fn prefix<E>(mut self, prefix: E) -> Self
where
E: IntoElement,
{
if let Self::Row { prefix: slot, .. } = &mut self {
*slot = Some(prefix.into_any_element());
}
self
}
/// Shows an element trailing the row.
pub fn suffix<E>(mut self, suffix: E) -> Self
where
E: IntoElement,
{
if let Self::Row { suffix: slot, .. } = &mut self {
*slot = Some(suffix.into_any_element());
}
self
}
/// Renders the row dimmed; clicks and keyboard skip it.
pub fn disabled(mut self, disabled: bool) -> Self {
if let Self::Row {
@@ -218,7 +207,7 @@ impl RenderOnce for Sidebar {
let mut row_position = 0usize;
let children: Vec<AnyElement> = entries
.into_iter()
.iter()
.enumerate()
.map(|(index, entry)| {
let is_row = matches!(entry, SidebarEntry::Row { .. });
@@ -285,7 +274,7 @@ impl RenderOnce for Sidebar {
#[allow(clippy::too_many_arguments)]
fn render_entry(
cx: &App,
entry: SidebarEntry,
entry: &SidebarEntry,
index: usize,
position_in_set: usize,
size_of_set: usize,
@@ -304,18 +293,17 @@ fn render_entry(
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.colors.muted_foreground)
.child(label)
.child(label.clone())
.into_any_element(),
SidebarEntry::Row {
label,
icon: row_icon,
detail,
prefix,
suffix,
disabled: row_disabled,
} => {
let label_color = selectable_label_color(cx, selected, row_disabled);
let label_color = selectable_label_color(cx, selected, *row_disabled);
div()
let mut row = div()
.id(("sidebar-row", index))
.flex()
.items_center()
@@ -328,45 +316,51 @@ fn render_entry(
.aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set)
.map(|row| {
if material && !row_disabled {
if material && !*row_disabled {
if selected {
row.bg(theme.material.selection)
} else {
row.hover(|row| row.bg(theme.material.hover))
}
} else {
paint_selectable_row(cx, row, selected, row_disabled)
paint_selectable_row(cx, row, selected, *row_disabled)
}
})
.when(!row_disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
}
});
if !*row_disabled {
row = row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
}
})
})
.when_some(prefix, |row, prefix| row.child(prefix))
.child(div().flex_1().text_color(label_color).child(label))
.when_some(detail, |row, detail| {
row.child(
div()
.flex_none()
.text_sm()
.text_color(if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
})
.child(detail),
)
})
.when_some(suffix, |row, suffix| row.child(suffix))
.into_any_element()
}
});
}
row = row.when_some(row_icon.clone(), |row, icon| {
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
});
row = row.child(div().flex_1().text_color(label_color).child(label.clone()));
if let Some(detail) = detail {
row = row.child(
div()
.flex_none()
.text_sm()
.text_color(if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
})
.child(detail.clone()),
);
}
row.into_any_element()
}
}
}
+100 -68
View File
@@ -1,6 +1,6 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
ParentElement as _, RenderOnce, Role, SharedString, Stateful, StatefulInteractiveElement as _,
@@ -10,7 +10,7 @@ use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::Sizable as _;
use gpui_native_core::spinner::Spinner;
use gpui_native_core::tooltip::Tooltip;
use gpui_native_icons::IconRole;
use gpui_native_icons::{Icon, IconRole};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_utils::adjacent_selectable;
@@ -35,6 +35,7 @@ pub struct Tab {
/// The stable identity for element state; must survive reordering.
key: ElementId,
label: SharedString,
icon: Option<Icon>,
lead: Option<AnyElement>,
busy: bool,
disabled: bool,
@@ -52,6 +53,7 @@ impl Tab {
Self {
key: key.into(),
label: label.into(),
icon: None,
lead: None,
busy: false,
disabled: false,
@@ -60,7 +62,16 @@ impl Tab {
}
}
/// Sets the leading element, e.g. an icon or an avatar.
/// Shows an icon leading the label.
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<Icon>,
{
self.icon = Some(icon.into());
self
}
/// Sets the leading element.
pub fn lead<E>(mut self, lead: E) -> Self
where
E: IntoElement + 'static,
@@ -97,9 +108,6 @@ impl Tab {
}
}
// Tab is a data builder, not an element, adopt the combinators explicitly.
impl FluentBuilder for Tab {}
/// A tab strip.
#[derive(IntoElement)]
pub struct Tabs {
@@ -218,7 +226,7 @@ impl RenderOnce for Tabs {
})
.collect();
div()
let mut strip_root = div()
.id(self.id.clone())
.flex()
.text_size(theme.sizes.medium.text_size)
@@ -246,14 +254,17 @@ impl RenderOnce for Tabs {
}
cx.stop_propagation();
}
})
.map(|root| match self.variant {
TabsVariant::Underline => {
root.gap_1().border_b_1().border_color(theme.colors.border)
}
TabsVariant::Pill => root.items_center().gap_1(),
})
.children(strip)
});
strip_root = match self.variant {
TabsVariant::Underline => strip_root
.gap_1()
.border_b_1()
.border_color(theme.colors.border),
TabsVariant::Pill => strip_root.items_center().gap_1(),
};
strip_root.children(strip)
}
}
@@ -295,7 +306,7 @@ fn render_tab(
}
};
let strip_tab = div()
let mut strip_tab = div()
.id((tab.key.clone(), "tab"))
.debug_selector({
let tabs_id = tabs_id.clone();
@@ -310,60 +321,81 @@ fn render_tab(
.text_color(label_color)
.when(!tab.disabled && !selected, |strip_tab| {
strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted))
})
.map(|strip_tab| match variant {
TabsVariant::Underline => strip_tab
.px_2()
.py_1()
.rounded_tl(theme.radius.sm)
.rounded_tr(theme.radius.sm)
.border_b_2()
.border_color(gpui::transparent_black())
.when(selected, |strip_tab| {
strip_tab.border_color(theme.ramps.primary.base)
}),
TabsVariant::Pill => strip_tab
.h(small.control_height + metrics.plate_inset * 2.0)
.px(metrics.dense_padding_x)
.pr(metrics.plate_inset)
.rounded(theme.radius.control)
.when(selected, |strip_tab| {
strip_tab
.bg(theme.colors.muted)
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
}),
})
.when_some(tab.lead.take(), |strip_tab, lead| strip_tab.child(lead))
.child(tab.label.clone())
.when(variant == TabsVariant::Pill, |strip_tab| {
strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5))
})
.when(tab.busy && !tab.disabled, |strip_tab| {
strip_tab.child(
Spinner::new((tab.key.clone(), "tab-busy"))
.small()
.accessibility_label("Loading"),
)
})
.when(
tab.closeable && on_close.is_some() && !tab.disabled,
|strip_tab| strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close)),
)
.when(!tab.disabled, |strip_tab| {
strip_tab.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if index != selected_index
&& let Some(on_change) = &on_change
{
on_change(index, window, cx);
}
}
})
});
strip_tab = match variant {
TabsVariant::Underline => strip_tab
.px_2()
.py_1()
.rounded_tl(theme.radius.sm)
.rounded_tr(theme.radius.sm)
.border_b_2()
.border_color(gpui::transparent_black())
.when(selected, |strip_tab| {
strip_tab.border_color(theme.ramps.primary.base)
}),
TabsVariant::Pill => strip_tab
.h(small.control_height + metrics.plate_inset * 2.0)
.px(metrics.dense_padding_x)
.pr(metrics.plate_inset)
.rounded(theme.radius.control)
.when(selected, |strip_tab| {
strip_tab
.bg(theme.colors.muted)
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
}),
};
let lead = tab.lead.take();
let icon = tab.icon.take();
strip_tab = match lead {
Some(lead) => strip_tab.child(lead),
None => match variant {
TabsVariant::Underline => {
strip_tab.child(div().flex_none().pb_0p5().when_some(icon, |slot, icon| {
slot.child(icon.size(metrics.dense_icon_size).color(label_color))
}))
}
TabsVariant::Pill => strip_tab.when_some(icon, |strip_tab, icon| {
strip_tab.child(icon.size(metrics.dense_icon_size).color(label_color))
}),
},
};
strip_tab = strip_tab.child(tab.label.clone());
if variant == TabsVariant::Pill {
strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5));
}
if tab.busy && !tab.disabled {
strip_tab = strip_tab.child(
Spinner::new((tab.key.clone(), "tab-busy"))
.small()
.accessibility_label("Loading"),
);
}
if tab.closeable && on_close.is_some() && !tab.disabled {
strip_tab = strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close));
}
if !tab.disabled {
strip_tab = strip_tab.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if index != selected_index
&& let Some(on_change) = &on_change
{
on_change(index, window, cx);
}
}
});
}
match tab.wrap {
Some(wrap) => wrap(strip_tab),
None => strip_tab.into_any_element(),
+17 -18
View File
@@ -546,25 +546,24 @@ fn render_row(
},
)
.when(options.line_numbers, |row_element| {
row_element.child(
gutter_cell(
cx,
colors.muted_foreground,
row.new_number
.or(row.old_number)
.map(|number| number.to_string())
.unwrap_or_default(),
)
.w_10()
.whitespace_nowrap()
.bg(match row.kind {
DiffRowKind::Context => wash_gutter(&colors),
_ => wash_strong(change_color),
})
.when(options.indicators == DiffIndicators::Bars, |number| {
number.border_l_4().border_color(change_color)
}),
let mut number = gutter_cell(
cx,
colors.muted_foreground,
row.new_number
.or(row.old_number)
.map(|number| number.to_string())
.unwrap_or_default(),
)
.w_10()
.whitespace_nowrap()
.bg(match row.kind {
DiffRowKind::Context => wash_gutter(&colors),
_ => wash_strong(change_color),
});
if options.indicators == DiffIndicators::Bars {
number = number.border_l_4().border_color(change_color);
}
row_element.child(number)
})
.when(
options.indicators == DiffIndicators::Bars && !options.line_numbers,
+79 -75
View File
@@ -817,43 +817,43 @@ fn text_row(
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let mut frame = block_frame(indent, quote, px(0.), bottom, id, cx);
block_frame(indent, quote, px(0.), bottom, id, cx)
.map(|frame| match marker {
Some(marker) => {
let line_height = theme.sizes.small.text_size * BODY_LINE_HEIGHT;
let marker_cell = div()
.flex_none()
.w(theme.root_font_size * MARKER_WIDTH)
.h(line_height)
.flex()
.items_center()
.justify_end()
.pr_1()
.child(match marker {
ListMarker::Bullet => div()
.text_color(theme.colors.muted_foreground)
.child("•")
.into_any_element(),
ListMarker::Ordered(number) => div()
.text_color(theme.colors.muted_foreground)
.whitespace_nowrap()
.child(format!("{number}."))
.into_any_element(),
ListMarker::Task(checked) => task_box(checked, cx),
});
if let Some(marker) = marker {
let line_height = theme.sizes.small.text_size * BODY_LINE_HEIGHT;
let marker_cell = div()
.flex_none()
.w(theme.root_font_size * MARKER_WIDTH)
.h(line_height)
.flex()
.items_center()
.justify_end()
.pr_1()
.child(match marker {
ListMarker::Bullet => div()
.text_color(theme.colors.muted_foreground)
.child("•")
.into_any_element(),
ListMarker::Ordered(number) => div()
.text_color(theme.colors.muted_foreground)
.whitespace_nowrap()
.child(format!("{number}."))
.into_any_element(),
ListMarker::Task(checked) => task_box(checked, cx),
});
frame.child(
div()
.flex()
.items_start()
.child(marker_cell)
.child(div().flex_1().min_w_0().child(text_child(spans, id, cx))),
)
}
None => frame.child(text_child(spans, id, cx)),
})
.into_any_element()
frame = frame.child(
div()
.flex()
.items_start()
.child(marker_cell)
.child(div().flex_1().min_w_0().child(text_child(spans, id, cx))),
);
} else {
frame = frame.child(text_child(spans, id, cx));
}
frame.into_any_element()
}
/// A read-only task item indicator, matching the checkbox component.
@@ -896,7 +896,7 @@ fn code_row(
let text_size = theme.sizes.small.text_size;
let line_height = text_size * CODE_LINE_HEIGHT;
let plate = div()
let mut plate = div()
.w_full()
.flex()
.flex_col()
@@ -906,14 +906,17 @@ fn code_row(
.font_family(theme.mono_font)
.text_size(text_size)
.line_height(relative(CODE_LINE_HEIGHT))
.overflow_hidden()
.children(lines.lines.iter().map(|line| {
.overflow_hidden();
for line in &lines.lines {
plate = plate.child(
div()
.h(line_height)
.whitespace_nowrap()
.overflow_hidden()
.child(code_line(line, theme.colors, theme.ramps))
}));
.child(code_line(line, theme.colors, theme.ramps)),
);
}
block_frame(
indent,
@@ -958,47 +961,48 @@ fn table_row(
.unwrap_or(0);
let row_count = table.rows.len();
let grid = div()
let mut grid = div()
.w_full()
.flex()
.flex_col()
.border_1()
.border_color(colors.border)
.rounded(theme.radius.control)
.overflow_hidden()
.children(table.rows.iter().enumerate().map(|(row_ix, row)| {
div()
.w_full()
.flex()
.when(row_ix + 1 < row_count, |row_element| {
row_element.border_b_1().border_color(colors.border)
})
.when(row.header, |row_element| row_element.bg(colors.muted))
.children(
row.cells
.iter()
.enumerate()
.map(|(column_ix, cell)| {
let cell_id = ElementId::from(SharedString::from(format!(
"{id}-cell-{row_ix}-{column_ix}"
)));
div()
.flex_1()
.min_w_0()
.px_2()
.py_1()
.when(row.header, |cell_element| {
cell_element.font_weight(FontWeight::SEMIBOLD)
})
.child(text_child(&cell.spans, &cell_id, cx))
})
// Ragged tables pad missing cells so rows align.
.chain(
(row.cells.len()..width)
.map(|_| div().flex_1().min_w_0().px_2().py_1()),
),
)
}));
.overflow_hidden();
for (row_ix, row) in table.rows.iter().enumerate() {
let mut row_element = div().w_full().flex();
if row_ix + 1 < row_count {
row_element = row_element.border_b_1().border_color(colors.border);
}
if row.header {
row_element = row_element.bg(colors.muted);
}
for (column_ix, cell) in row.cells.iter().enumerate() {
let cell_id = ElementId::from(SharedString::from(format!(
"{id}-cell-{row_ix}-{column_ix}"
)));
row_element = row_element.child(
div()
.flex_1()
.min_w_0()
.px_2()
.py_1()
.when(row.header, |cell_element| {
cell_element.font_weight(FontWeight::SEMIBOLD)
})
.child(text_child(&cell.spans, &cell_id, cx)),
);
}
// Ragged tables pad missing cells so rows align.
for _ in row.cells.len()..width {
row_element = row_element.child(div().flex_1().min_w_0().px_2().py_1());
}
grid = grid.child(row_element);
}
block_frame(
indent,
+25 -29
View File
@@ -1038,37 +1038,33 @@ fn render_pane(
.selected(selected)
.tabs({
let tabs = pane.tabs.clone();
move |_, cx| {
let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default());
move |_, _| {
tabs.iter()
.map(|tab| {
Tab::new(tab.panel.clone(), tab.title.clone())
.when_some(tab.icon.clone(), |entry, icon| {
entry.lead(icon.size(metrics.icon_size))
})
.closeable(true)
.wrap({
let panel = tab.panel.clone();
let title = tab.title.clone();
move |tab| {
tab.on_drag(
PanelDrag {
panel: panel.clone(),
},
{
let title = title.clone();
move |_, _, _, cx| {
cx.new(|_| DragPreview {
title: title.clone(),
})
}
},
)
.into_any_element()
}
})
let mut entry = Tab::new(tab.panel.clone(), tab.title.clone());
if let Some(icon) = tab.icon.clone() {
entry = entry.icon(icon);
}
entry.closeable(true).wrap({
let panel = tab.panel.clone();
let title = tab.title.clone();
move |tab| {
tab.on_drag(
PanelDrag {
panel: panel.clone(),
},
{
let title = title.clone();
move |_, _, _, cx| {
cx.new(|_| DragPreview {
title: title.clone(),
})
}
},
)
.into_any_element()
}
})
})
.collect()
}
+53 -48
View File
@@ -220,36 +220,39 @@ impl RenderOnce for Resizable {
.map(|ix| state.read(cx).pane_size(ix, pane_count))
.collect();
div()
let mut group = div()
.flex()
.overflow_hidden()
.when(axis == Axis::Vertical, |group| group.flex_col())
.when(axis == Axis::Horizontal, |group| group.flex_row())
.refine_style(&self.style)
.children(self.panes.into_iter().enumerate().flat_map(|(ix, pane)| {
let remainder = ix + 1 == pane_count;
let pane_element = div()
.overflow_hidden()
.when(remainder, |pane_element| {
pane_element
.flex_1()
.when(axis == Axis::Vertical, |p| p.min_h(pane.min))
.when(axis == Axis::Horizontal, |p| p.min_w(pane.min))
})
.when(!remainder, |pane_element| {
pane_element
.flex_none()
.when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full())
.when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full())
})
.when_some(debug_selector.clone(), |pane_element, selector| {
let prefix = selector();
pane_element.debug_selector(move || format!("{prefix}-{ix}"))
})
.child(pane.content)
.into_any_element();
.refine_style(&self.style);
let divider = (!remainder).then(|| {
for (ix, pane) in self.panes.into_iter().enumerate() {
let remainder = ix + 1 == pane_count;
let content = pane.content;
let mut pane_element = if remainder {
div()
.flex_1()
.overflow_hidden()
.when(axis == Axis::Vertical, |p| p.min_h(pane.min))
.when(axis == Axis::Horizontal, |p| p.min_w(pane.min))
} else {
div()
.flex_none()
.overflow_hidden()
.when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full())
.when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full())
};
if let Some(selector) = &debug_selector {
let prefix = selector();
pane_element = pane_element.debug_selector(move || format!("{prefix}-{ix}"));
}
group = group.child(pane_element.child(content));
if !remainder {
let divider =
ResizeHandle::new(SharedString::from(format!("{}-divider-{ix}", self.id)))
.axis(axis)
.on_resize({
@@ -265,30 +268,32 @@ impl RenderOnce for Resizable {
move |_, cx| {
state.update(cx, |state, cx| state.reset_pane(ix, mins.len(), cx));
}
})
.into_any_element()
});
});
std::iter::once(pane_element).chain(divider)
}))
.when(pane_count > 0, |group| {
group.child(
canvas(
{
let state = state.clone();
move |bounds: Bounds<Pixels>, _, cx: &mut App| {
let extent = match axis {
Axis::Vertical => bounds.size.height,
Axis::Horizontal => bounds.size.width,
};
state.update(cx, |state, cx| state.set_measured(extent, cx));
}
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
group = group.child(divider);
}
}
if pane_count > 0 {
group = group.child(
canvas(
{
let state = state.clone();
move |bounds: Bounds<Pixels>, _, cx: &mut App| {
let extent = match axis {
Axis::Vertical => bounds.size.height,
Axis::Horizontal => bounds.size.width,
};
state.update(cx, |state, cx| state.set_measured(extent, cx));
}
},
|_, _, _, _| {},
)
})
.absolute()
.inset_0(),
);
}
group
}
}
+3 -3
View File
@@ -424,7 +424,7 @@ impl SizeTable {
padding: 0.75,
text: 0.85,
icon: 0.875,
avatar: 1.125,
avatar: 1.5,
strip: 1.5,
inset: 0.125,
floor_icon: false,
@@ -435,7 +435,7 @@ impl SizeTable {
padding: 1.0,
text: 1.0,
icon: 1.0,
avatar: 1.5,
avatar: 2.0,
strip: 2.0,
inset: 0.25,
floor_icon: true,
@@ -446,7 +446,7 @@ impl SizeTable {
padding: 1.25,
text: 1.15,
icon: 1.25,
avatar: 2.0,
avatar: 3.0,
strip: 3.0,
inset: 0.375,
floor_icon: true,
+1 -3
View File
@@ -390,9 +390,7 @@ mod tests {
assert_eq!(resolved.sizes.medium.control_height, px(28.));
assert_eq!(resolved.sizes.medium.input_height, px(30.));
assert_eq!(resolved.sizes.medium.text_size, px(16.));
assert_eq!(resolved.sizes.small.avatar_size, px(18.));
assert_eq!(resolved.sizes.medium.avatar_size, px(24.));
assert_eq!(resolved.sizes.large.avatar_size, px(32.));
assert_eq!(resolved.sizes.large.avatar_size, px(48.));
assert_eq!(resolved.sizes.small.caption_size, px(13.6 * 0.85));
assert_eq!(resolved.sizes.medium.caption_size, px(16.));
assert_eq!(resolved.sizes.large.caption_size, px(16.));
-11
View File
@@ -9,17 +9,6 @@ is the first downstream consumer; its screens drive the order.
## Next
- [x] **Declarative render audit** — the AGENTS.md render rule swept the
landed components: `let mut` element variables reassigned under `if` /
`if let` now build one chained expression with `.when(...)` /
`.when_some(...)` / `.map(...)`. Converted: Tabs strip root and tab,
Menu / DropdownMenu / Popover / Combobox wrappers, ScrollArea pane and
scrollbars, Select rows, Dock tab entries, Resizable panes and dividers,
the Markdown text / code / table rows, and the Diff gutter cell.
`MenuItem` and `Tab` adopted `FluentBuilder` so their builders chain
too; data accumulators (`Vec`/`String` builders, style clones) stay
imperative — they are state, not element refinement.
## Queue — to follow
Platform metric adoption continues one component at a time; each stop goes
+14 -71
View File
@@ -509,7 +509,6 @@ impl Gallery {
SharedString::from("Blame"),
],
pill_selected: 0,
lead_selected: 0,
})
.into()
}));
@@ -1869,27 +1868,13 @@ impl Render for BadgeDemo {
h_flex()
.flex_wrap()
.gap_3()
.child(Badge::new("badge-count", "3"))
.child(Badge::new("badge-new", "new").variant(BadgeVariant::Accent))
.child(
Badge::new("badge-synced", "synced")
.icon(IconRole::Check)
.variant(BadgeVariant::Success),
)
.child(Badge::new("badge-drift", "drift").variant(BadgeVariant::Warning))
.child(Badge::new("badge-danger", "12").variant(BadgeVariant::Danger))
.child(Badge::new("badge-outline", "outline").variant(BadgeVariant::Outline))
.child(Badge::new("badge-small", "small").small())
.child(Badge::new("badge-large", "large").large()),
))
.child(demo_section(
"tooltip",
colors,
h_flex().flex_wrap().gap_3().child(
Badge::new("badge-help", "beta")
.variant(BadgeVariant::Accent)
.tooltip(Tooltip::text("This build is not ready for release")),
),
.child(Badge::new("3"))
.child(Badge::new("new").variant(BadgeVariant::Accent))
.child(Badge::new("synced").variant(BadgeVariant::Success))
.child(Badge::new("drift").variant(BadgeVariant::Warning))
.child(Badge::new("12").variant(BadgeVariant::Danger))
.child(Badge::new("small").small())
.child(Badge::new("large").large()),
))
}
}
@@ -2208,7 +2193,6 @@ struct TabsDemo {
selected: usize,
pill_tabs: Vec<SharedString>,
pill_selected: usize,
lead_selected: usize,
}
impl Render for TabsDemo {
@@ -2216,7 +2200,6 @@ impl Render for TabsDemo {
let view = cx.entity();
let view_pill = view.clone();
let view_close = view.clone();
let view_leads = view.clone();
let colors = cx.theme().colors;
let pill_count = self.pill_tabs.len();
@@ -2298,31 +2281,7 @@ impl Render for TabsDemo {
.child(format!(
"{pill_count} tabs left; \"Syncing\" spins while it loads"
)),
)
.child(demo_section(
"leads",
colors,
Tabs::new("tabs-demo-leads")
.variant(TabsVariant::Pill)
.selected(self.lead_selected)
.on_change(move |selected, _, cx| {
view_leads.update(cx, |demo, cx| {
demo.lead_selected = selected;
cx.notify();
});
})
.tabs(|_, cx| {
let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default());
vec![
Tab::new("files", "Files")
.lead(icon(IconRole::Folder).size(metrics.dense_icon_size)),
Tab::new("people", "People")
.lead(Avatar::new("ada").name("Ada Lovelace").small()),
Tab::new("plain", "Plain"),
]
}),
)),
),
)
}
}
@@ -2755,11 +2714,7 @@ impl Render for DropdownMenuDemo {
MenuItem::new("Save as template"),
MenuItem::new("Save a copy"),
MenuItem::separator(),
MenuItem::new("Export").submenu(vec![
MenuItem::new("PNG"),
MenuItem::new("SVG"),
MenuItem::new("PDF"),
]),
MenuItem::new("Export"),
]
});
@@ -3757,28 +3712,16 @@ impl Render for SidebarDemo {
cx.notify();
});
})
.entries(|_, cx| {
let theme = cx.theme();
let row_icon = || {
icon(IconRole::Folder).size(theme.sizes.medium.icon_size)
};
.entries(|_, _| {
vec![
SidebarEntry::header("Favorites"),
SidebarEntry::row("Documents").prefix(row_icon()),
SidebarEntry::row("Documents").icon(IconRole::Folder),
SidebarEntry::row("Pictures")
.prefix(row_icon())
.detail("24")
.suffix(
Badge::new("sidebar-demo-new-badge", "new")
.variant(BadgeVariant::Accent),
),
.icon(IconRole::Folder)
.detail("24"),
SidebarEntry::row("Archive").disabled(true),
SidebarEntry::header("Locations"),
SidebarEntry::row("iCloud").prefix(row_icon()).suffix(
StatusDot::new("sidebar-demo-icloud-status")
.small()
.variant(StatusDotVariant::Success),
),
SidebarEntry::row("iCloud").icon(IconRole::Folder),
SidebarEntry::row("Trash"),
]
}),
+3
View File
@@ -13,6 +13,9 @@ pub use gpui_native_data as data;
#[cfg(feature = "layout")]
pub use gpui_native_layout as layout;
#[cfg(feature = "editor")]
pub use gpui_native_editor as editor;
use gpui::App;
/// Initializes the framework.