7 Commits
Author SHA1 Message Date
reya b5ce99f4e6 update 2026-11-10 17:00:44 +07:00
reya 4de0029b03 add toast and alert overlays to the shell 2026-11-10 16:43:48 +07:00
reya 37d95d3c75 clean up 2026-11-09 16:00:19 +07:00
reya 2e7711ee2e Move layout metrics into theme and apply audit cleanups 2026-11-09 15:59:13 +07:00
reya 122f9b12ae Reduce render allocations and redundant work in core components 2026-11-09 14:35:15 +07:00
reya 73f7dc31d4 Fix shared element state across component instances 2026-11-09 14:04:25 +07:00
reya f022f08947 Add component audit report 2026-11-09 13:36:26 +07:00
47 changed files with 1554 additions and 1291 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ badge = []
input = ["button"] input = ["button"]
clipboard = ["button"] clipboard = ["button"]
popover = [] popover = []
scroll_area = [] scroll_area = ["scrollbar"]
scrollbar = ["scroll_area"] scrollbar = ["scroll_area"]
segmented = [] segmented = []
separator = [] separator = []
+13 -4
View File
@@ -260,11 +260,19 @@ impl Alerts {
} }
} }
/// Dismisses every alert. /// Dismisses every alert, running each entry's `on_dismiss` handler.
pub fn clear(&mut self, cx: &mut Context<Self>) { pub fn clear(&mut self, cx: &mut Context<Self>) {
if !self.entries.is_empty() { if self.entries.is_empty() {
self.entries.clear(); return;
}
let entries = std::mem::take(&mut self.entries);
cx.notify(); cx.notify();
for entry in entries {
if let Some(on_dismiss) = entry.on_dismiss {
on_dismiss(cx);
}
} }
} }
@@ -304,7 +312,7 @@ impl RenderOnce for AlertsView {
let theme = cx.theme(); let theme = cx.theme();
let enter_duration = theme.motion.overlay; let enter_duration = theme.motion.overlay;
let entries: Vec<AlertEntry> = self.alerts.read(cx).entries().to_vec(); let entries = self.alerts.read(cx).entries();
deferred( deferred(
div() div()
@@ -324,6 +332,7 @@ impl RenderOnce for AlertsView {
div() div()
.relative() .relative()
.w_full() .w_full()
.debug_selector(|| "alert-entry".to_string())
.child(entry.alert.clone()) .child(entry.alert.clone())
.with_animation( .with_animation(
("alert-enter", id), ("alert-enter", id),
+11 -18
View File
@@ -4,7 +4,10 @@ use gpui::{
App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div,
}; };
use gpui_native_theme::{ActiveTheme as _, Oklch, Size, ThemeVariant}; use gpui_native_theme::{
ActiveTheme as _, Oklch, PIXEL_AVATAR_DARK_FACTOR, PIXEL_AVATAR_DARK_FLOOR,
PIXEL_AVATAR_LIGHT_CAP, PIXEL_AVATAR_LIGHT_LIFT, PIXEL_AVATAR_TINT_ALPHA, Size, ThemeVariant,
};
use crate::common::{Sizable, StyledExt, h_flex}; use crate::common::{Sizable, StyledExt, h_flex};
@@ -108,12 +111,10 @@ impl RenderOnce for Avatar {
.refine_style(&self.style); .refine_style(&self.style);
match self.picture { match self.picture {
Some(url) => base.child(gpui::img(url).size_full().rounded(radius)), Some(url) => base.child(gpui::img(url).size_full()),
None => base.child(PixelAvatar { None => base.child(PixelAvatar {
seed: fnv1a(self.id.as_bytes()), seed: fnv1a(self.id.as_bytes()),
side, side,
radius,
style: self.style,
}), }),
} }
.into_any_element() .into_any_element()
@@ -125,14 +126,6 @@ impl RenderOnce for Avatar {
struct PixelAvatar { struct PixelAvatar {
seed: u64, seed: u64,
side: gpui::Pixels, side: gpui::Pixels,
radius: gpui::Pixels,
style: StyleRefinement,
}
impl gpui::Styled for PixelAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
} }
impl RenderOnce for PixelAvatar { impl RenderOnce for PixelAvatar {
@@ -147,13 +140,13 @@ impl RenderOnce for PixelAvatar {
let shade = if theme.variant == ThemeVariant::Dark { let shade = if theme.variant == ThemeVariant::Dark {
Oklch { Oklch {
l: (accent.l * 1.6).min(0.95), l: (accent.l * PIXEL_AVATAR_LIGHT_LIFT).min(PIXEL_AVATAR_LIGHT_CAP),
..Oklch::from_hsla(main) ..Oklch::from_hsla(main)
} }
.to_hsla() .to_hsla()
} else { } else {
Oklch { Oklch {
l: (accent.l * 0.45).max(0.18), l: (accent.l * PIXEL_AVATAR_DARK_FACTOR).max(PIXEL_AVATAR_DARK_FLOOR),
..Oklch::from_hsla(main) ..Oklch::from_hsla(main)
} }
.to_hsla() .to_hsla()
@@ -182,11 +175,11 @@ impl RenderOnce for PixelAvatar {
.grid_rows(GRID_SIZE as u16) .grid_rows(GRID_SIZE as u16)
.size(self.side) .size(self.side)
.flex_none() .flex_none()
.rounded(self.radius) .bg(gpui::Hsla {
.overflow_hidden() a: PIXEL_AVATAR_TINT_ALPHA,
.bg(gpui::Hsla { a: 0.16, ..main }) ..main
})
.children(cells) .children(cells)
.refine_style(&self.style)
} }
} }
+4 -4
View File
@@ -4,7 +4,7 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, AnyView, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas,
hsla, relative, relative, transparent_black,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
@@ -199,9 +199,9 @@ impl RenderOnce for Button {
match background { match background {
Some(fill) => (fill, hover, pressed), Some(fill) => (fill, hover, pressed),
None => ( None => (
hsla(0.0, 0.0, 0.0, 0.0), transparent_black(),
hsla(0.0, 0.0, 0.0, 0.0), transparent_black(),
hsla(0.0, 0.0, 0.0, 0.0), transparent_black(),
), ),
} }
}; };
+21 -29
View File
@@ -10,13 +10,13 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, KeyBinding,
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, ScrollHandle,
ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Task,
Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px, Window, actions, anchored, canvas, deferred, div, point, px,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, POPUP_MAX_HEIGHT, Size};
use crate::common::{Sizable, StyledExt as _, h_flex, v_flex}; use crate::common::{Sizable, StyledExt as _, h_flex, v_flex};
use crate::input::{Input, InputState, InputStateEvent}; use crate::input::{Input, InputState, InputStateEvent};
@@ -177,9 +177,6 @@ where
} }
} }
/// Emitted when the combobox's value changes.
pub struct ComboboxChanged;
/// The stateful combobox. /// The stateful combobox.
pub struct ComboboxState<T> pub struct ComboboxState<T>
where where
@@ -209,8 +206,6 @@ where
_subscription: Subscription, _subscription: Subscription,
} }
impl<T> EventEmitter<ComboboxChanged> for ComboboxState<T> where T: Clone + PartialEq + 'static {}
impl<T> Sizable for ComboboxState<T> impl<T> Sizable for ComboboxState<T>
where where
T: Clone + PartialEq + 'static, T: Clone + PartialEq + 'static,
@@ -316,8 +311,8 @@ where
.map(|(_, label)| label.clone()) .map(|(_, label)| label.clone())
} }
/// Recomputes `visible` for `query` and hands the survivors' labels back. /// Recomputes `visible` for `query` and clamps the highlight onto it.
fn refilter(&mut self, query: &str) -> Vec<SharedString> { fn refilter(&mut self, query: &str) {
let filter = self.filter.clone(); let filter = self.filter.clone();
self.visible = self self.visible = self
.options .options
@@ -326,10 +321,7 @@ where
.filter(|(_, (value, label))| filter(query, value, label)) .filter(|(_, (value, label))| filter(query, value, label))
.map(|(index, _)| index) .map(|(index, _)| index)
.collect(); .collect();
self.visible self.highlight = self.highlight.filter(|row| *row < self.visible.len());
.iter()
.map(|index| self.options[*index].1.clone())
.collect()
} }
fn query_text(&self, cx: &Context<Self>) -> String { fn query_text(&self, cx: &Context<Self>) -> String {
@@ -349,7 +341,6 @@ where
}); });
self.tasks.push(task); self.tasks.push(task);
} }
cx.emit(ComboboxChanged);
cx.notify(); cx.notify();
} }
@@ -375,7 +366,7 @@ where
fn text_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn text_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.query_text(cx); let query = self.query_text(cx);
self.refilter(&query); self.refilter(&query);
self.highlight = (!self.visible.is_empty()).then_some(0usize); self.highlight = Some(0);
self.set_value(None, window, cx); self.set_value(None, window, cx);
cx.notify(); cx.notify();
} }
@@ -386,7 +377,7 @@ where
return; return;
} }
self.refilter(""); self.refilter("");
self.highlight = (!self.visible.is_empty()).then_some(0usize); self.highlight = Some(0);
cx.notify(); cx.notify();
} }
@@ -427,10 +418,7 @@ where
if self.visible.is_empty() { if self.visible.is_empty() {
return; return;
} }
let current = self let current = self.highlight.filter(|row| *row < self.visible.len());
.highlight
.and_then(|row| self.visible.get(row).copied())
.and_then(|option_ix| self.visible.iter().position(|&row| row == option_ix));
let next = match current { let next = match current {
Some(row) => { Some(row) => {
let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32); let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32);
@@ -509,10 +497,14 @@ where
state.update(cx, |state, cx| { state.update(cx, |state, cx| {
if state.highlight.is_some() { if state.highlight.is_some() {
state.highlight = None; state.highlight = None;
} else {
if state.selected.is_some() {
state.refilter("");
} else { } else {
let query = state.query.read(cx).value().to_string(); let query = state.query.read(cx).value().to_string();
state.refilter(&query); state.refilter(&query);
state.highlight = (!state.visible.is_empty()).then_some(0usize); }
state.highlight = Some(0);
} }
cx.notify(); cx.notify();
}); });
@@ -600,8 +592,8 @@ where
div() div()
.id(("option", row)) .id(("option", row))
.debug_selector({ .debug_selector({
let name = format!("{id}-option-{row}"); let id = id.clone();
move || name move || format!("{id}-option-{row}")
}) })
.flex() .flex()
.items_center() .items_center()
@@ -663,7 +655,7 @@ where
ScrollArea::new((id.clone(), "rows")) ScrollArea::new((id.clone(), "rows"))
.handle(scroll.read(cx).clone()) .handle(scroll.read(cx).clone())
.axes(crate::scroll_area::ScrollAxes::Vertical) .axes(crate::scroll_area::ScrollAxes::Vertical)
.max_h(px(320.)) .max_h(POPUP_MAX_HEIGHT)
.children(rows) .children(rows)
.into_any_element() .into_any_element()
}; };
@@ -677,8 +669,8 @@ where
let panel = v_flex() let panel = v_flex()
.id((id.clone(), "panel")) .id((id.clone(), "panel"))
.debug_selector({ .debug_selector({
let name = format!("{id}-panel"); let id = id.clone();
move || name move || format!("{id}-panel")
}) })
.w(popup_width) .w(popup_width)
.p_1() .p_1()
+18 -32
View File
@@ -1,17 +1,16 @@
//! The command palette: a modal query field over the application's actions. //! The command palette: a modal query field over the application's actions.
use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, AnyElement, App, Context, ElementId, Entity, InteractiveElement as _, IntoElement, KeyBinding,
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, MouseButton, ParentElement as _, Render, Role, ScrollHandle, SharedString,
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, deferred,
actions, canvas, div, px, relative, div, relative,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT, Size};
use crate::common::{h_flex, v_flex}; use crate::common::{h_flex, v_flex};
use crate::input::{Input, InputState, InputStateEvent}; use crate::input::{Input, InputState, InputStateEvent};
@@ -322,6 +321,7 @@ impl Render for CommandPaletteState {
let id = self.id.clone(); let id = self.id.clone();
let state = cx.entity(); let state = cx.entity();
let query_focus = self.query.read(cx).focus_handle();
let rows: Vec<AnyElement> = self let rows: Vec<AnyElement> = self
.matches .matches
@@ -333,10 +333,10 @@ impl Render for CommandPaletteState {
let row_ix = row; let row_ix = row;
h_flex() h_flex()
.id(("command", row)) .id((ElementId::from(command.id.clone()), "command"))
.debug_selector({ .debug_selector({
let name = format!("{id}-command-{row}"); let id = id.clone();
move || name move || format!("{id}-command-{row}")
}) })
.gap_2() .gap_2()
.px_2() .px_2()
@@ -401,19 +401,16 @@ impl Render for CommandPaletteState {
ScrollArea::new((id.clone(), "rows")) ScrollArea::new((id.clone(), "rows"))
.handle(scroll.read(cx).clone()) .handle(scroll.read(cx).clone())
.axes(ScrollAxes::Vertical) .axes(ScrollAxes::Vertical)
.max_h(px(320.)) .max_h(POPUP_MAX_HEIGHT)
.children(rows) .children(rows)
.into_any_element() .into_any_element()
}; };
let panel_bounds: Rc<RefCell<Option<Bounds<Pixels>>>> = Rc::new(RefCell::new(None));
let query_focus = self.query.read(cx).focus_handle();
let panel = v_flex() let panel = v_flex()
.id((id.clone(), "panel")) .id((id.clone(), "panel"))
.debug_selector({ .debug_selector({
let name = format!("{id}-panel"); let id = id.clone();
move || name move || format!("{id}-panel")
}) })
.key_context(COMMAND_PALETTE_CONTEXT) .key_context(COMMAND_PALETTE_CONTEXT)
.role(Role::Dialog) .role(Role::Dialog)
@@ -460,20 +457,14 @@ impl Render for CommandPaletteState {
) )
.child(rows) .child(rows)
.child( .child(
canvas( canvas(|_, _, _| {}, {
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let focus = query_focus.clone(); let focus = query_focus.clone();
move |_, _, window, cx| { move |_, _, window, cx| {
if !focus.contains_focused(window, cx) { if !focus.contains_focused(window, cx) {
window.focus(&focus, cx); window.focus(&focus, cx);
} }
} }
}, })
)
.absolute() .absolute()
.inset_0(), .inset_0(),
); );
@@ -485,19 +476,13 @@ impl Render for CommandPaletteState {
.occlude() .occlude()
.on_mouse_down(MouseButton::Left, { .on_mouse_down(MouseButton::Left, {
let state = state.clone(); let state = state.clone();
let panel_bounds = panel_bounds.clone(); move |_, _window, cx| {
move |event: &MouseDownEvent, _window, cx| {
let on_panel = panel_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if on_panel {
return;
}
state.update(cx, |state, cx| state.close(cx)); state.update(cx, |state, cx| state.close(cx));
cx.stop_propagation(); cx.stop_propagation();
} }
}); });
deferred(
div() div()
.absolute() .absolute()
.inset_0() .inset_0()
@@ -506,7 +491,8 @@ impl Render for CommandPaletteState {
.items_start() .items_start()
.pt_10() .pt_10()
.child(scrim) .child(scrim)
.child(panel) .child(panel),
)
.into_any_element() .into_any_element()
} }
} }
+7 -25
View File
@@ -1,12 +1,10 @@
use std::cell::RefCell; use gpui::prelude::FluentBuilder as _;
use std::rc::Rc; use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, ElementId, FontWeight, Animation, AnimationExt as _, AnyElement, App, ElementId, FontWeight, InteractiveElement as _,
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, IntoElement, KeyDownEvent, MouseButton, ParentElement as _, RenderOnce, Role, SharedString,
ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, ease_out_quint, relative,
Styled as _, Window, canvas, div, ease_out_quint, relative,
}; };
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
@@ -16,8 +14,6 @@ use crate::common::v_flex;
pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>; pub type ChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
type ContentFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>; type ContentFn = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>; type ActionsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<AnyElement>>;
/// Bounds captured during the last paint, shared with the scrim listener.
type BoundsCell = Rc<RefCell<Option<Bounds<Pixels>>>>;
/// A centred panel over a dim scrim: the platform's modal. /// A centred panel over a dim scrim: the platform's modal.
#[derive(IntoElement)] #[derive(IntoElement)]
@@ -165,7 +161,6 @@ impl RenderOnce for Dialog {
let content = self.content.map(|build| build(window, cx)); let content = self.content.map(|build| build(window, cx));
let actions = self.actions.map(|build| build(window, cx)); let actions = self.actions.map(|build| build(window, cx));
let panel_bounds: BoundsCell = Rc::new(RefCell::new(None));
let panel = v_flex() let panel = v_flex()
.id("dialog-panel") .id("dialog-panel")
@@ -211,19 +206,13 @@ impl RenderOnce for Dialog {
panel.child(div().flex().justify_end().gap_2().children(actions)) panel.child(div().flex().justify_end().gap_2().children(actions))
}) })
.child( .child(
canvas( canvas(|_, _, _| {}, {
{
let cell = panel_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
move |_, _, window, cx| { move |_, _, window, cx| {
if !focus.contains_focused(window, cx) { if !focus.contains_focused(window, cx) {
window.focus(&focus, cx); window.focus(&focus, cx);
} }
} }
}, })
)
.absolute() .absolute()
.inset_0(), .inset_0(),
); );
@@ -235,15 +224,8 @@ impl RenderOnce for Dialog {
.occlude() .occlude()
.when(!alert, |scrim| { .when(!alert, |scrim| {
scrim.on_mouse_down(MouseButton::Left, { scrim.on_mouse_down(MouseButton::Left, {
let panel_bounds = panel_bounds.clone();
let on_change = on_change.clone(); let on_change = on_change.clone();
move |event: &MouseDownEvent, _window, cx| { move |_, _window, cx| {
let on_panel = panel_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if on_panel {
return;
}
if let Some(on_change) = &on_change { if let Some(on_change) = &on_change {
on_change(false, _window, cx); on_change(false, _window, cx);
} }
+7 -6
View File
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent, App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString, MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, hsla, relative, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
@@ -193,7 +193,7 @@ impl RenderOnce for DropdownMenu {
let border = if show_border { let border = if show_border {
colors.border colors.border
} else { } else {
background.unwrap_or_else(|| hsla(0.0, 0.0, 0.0, 0.0)) background.unwrap_or_else(gpui::transparent_black)
}; };
let split = self.on_click.is_some(); let split = self.on_click.is_some();
@@ -207,7 +207,8 @@ impl RenderOnce for DropdownMenu {
let a11y_label = self.a11y_label.or_else(|| self.label.clone()); let a11y_label = self.a11y_label.or_else(|| self.label.clone());
let focus = focus_handle(&self.id, window, cx).tab_stop(self.tab_stop); let focus = focus_handle(&self.id, window, cx).tab_stop(self.tab_stop);
// How the menu was last opened, read by the frame that mounts the panel. // How the menu was last opened, read by the frame that mounts the panel.
let keyboard_open = window.use_keyed_state("dropdown-open-origin", cx, |_, _| false); let keyboard_open =
window.use_keyed_state((self.id.clone(), "dropdown-open-origin"), cx, |_, _| false);
let on_open: ChangeHandler = { let on_open: ChangeHandler = {
let focus = focus.clone(); let focus = focus.clone();
@@ -421,10 +422,10 @@ impl RenderOnce for DropdownMenu {
let items = if self.open { let items = if self.open {
self.items self.items
.map(|build| build(window, cx)) .map(|build| Rc::from(build(window, cx)))
.unwrap_or_default() .unwrap_or_else(|| Rc::from([]))
} else { } else {
Vec::new() Rc::from([])
}; };
if let Some(panel) = anchored_menu_panel( if let Some(panel) = anchored_menu_panel(
+6 -6
View File
@@ -95,8 +95,8 @@ impl RenderOnce for Field {
let title = div() let title = div()
.debug_selector({ .debug_selector({
let name = format!("{id}-label"); let id = id.clone();
move || name move || format!("{id}-label")
}) })
.text_size(metrics.text_size) .text_size(metrics.text_size)
.child(self.label.clone()); .child(self.label.clone());
@@ -105,16 +105,16 @@ impl RenderOnce for Field {
.items_center() .items_center()
.gap_2() .gap_2()
.debug_selector({ .debug_selector({
let name = format!("{id}-control"); let id = id.clone();
move || name move || format!("{id}-control")
}) })
.children(self.control); .children(self.control);
let error = self.error.map(|error| { let error = self.error.map(|error| {
div() div()
.debug_selector({ .debug_selector({
let name = format!("{id}-error"); let id = id.clone();
move || name move || format!("{id}-error")
}) })
.text_size(metrics.caption_size) .text_size(metrics.caption_size)
.text_color(error_color) .text_color(error_color)
+103 -82
View File
@@ -1,4 +1,3 @@
use std::cell::RefCell;
use std::ops::Range; use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -16,7 +15,10 @@ use gpui::{
WrappedLine, actions, canvas, div, fill, point, px, WrappedLine, actions, canvas, div, fill, point, px,
}; };
use gpui_native_icons::IconRole; use gpui_native_icons::IconRole;
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, ColorTokens, Size}; use gpui_native_theme::{
ActiveTheme as _, BORDER_WIDTH, CARET_WIDTH, ColorTokens, Size, UNDERLINE_WIDTH,
WHEEL_LINE_HEIGHT,
};
use gpui_native_utils::focus_ring; use gpui_native_utils::focus_ring;
use crate::button::{Button, ButtonVariant}; use crate::button::{Button, ButtonVariant};
@@ -29,7 +31,7 @@ const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
/// How often the focused caret blinks. /// How often the focused caret blinks.
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
/// Pixel height of one scroll "line" for wheel scrolling. /// Pixel height of one scroll "line" for wheel scrolling.
const SCROLL_LINES_HEIGHT: f32 = 20.0; const SCROLL_LINES_HEIGHT: f32 = WHEEL_LINE_HEIGHT;
/// The row count a multiline field shows until the app sets one. /// The row count a multiline field shows until the app sets one.
const DEFAULT_ROWS: usize = 4; const DEFAULT_ROWS: usize = 4;
/// Maximum undo entries kept; the oldest drops off the front. /// Maximum undo entries kept; the oldest drops off the front.
@@ -270,7 +272,7 @@ impl InputBindings {
} }
/// Collects the table into keybindings. /// Collects the table into keybindings.
pub fn into_bindings(self) -> Vec<KeyBinding> { pub(crate) fn into_bindings(self) -> Vec<KeyBinding> {
let mut bindings: Vec<KeyBinding> = [ let mut bindings: Vec<KeyBinding> = [
self.escape, self.escape,
self.submit, self.submit,
@@ -491,9 +493,9 @@ pub struct InputState {
focused: bool, focused: bool,
focus_handle: gpui::FocusHandle, focus_handle: gpui::FocusHandle,
/// The key-down handler, bound to this entity with `cx.listener`. /// The key-down handler, bound to this entity with `cx.listener`.
key_down_handler: Option<KeyDownHandler>, key_down_handler: KeyDownHandler,
/// The mouse-down handler, bound to this entity with `cx.listener`. /// The mouse-down handler, bound to this entity with `cx.listener`.
mouse_down_handler: Option<MouseDownHandler>, mouse_down_handler: MouseDownHandler,
/// Whether a drag-select is in progress. /// Whether a drag-select is in progress.
dragging: bool, dragging: bool,
/// Patch history. `redo_stack` holds undone entries awaiting redo. /// Patch history. `redo_stack` holds undone entries awaiting redo.
@@ -520,9 +522,9 @@ pub struct InputState {
/// Bumped on every edit. /// Bumped on every edit.
caret_epoch: usize, caret_epoch: usize,
/// When the current caret epoch started. /// When the current caret epoch started.
caret_epoch_started: Option<Instant>, caret_epoch_started: Instant,
/// The shaped line and element bounds, recorded during each paint. /// The shaped line and element bounds, recorded during each paint.
layout: Rc<RefCell<Option<InputLayout>>>, layout: Option<InputLayout>,
} }
impl EventEmitter<InputStateEvent> for InputState {} impl EventEmitter<InputStateEvent> for InputState {}
@@ -536,16 +538,14 @@ impl InputState {
marked_range: None, marked_range: None,
focused: false, focused: false,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
key_down_handler: Some(Rc::new(cx.listener( key_down_handler: Rc::new(cx.listener(|state, event: &KeyDownEvent, window, cx| {
|state, event: &KeyDownEvent, window, cx| {
state.handle_key_down(event, window, cx); state.handle_key_down(event, window, cx);
}, })),
))), mouse_down_handler: Rc::new(cx.listener(
mouse_down_handler: Some(Rc::new(cx.listener(
|state, event: &MouseDownEvent, window, cx| { |state, event: &MouseDownEvent, window, cx| {
state.handle_mouse_down(event, window, cx); state.handle_mouse_down(event, window, cx);
}, },
))), )),
dragging: false, dragging: false,
undo_stack: Vec::new(), undo_stack: Vec::new(),
redo_stack: Vec::new(), redo_stack: Vec::new(),
@@ -559,8 +559,8 @@ impl InputState {
scroll_offset: point(Pixels::ZERO, Pixels::ZERO), scroll_offset: point(Pixels::ZERO, Pixels::ZERO),
scroll_to_caret_pending: true, scroll_to_caret_pending: true,
caret_epoch: 0, caret_epoch: 0,
caret_epoch_started: Some(Instant::now()), caret_epoch_started: Instant::now(),
layout: Rc::new(RefCell::new(None)), layout: None,
} }
} }
@@ -696,12 +696,7 @@ impl InputState {
} }
} }
/// Which keystroke submits a multiline field. // Whether the painted line masks the buffer.
pub fn submit_on(&self) -> SubmitOn {
self.submit_on
}
/// Whether the painted line masks the buffer.
fn masked(&self) -> bool { fn masked(&self) -> bool {
self.password && !self.revealed self.password && !self.revealed
} }
@@ -711,11 +706,21 @@ impl InputState {
self.redo_stack.clear(); self.redo_stack.clear();
let now = Instant::now(); let now = Instant::now();
if let Some(last) = self.undo_stack.last_mut() if let Some(last) = self.undo_stack.last_mut() {
&& now.duration_since(last.timestamp) < self.group_interval
{
let last_end = last.range.start + last.new_len; let last_end = last.range.start + last.new_len;
if self.marked_range.is_some() {
// A pre-edit step overlapping the previous entry's region.
if range.start >= last.range.start {
last.new_len += new_len;
let replaced = range.len();
last.new_len -= replaced;
last.timestamp = now;
return;
}
}
if now.duration_since(last.timestamp) < self.group_interval {
// Typing that continues right after the previous edit. // Typing that continues right after the previous edit.
if range.start == last_end { if range.start == last_end {
last.new_len += new_len; last.new_len += new_len;
@@ -739,6 +744,7 @@ impl InputState {
return; return;
} }
} }
}
self.undo_stack.push(HistoryEntry { self.undo_stack.push(HistoryEntry {
range: range.start..range.start + new_len, range: range.start..range.start + new_len,
@@ -769,7 +775,7 @@ impl InputState {
self.scroll_to_caret_pending = true; self.scroll_to_caret_pending = true;
self.goal_x = None; self.goal_x = None;
self.caret_epoch += 1; self.caret_epoch += 1;
self.caret_epoch_started = Some(Instant::now()); self.caret_epoch_started = Instant::now();
window.notify_text_input_changed(&self.focus_handle); window.notify_text_input_changed(&self.focus_handle);
} }
@@ -781,20 +787,38 @@ impl InputState {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> bool { ) -> bool {
if self.multiline { if self.multiline {
self.scroll_offset.y = (self.scroll_offset.y + delta.y).max(Pixels::ZERO); let next_y = (self.scroll_offset.y + delta.y).max(Pixels::ZERO);
} else { if next_y == self.scroll_offset.y {
self.scroll_offset.x = (self.scroll_offset.x - delta.x).max(Pixels::ZERO); return self.multiline_scrollable();
} }
self.scroll_offset.y = next_y;
} else {
let next_x = (self.scroll_offset.x - delta.x).max(Pixels::ZERO);
if next_x == self.scroll_offset.x {
return false;
}
self.scroll_offset.x = next_x;
}
let scrollable = self let scrollable = self
.layout .layout
.borrow()
.as_ref() .as_ref()
.is_some_and(|layout| layout.content_height > layout.visible_height); .is_some_and(|layout| layout.content_height > layout.visible_height);
cx.notify(); cx.notify();
window.notify_text_input_changed(&self.focus_handle); window.notify_text_input_changed(&self.focus_handle);
self.multiline && scrollable self.multiline && scrollable
} }
/// Whether a multiline field's content overflows vertically.
fn multiline_scrollable(&self) -> bool {
self.multiline
&& self
.layout
.as_ref()
.is_some_and(|layout| layout.content_height > layout.visible_height)
}
/// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint. /// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint.
fn sync_focus(&mut self, focused: bool, cx: &mut Context<Self>) { fn sync_focus(&mut self, focused: bool, cx: &mut Context<Self>) {
if self.focused == focused { if self.focused == focused {
@@ -802,11 +826,9 @@ impl InputState {
} }
self.focused = focused; self.focused = focused;
// A fresh focus starts a fresh blink epoch, so the caret shows solid
// for one interval before blinking resumes.
if focused { if focused {
self.caret_epoch += 1; self.caret_epoch += 1;
self.caret_epoch_started = Some(Instant::now()); self.caret_epoch_started = Instant::now();
} }
cx.emit(if focused { cx.emit(if focused {
@@ -817,11 +839,8 @@ impl InputState {
} }
/// Whether the caret should paint. /// Whether the caret should paint.
pub fn caret_visible(&mut self, focused: bool) -> bool { pub fn caret_visible(&self, focused: bool) -> bool {
let elapsed = self let elapsed = self.caret_epoch_started.elapsed();
.caret_epoch_started
.map(|started| started.elapsed())
.unwrap_or_default();
self.caret_blink_visible_at(focused, elapsed) self.caret_blink_visible_at(focused, elapsed)
} }
@@ -901,15 +920,21 @@ impl InputState {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.cursor = to.clamp(0, self.text.len()); let cursor = to.clamp(0, self.text.len());
if !extend { let anchor = if extend { self.anchor } else { cursor };
self.anchor = self.cursor; if cursor == self.cursor && anchor == self.anchor {
return;
} }
self.cursor = cursor;
self.anchor = anchor;
self.notify(window, cx); self.notify(window, cx);
} }
/// Selects `range`. /// Selects `range`.
fn select_range(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) { fn select_range(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) {
if range.start == self.anchor && range.end == self.cursor {
return;
}
self.anchor = range.start; self.anchor = range.start;
self.cursor = range.end; self.cursor = range.end;
self.notify(window, cx); self.notify(window, cx);
@@ -1085,7 +1110,7 @@ impl InputState {
); );
let text_width = (bounds.size.width - padding.left - padding.right).max(Pixels::ZERO); let text_width = (bounds.size.width - padding.left - padding.right).max(Pixels::ZERO);
*self.layout.borrow_mut() = Some(InputLayout { self.layout = Some(InputLayout {
lines: text.lines, lines: text.lines,
rows, rows,
bounds, bounds,
@@ -1107,8 +1132,7 @@ impl InputState {
return; return;
} }
let layout = self.layout.borrow(); let Some(layout) = self.layout.as_ref() else {
let Some(layout) = layout.as_ref() else {
return; return;
}; };
@@ -1138,8 +1162,7 @@ impl InputState {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
let layout = self.layout.borrow(); let Some(layout) = self.layout.as_ref() else {
let Some(layout) = layout.as_ref() else {
return; return;
}; };
@@ -1155,17 +1178,16 @@ impl InputState {
let mut line_top = Pixels::ZERO; let mut line_top = Pixels::ZERO;
for entry in &layout.lines { for entry in &layout.lines {
let line_origin = point(origin.x, origin.y + line_top); let line_origin = point(origin.x, origin.y + line_top);
entry if let Err(err) = entry.line.paint(
.line
.paint(
line_origin, line_origin,
layout.line_height, layout.line_height,
TextAlign::Left, TextAlign::Left,
None, None,
window, window,
cx, cx,
) ) {
.ok(); eprintln!("painting input text failed: {err}");
}
line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32; line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32;
} }
}); });
@@ -1179,8 +1201,7 @@ impl InputState {
color: gpui::Hsla, color: gpui::Hsla,
window: &mut Window, window: &mut Window,
) { ) {
let layout = self.layout.borrow(); let Some(layout) = self.layout.as_ref() else {
let Some(layout) = layout.as_ref() else {
return; return;
}; };
@@ -1192,7 +1213,7 @@ impl InputState {
let caret_top = origin.y + layout.rows[row_ix].y + (layout.line_height - font_size) / 2.0; let caret_top = origin.y + layout.rows[row_ix].y + (layout.line_height - font_size) / 2.0;
let caret = Bounds::from_corners( let caret = Bounds::from_corners(
point(origin.x + caret_x, caret_top), point(origin.x + caret_x, caret_top),
point(origin.x + caret_x + px(1.5), caret_top + font_size), point(origin.x + caret_x + CARET_WIDTH, caret_top + font_size),
); );
window.paint_quad(fill(caret, color)); window.paint_quad(fill(caret, color));
@@ -1242,8 +1263,7 @@ impl InputState {
/// The text index at a window point, using the last painted layout. /// The text index at a window point, using the last painted layout.
fn index_at(&self, position: gpui::Point<Pixels>) -> usize { fn index_at(&self, position: gpui::Point<Pixels>) -> usize {
let layout = self.layout.borrow(); let Some(layout) = self.layout.as_ref() else {
let Some(layout) = layout.as_ref() else {
return 0; return 0;
}; };
real_index(&layout.mask_map, layout.index_at(position)) real_index(&layout.mask_map, layout.index_at(position))
@@ -1256,7 +1276,11 @@ impl InputState {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.cursor = self.index_at(position); let cursor = self.index_at(position);
if cursor == self.cursor {
return;
}
self.cursor = cursor;
self.notify(window, cx); self.notify(window, cx);
} }
@@ -1268,14 +1292,13 @@ impl InputState {
color: gpui::Hsla, color: gpui::Hsla,
window: &mut Window, window: &mut Window,
) { ) {
let layout = self.layout.borrow(); let Some(layout) = self.layout.as_ref() else {
let Some(layout) = layout.as_ref() else {
return; return;
}; };
let start = display_index(&layout.mask_map, range.start); let start = display_index(&layout.mask_map, range.start);
let end = display_index(&layout.mask_map, range.end); let end = display_index(&layout.mask_map, range.end);
let thickness = px(1.0); let thickness = UNDERLINE_WIDTH;
for (row_ix, clipped, beyond) in layout.row_overlaps(start..end) { for (row_ix, clipped, beyond) in layout.row_overlaps(start..end) {
let top = origin.y + layout.rows[row_ix].y + layout.line_height - thickness; let top = origin.y + layout.rows[row_ix].y + layout.line_height - thickness;
@@ -1329,7 +1352,7 @@ impl InputState {
if event.click_count >= 3 { if event.click_count >= 3 {
// Triple-click selects the clicked row, which is all of it when single-line. // Triple-click selects the clicked row, which is all of it when single-line.
let row_range = self.layout.borrow().as_ref().and_then(|layout| { let row_range = self.layout.as_ref().and_then(|layout| {
let display = display_index(&layout.mask_map, index); let display = display_index(&layout.mask_map, index);
layout.row_range_at(display).map(|range| { layout.row_range_at(display).map(|range| {
real_index(&layout.mask_map, range.start) real_index(&layout.mask_map, range.start)
@@ -1462,15 +1485,16 @@ impl InputState {
} }
let target = { let target = {
let layout = self.layout.borrow(); let Some(layout) = self.layout.as_ref() else {
let Some(layout) = layout.as_ref() else {
return; return;
}; };
let display = display_index(&layout.mask_map, self.cursor); let display = display_index(&layout.mask_map, self.cursor);
let Some((row_ix, caret_x)) = layout.row_for_index(display) else { let Some((row_ix, caret_x)) = layout.row_for_index(display) else {
return; return;
}; };
let target = row_ix as isize + delta; let target = row_ix as isize + delta;
let (display, goal_x) = if target < 0 { let (display, goal_x) = if target < 0 {
(0, None) (0, None)
} else if target as usize >= layout.rows.len() { } else if target as usize >= layout.rows.len() {
@@ -1479,6 +1503,7 @@ impl InputState {
let goal_x = self.goal_x.unwrap_or(caret_x); let goal_x = self.goal_x.unwrap_or(caret_x);
(layout.index_at_row_x(target as usize, goal_x), Some(goal_x)) (layout.index_at_row_x(target as usize, goal_x), Some(goal_x))
}; };
Some((real_index(&layout.mask_map, display), goal_x)) Some((real_index(&layout.mask_map, display), goal_x))
}; };
@@ -1748,8 +1773,7 @@ impl EntityInputHandler for InputState {
_window: &mut Window, _window: &mut Window,
_cx: &mut Context<Self>, _cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> { ) -> Option<Bounds<Pixels>> {
let layout = self.layout.borrow(); let layout = self.layout.as_ref()?;
let layout = layout.as_ref()?;
let start = display_index( let start = display_index(
&layout.mask_map, &layout.mask_map,
@@ -1797,9 +1821,10 @@ impl EntityInputHandler for InputState {
_window: &mut Window, _window: &mut Window,
_cx: &mut Context<Self>, _cx: &mut Context<Self>,
) -> Option<usize> { ) -> Option<usize> {
let layout = self.layout.borrow(); let layout = self.layout.as_ref()?;
let layout = layout.as_ref()?; let index =
Some(real_index(&layout.mask_map, layout.index_at(window_point)).min(self.text.len())) real_index(&layout.mask_map, layout.index_at(window_point)).min(self.text.len());
Some(utf8_range_to_utf16(&self.text, index..index).start)
} }
fn text_length_utf16( fn text_length_utf16(
@@ -2049,7 +2074,11 @@ impl RenderOnce for Input {
canvas canvas
.with_animation( .with_animation(
animation_id, animation_id,
Animation::new(CARET_BLINK_INTERVAL * 2).repeat(), // The blink needs four repaints per period,
// not the display's refresh rate.
Animation::new(CARET_BLINK_INTERVAL * 2)
.repeat()
.with_max_fps(4.0),
|canvas, _| canvas, |canvas, _| canvas,
) )
.into_any_element() .into_any_element()
@@ -2172,23 +2201,13 @@ impl RenderOnce for Input {
.when(!self.disabled, |input| { .when(!self.disabled, |input| {
input input
.on_mouse_down(MouseButton::Left, { .on_mouse_down(MouseButton::Left, {
let mouse_down_handler = self let mouse_down_handler = self.state.read(cx).mouse_down_handler.clone();
.state
.read(cx)
.mouse_down_handler
.clone()
.expect("input state always carries its mouse-down handler");
move |event: &MouseDownEvent, window, cx| { move |event: &MouseDownEvent, window, cx| {
mouse_down_handler(event, window, cx); mouse_down_handler(event, window, cx);
} }
}) })
.on_key_down({ .on_key_down({
let key_down_handler = self let key_down_handler = self.state.read(cx).key_down_handler.clone();
.state
.read(cx)
.key_down_handler
.clone()
.expect("input state always carries its key handler");
move |event: &KeyDownEvent, window, cx| { move |event: &KeyDownEvent, window, cx| {
key_down_handler(event, window, cx); key_down_handler(event, window, cx);
} }
@@ -2516,7 +2535,9 @@ fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> usize {
/// Converts a UTF-8 byte range to a UTF-16 range. /// Converts a UTF-8 byte range to a UTF-16 range.
fn utf8_range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> { fn utf8_range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> {
utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end) let start = text[..range.start].encode_utf16().count();
let end = start + text[range.start..range.end].encode_utf16().count();
start..end
} }
/// Converts a UTF-16 range to a UTF-8 byte range. /// Converts a UTF-16 range to a UTF-8 byte range.
+15 -14
View File
@@ -10,7 +10,7 @@ use gpui::{
ease_out_quint, point, px, relative, ease_out_quint, point, px, relative,
}; };
use gpui_native_icons::{IconRole, icon as icon_glyph}; use gpui_native_icons::{IconRole, icon as icon_glyph};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::{ActiveTheme as _, POPUP_MAX_HEIGHT};
use crate::common::v_flex; use crate::common::v_flex;
use crate::scroll_area::{ScrollArea, ScrollAxes}; use crate::scroll_area::{ScrollArea, ScrollAxes};
@@ -35,7 +35,7 @@ pub struct MenuItem {
handler: Option<ItemHandler>, handler: Option<ItemHandler>,
disabled: bool, disabled: bool,
destructive: bool, destructive: bool,
checked: Option<bool>, checked: bool,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -58,7 +58,7 @@ impl MenuItem {
handler: None, handler: None,
disabled: false, disabled: false,
destructive: false, destructive: false,
checked: None, checked: false,
} }
} }
@@ -72,7 +72,7 @@ impl MenuItem {
handler: None, handler: None,
disabled: false, disabled: false,
destructive: false, destructive: false,
checked: None, checked: false,
} }
} }
@@ -114,7 +114,7 @@ impl MenuItem {
/// Shows a check slot before the label. /// Shows a check slot before the label.
pub fn checked(mut self, checked: bool) -> Self { pub fn checked(mut self, checked: bool) -> Self {
self.checked = Some(checked); self.checked = checked;
self self
} }
@@ -336,11 +336,11 @@ impl RenderOnce for Menu {
let items = if self.open { let items = if self.open {
match self.items { match self.items {
Some(build) => build(window, cx), Some(build) => Rc::from(build(window, cx)),
None => Vec::new(), None => Rc::from([]),
} }
} else { } else {
Vec::new() Rc::from([])
}; };
if let Some(panel) = anchored_menu_panel( if let Some(panel) = anchored_menu_panel(
@@ -372,7 +372,7 @@ pub(crate) fn anchored_menu_panel(
cx: &mut App, cx: &mut App,
id: &ElementId, id: &ElementId,
open: bool, open: bool,
items: Vec<MenuItem>, items: Rc<[MenuItem]>,
initial_selection: Option<usize>, initial_selection: Option<usize>,
preselect: bool, preselect: bool,
on_change: Option<ChangeHandler>, on_change: Option<ChangeHandler>,
@@ -382,7 +382,7 @@ pub(crate) fn anchored_menu_panel(
chrome: &MenuChrome, chrome: &MenuChrome,
enter_duration: std::time::Duration, enter_duration: std::time::Duration,
) -> Option<AnyElement> { ) -> Option<AnyElement> {
let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| false); let was_open = window.use_keyed_state((id.clone(), "menu-was-open"), cx, |_, _| false);
if !open { if !open {
was_open.update(cx, |was_open, _| *was_open = false); was_open.update(cx, |was_open, _| *was_open = false);
@@ -392,8 +392,9 @@ pub(crate) fn anchored_menu_panel(
let on_change = on_change.clone(); let on_change = on_change.clone();
// Keyboard focus rides the panel across frames // Keyboard focus rides the panel across frames
let focus = gpui_native_utils::focus_handle(&(id.clone(), "panel").into(), window, cx); let focus = gpui_native_utils::focus_handle(&(id.clone(), "panel").into(), window, cx);
let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new()); let scroll =
let selection = window.use_keyed_state("menu-selection", cx, |_, _| None); 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) { let selected = if *was_open.read(cx) {
*selection.read(cx) *selection.read(cx)
@@ -469,7 +470,7 @@ pub(crate) fn anchored_menu_panel(
ScrollArea::new("menu-items") ScrollArea::new("menu-items")
.handle(scroll.read(cx).clone()) .handle(scroll.read(cx).clone())
.axes(ScrollAxes::Vertical) .axes(ScrollAxes::Vertical)
.max_h(px(320.)) .max_h(POPUP_MAX_HEIGHT)
.child(v_flex().gap_0p5().children(rows)), .child(v_flex().gap_0p5().children(rows)),
) )
.child( .child(
@@ -595,7 +596,7 @@ fn render_item(
.child(shortcut), .child(shortcut),
) )
}) })
.when(checked == Some(true), |row| { .when(checked, |row| {
row.child( row.child(
icon_glyph(IconRole::Check) icon_glyph(IconRole::Check)
.size(chrome.dense_icon_size) .size(chrome.dense_icon_size)
+17 -298
View File
@@ -1,17 +1,13 @@
use std::sync::Arc; //! A scrollable container with an overlay scrollbar.
use std::time::Duration;
use std::time::Instant;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, Bounds, DispatchPhase, ElementId, Entity, EntityId, InteractiveElement as _, IntoElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, ScrollHandle,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled, Window, div,
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, Task, Window, canvas, div,
fill, point,
}; };
use gpui_native_theme::ActiveTheme as _;
use crate::common::StyledExt; use crate::common::StyledExt;
use crate::scrollbar::{Scrollbar, ScrollbarVisibility};
/// Which directions the content scrolls in. /// Which directions the content scrolls in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
@@ -25,108 +21,6 @@ pub enum ScrollAxes {
Both, Both,
} }
/// When an overlay scrollbar shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScrollbarVisibility {
/// The running platform's behavior, from the theme.
#[default]
Platform,
Always,
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);
/// The shared autohide timeline for an overlay scrollbar.
#[derive(Clone)]
pub(crate) struct Autohide {
view: EntityId,
state: Entity<AutohideState>,
}
struct AutohideState {
last_activity: Option<Instant>,
hide_task: Option<Task<()>>,
}
impl Autohide {
pub(crate) fn new(key: ElementId, window: &mut Window, cx: &mut App) -> Self {
let view = window.current_view();
let state = window.use_keyed_state(key, cx, |_, _| AutohideState {
last_activity: None,
hide_task: None,
});
Self { view, state }
}
/// Marks interaction.
pub(crate) fn touch(&self, window: &mut Window, cx: &mut App) {
let view = self.view;
let reduce_motion = cx.theme().accessibility.reduce_motion;
let task = window.spawn(cx, async move |cx| {
if reduce_motion {
cx.background_executor()
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
.await;
cx.update(|_, cx| cx.notify(view)).ok();
return;
}
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
cx.background_executor().timer(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)).ok();
});
self.state.update(cx, |state, _| {
state.last_activity = Some(Instant::now());
state.hide_task = Some(task);
});
}
/// The thumb's opacity.
pub(crate) fn alpha(&self, cx: &App, dragging: bool) -> f32 {
if dragging {
return 1.0;
}
let Some(activity) = self.state.read(cx).last_activity else {
return 0.0;
};
let idle = activity.elapsed();
if idle <= AUTOHIDE_DELAY {
return 1.0;
}
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
if fade >= AUTOHIDE_FADE {
return 0.0;
}
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.as_secs_f32()
}
}
/// An overlay scrollbar thumb's style for one paint: `None` hides it.
pub(crate) fn thumb_style(color: gpui::Hsla, alpha: f32) -> Option<gpui::Hsla> {
(alpha > 0.0).then_some(gpui::Hsla {
a: color.a * alpha,
..color
})
}
/// A scrollable container with an overlay scrollbar. /// A scrollable container with an overlay scrollbar.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct ScrollArea { pub struct ScrollArea {
@@ -196,32 +90,8 @@ impl ScrollArea {
} }
} }
/// Names keyed state under the scrollbar's own id.
pub 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.
pub(crate) 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)
}
impl RenderOnce for ScrollArea { impl RenderOnce for ScrollArea {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let mut pane_style = self.style.clone(); let mut pane_style = self.style.clone();
pane_style.padding = Default::default(); pane_style.padding = Default::default();
@@ -252,175 +122,24 @@ impl RenderOnce for ScrollArea {
if let Some(handle) = self.handle { if let Some(handle) = self.handle {
let id = self.id.clone(); let id = self.id.clone();
let autohide = match self.visibility {
ScrollbarVisibility::Platform => cx.theme().scrollbar.autohide,
ScrollbarVisibility::Always => false,
ScrollbarVisibility::Autohide => true,
};
if self.axes != ScrollAxes::Horizontal {
wrapper.extend(std::iter::once( wrapper.extend(std::iter::once(
canvas(|bounds, _, _| bounds, { Scrollbar::vertical((id.clone(), "v-scrollbar"), handle.clone())
let handle = handle.clone(); .visibility(self.visibility)
move |overlay, _, window, cx| {
paint_scrollbar(&handle, &id, autohide, overlay, window, cx);
}
})
.absolute()
.inset_0()
.into_any_element(), .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 wrapper
} }
} }
/// Paints the overlay scrollbar thumb.
fn paint_scrollbar(
handle: &ScrollHandle,
key: &ElementId,
autohide: bool,
overlay: Bounds<Pixels>,
window: &mut Window,
cx: &mut App,
) {
let theme = cx.theme();
let view = window.current_view();
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.bounds();
let max_offset = handle.max_offset().y;
if max_offset <= Pixels::ZERO || viewport.size.height <= Pixels::ZERO {
return;
}
let autohide_state =
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx));
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| {
Option::<(Pixels, Pixels)>::None
});
let dragging = drag.read(cx).is_some();
let alpha = match (&autohide_state, autohide) {
(Some(state), true) => state.alpha(cx, dragging),
_ => 1.0,
};
let track = overlay.size.height - inset * 2.0;
let (thumb_length, thumb_top) = thumb_geometry(
viewport.size.height,
viewport.size.height + max_offset,
handle.offset().y,
track,
min_thumb,
);
let thumb_x = overlay.right() - width - inset;
let thumb_bounds = Bounds::from_corners(
point(thumb_x, overlay.top() + inset + thumb_top),
point(
thumb_x + width,
overlay.top() + inset + thumb_top + thumb_length,
),
);
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 || !overlay.contains(&event.position) {
return;
}
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 != gpui::MouseButton::Left
|| !thumb_bounds.contains(&event.position)
{
return;
}
drag.update(cx, |drag, _| {
*drag = Some((event.position.y, handle.offset().y));
});
if let Some(state) = &autohide_state {
state.touch(window, cx);
}
cx.stop_propagation();
}
});
window.on_mouse_event({
let drag = drag.clone();
let autohide_state = autohide_state.clone();
move |event: &MouseUpEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble || event.button != gpui::MouseButton::Left {
return;
}
if drag.read(cx).is_none() {
return;
}
drag.update(cx, |drag, _| {
*drag = None;
});
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: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
if let Some(state) = &autohide_state
&& drag.read(cx).is_some()
{
state.touch(window, cx);
}
if event.pressed_button != Some(gpui::MouseButton::Left) {
return;
}
let Some((start_y, start_offset)) = *drag.read(cx) else {
return;
};
if max_offset > Pixels::ZERO && track > thumb_length {
let scale = max_offset / (track - thumb_length);
let offset = (start_offset - (event.position.y - start_y) * scale)
.clamp(-max_offset, Pixels::ZERO);
handle.set_offset(point(handle.offset().x, offset));
cx.notify(view);
}
}
});
}
+143 -28
View File
@@ -1,18 +1,141 @@
//! Overlay scrollbars for scroll surfaces. //! Overlay scrollbars for scroll surfaces.
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use gpui::{ use gpui::{
App, Bounds, DispatchPhase, ElementId, IntoElement, ListState, MouseButton, MouseDownEvent, App, Bounds, DispatchPhase, ElementId, Entity, EntityId, IntoElement, ListState, MouseButton,
MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent, ScrollHandle, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, RenderOnce, ScrollEvent,
Styled as _, UniformListScrollHandle, Window, canvas, fill, point, ScrollHandle, SharedString, Styled as _, Task, UniformListScrollHandle, Window, canvas, fill,
point,
}; };
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
use crate::scroll_area::{Autohide, scrollbar_state_key, thumb_geometry, thumb_style}; /// When an overlay scrollbar shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ScrollbarVisibility {
/// The running platform's behavior, from the theme.
#[default]
Platform,
Always,
Autohide,
}
/// Re-exported so callers only need the scrollbar module. /// How long an autohide thumb stays fully visible before its fade begins.
pub use crate::scroll_area::ScrollbarVisibility; 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);
/// The shared autohide timeline for an overlay scrollbar.
#[derive(Clone)]
struct Autohide {
view: EntityId,
state: Entity<AutohideState>,
}
struct AutohideState {
last_activity: Option<Instant>,
hide_task: Option<Task<()>>,
}
impl Autohide {
fn new(key: ElementId, window: &mut Window, cx: &mut App) -> Self {
let view = window.current_view();
let state = window.use_keyed_state(key, cx, |_, _| AutohideState {
last_activity: None,
hide_task: None,
});
Self { view, state }
}
/// Marks interaction.
fn touch(&self, window: &mut Window, cx: &mut App) {
let view = self.view;
let reduce_motion = cx.theme().accessibility.reduce_motion;
let task = window.spawn(cx, async move |cx| {
if reduce_motion {
cx.background_executor()
.timer(AUTOHIDE_DELAY + AUTOHIDE_FADE)
.await;
cx.update(|_, cx| cx.notify(view)).ok();
return;
}
let steps = (AUTOHIDE_FADE.as_millis() / AUTOHIDE_FADE_STEP.as_millis()).max(1);
cx.background_executor().timer(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)).ok();
});
self.state.update(cx, |state, _| {
state.last_activity = Some(Instant::now());
state.hide_task = Some(task);
});
}
/// The thumb's opacity.
fn alpha(&self, cx: &App, dragging: bool) -> f32 {
if dragging {
return 1.0;
}
let Some(activity) = self.state.read(cx).last_activity else {
return 0.0;
};
let idle = activity.elapsed();
if idle <= AUTOHIDE_DELAY {
return 1.0;
}
let fade = idle.saturating_sub(AUTOHIDE_DELAY);
if fade >= AUTOHIDE_FADE {
return 0.0;
}
1.0 - fade.as_secs_f32() / AUTOHIDE_FADE.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. /// The scroll state a scrollbar drives, implemented for gpui's scroll handles.
pub trait ScrollbarHandle: 'static { pub trait ScrollbarHandle: 'static {
@@ -128,33 +251,35 @@ pub struct Scrollbar {
axis: Axis, axis: Axis,
handle: Rc<dyn ScrollbarHandle>, handle: Rc<dyn ScrollbarHandle>,
visibility: ScrollbarVisibility, visibility: ScrollbarVisibility,
key: ElementId, id: ElementId,
} }
impl Scrollbar { impl Scrollbar {
/// A vertical scrollbar driven by `handle`. /// A vertical scrollbar named by `id`, driven by `handle`.
pub fn vertical<H>(handle: H) -> Self pub fn vertical<I, H>(id: I, handle: H) -> Self
where where
I: Into<ElementId>,
H: ScrollbarHandle, H: ScrollbarHandle,
{ {
Self { Self {
axis: Axis::Vertical, axis: Axis::Vertical,
handle: Rc::new(handle), handle: Rc::new(handle),
visibility: ScrollbarVisibility::default(), visibility: ScrollbarVisibility::default(),
key: ElementId::Name("scrollbar".into()), id: id.into(),
} }
} }
/// A horizontal scrollbar driven by `handle`. /// A horizontal scrollbar named by `id`, driven by `handle`.
pub fn horizontal<H>(handle: H) -> Self pub fn horizontal<I, H>(id: I, handle: H) -> Self
where where
I: Into<ElementId>,
H: ScrollbarHandle, H: ScrollbarHandle,
{ {
Self { Self {
axis: Axis::Horizontal, axis: Axis::Horizontal,
handle: Rc::new(handle), handle: Rc::new(handle),
visibility: ScrollbarVisibility::default(), visibility: ScrollbarVisibility::default(),
key: ElementId::Name("scrollbar".into()), id: id.into(),
} }
} }
@@ -163,12 +288,6 @@ impl Scrollbar {
self.visibility = visibility; self.visibility = visibility;
self self
} }
/// Names this scrollbar's state.
pub fn key(mut self, key: impl Into<ElementId>) -> Self {
self.key = key.into();
self
}
} }
impl RenderOnce for Scrollbar { impl RenderOnce for Scrollbar {
@@ -177,8 +296,8 @@ impl RenderOnce for Scrollbar {
let handle = self.handle; let handle = self.handle;
let axis = self.axis; let axis = self.axis;
let visibility = self.visibility; let visibility = self.visibility;
let key = self.key; let id = self.id;
move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &key, window, cx) move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &id, window, cx)
}) })
.absolute() .absolute()
.inset_0() .inset_0()
@@ -192,7 +311,7 @@ fn paint_scrollbar(
handle: &Rc<dyn ScrollbarHandle>, handle: &Rc<dyn ScrollbarHandle>,
axis: Axis, axis: Axis,
visibility: ScrollbarVisibility, visibility: ScrollbarVisibility,
key: &ElementId, id: &ElementId,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
@@ -217,12 +336,10 @@ fn paint_scrollbar(
ScrollbarVisibility::Always => false, ScrollbarVisibility::Always => false,
ScrollbarVisibility::Autohide => true, ScrollbarVisibility::Autohide => true,
}; };
let autohide_state = let autohide_state =
autohide.then(|| Autohide::new(scrollbar_state_key("autohide", key), window, cx)); autohide.then(|| Autohide::new(scrollbar_state_key("autohide", id), window, cx));
let track_length = viewport_along - inset * 2.0; let track_length = viewport_along - inset * 2.0;
let (thumb_length, thumb_travel) = thumb_geometry( let (thumb_length, thumb_travel) = thumb_geometry(
viewport_along, viewport_along,
viewport_along + max_offset, viewport_along + max_offset,
@@ -267,9 +384,7 @@ fn paint_scrollbar(
} }
}; };
let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| { let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", id), cx, |_, _| Drag::None);
Drag::None
});
let dragging = drag.read(cx).is_some(); let dragging = drag.read(cx).is_some();
let alpha = match &autohide_state { let alpha = match &autohide_state {
+25 -19
View File
@@ -9,7 +9,7 @@ use gpui::{
}; };
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_utils::{adjacent_selectable, focus_handle, focus_ring}; use gpui_native_utils::{focus_handle, focus_ring};
use crate::common::{Sizable, StyledExt, h_flex}; use crate::common::{Sizable, StyledExt, h_flex};
@@ -64,7 +64,8 @@ where
T: Clone + PartialEq + 'static, T: Clone + PartialEq + 'static,
{ {
id: ElementId, id: ElementId,
options: Vec<SegmentedOption<T>>, /// Shared with the per-segment and key handlers, so captures are bumps.
options: Rc<[SegmentedOption<T>]>,
selected: T, selected: T,
a11y_label: Option<SharedString>, a11y_label: Option<SharedString>,
size: Size, size: Size,
@@ -82,7 +83,7 @@ where
{ {
Self { Self {
id: id.into(), id: id.into(),
options: Vec::new(), options: Rc::from([]),
selected, selected,
a11y_label: None, a11y_label: None,
size: Size::Small, size: Size::Small,
@@ -163,12 +164,12 @@ where
.options .options
.iter() .iter()
.position(|segment| segment.value == self.selected); .position(|segment| segment.value == self.selected);
let option_count = self.options.len();
let selectable: Vec<usize> = (0..self.options.len()).collect();
let on_change = self.on_change.clone(); let on_change = self.on_change.clone();
let key_on_change = on_change.clone(); let key_on_change = on_change.clone();
let key_options = self.options.clone(); let key_options = self.options.clone();
let key_selectable = selectable.clone(); let options = self.options.clone();
let label = self let label = self
.a11y_label .a11y_label
@@ -176,14 +177,13 @@ where
.unwrap_or_else(|| SharedString::from(self.id.to_string())); .unwrap_or_else(|| SharedString::from(self.id.to_string()));
window.with_element_namespace(self.id.clone(), |window| { window.with_element_namespace(self.id.clone(), |window| {
let focus = focus_handle(&self.id, window, cx);
let geometry: Entity<Vec<Option<Bounds<gpui::Pixels>>>> = let geometry: Entity<Vec<Option<Bounds<gpui::Pixels>>>> =
window.use_keyed_state("segmented-geometry", cx, |_, _| Vec::new()); window.use_keyed_state("segmented-geometry", cx, |_, _| Vec::new());
let track_origin: Entity<Option<Point<gpui::Pixels>>> = let track_origin: Entity<Option<Point<gpui::Pixels>>> =
window.use_keyed_state("segmented-track-origin", cx, |_, _| None); 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 anim_config = SpringConfig::new(500., 35., 1.);
let track_point = *track_origin.read(cx); let track_point = *track_origin.read(cx);
@@ -197,12 +197,13 @@ where
let segments = self let segments = self
.options .options
.into_iter() .iter()
.enumerate() .enumerate()
.map(move |(index, segment)| { .map(move |(index, segment)| {
let is_selected = Some(index) == selected_index; let is_selected = Some(index) == selected_index;
let on_change = on_change.clone(); let on_change = on_change.clone();
let value = segment.value; let options = options.clone();
let content_color = if is_selected { let content_color = if is_selected {
foreground foreground
} else { } else {
@@ -228,7 +229,7 @@ where
.when_some(segment.icon, |segment, role| { .when_some(segment.icon, |segment, role| {
segment.child(icon(role).size(icon_size).color(content_color)) segment.child(icon(role).size(icon_size).color(content_color))
}) })
.child(segment.label) .child(segment.label.clone())
.child( .child(
canvas( canvas(
{ {
@@ -251,8 +252,9 @@ where
move |_, window, cx| { move |_, window, cx| {
if Some(index) != selected_index if Some(index) != selected_index
&& let Some(on_change) = &on_change && let Some(on_change) = &on_change
&& let Some(option) = options.get(index)
{ {
on_change(value.clone(), window, cx); on_change(option.value.clone(), window, cx);
} }
} }
}) })
@@ -319,22 +321,26 @@ where
.on_key_down({ .on_key_down({
move |event: &KeyDownEvent, window, cx| { move |event: &KeyDownEvent, window, cx| {
let target = match event.keystroke.key.as_str() { let target = match event.keystroke.key.as_str() {
"right" => { "right" => match selected_index {
adjacent_selectable(&key_selectable, selected_index, true) Some(index) => {
index.checked_add(1).filter(|next| *next < option_count)
} }
"left" => { None => (option_count > 0).then_some(0),
adjacent_selectable(&key_selectable, selected_index, false) },
} "left" => selected_index.and_then(|index| index.checked_sub(1)),
"home" => key_selectable.first().copied(), "home" => (option_count > 0).then_some(0),
"end" => key_selectable.last().copied(), "end" => option_count.checked_sub(1),
_ => return, _ => return,
}; };
if let Some(next) = target if let Some(next) = target
&& Some(next) != selected_index && Some(next) != selected_index
&& let Some(on_change) = &key_on_change && let Some(on_change) = &key_on_change
&& let Some(option) = key_options.get(next)
{ {
on_change(key_options[next].value.clone(), window, cx); on_change(option.value.clone(), window, cx);
} }
cx.stop_propagation(); cx.stop_propagation();
} }
}) })
+9 -15
View File
@@ -206,24 +206,18 @@ where
.position(|option| &option.value == value) .position(|option| &option.value == value)
}); });
let trigger_options = self.options.clone(); // The trigger's text is fixed at render time; only it moves into the closure.
let trigger_selected = self.selected.clone(); let trigger_text = selected_index
let trigger_placeholder = self.placeholder.clone(); .and_then(|index| self.options.get(index))
.map(|option| option.label.clone())
.unwrap_or_else(|| self.placeholder.clone());
let trigger_has_value = self.selected.is_some();
let trigger_id = self.id.clone(); let trigger_id = self.id.clone();
let trigger_label = label.clone(); let trigger_label = label.clone();
let trigger_style = self.style; let trigger_style = self.style;
let trigger = move |_window: &mut Window, _cx: &mut App| { let trigger = move |_window: &mut Window, _cx: &mut App| {
let text = trigger_selected
.as_ref()
.and_then(|value| {
trigger_options
.iter()
.find(|option| &option.value == value)
.map(|option| option.label.clone())
})
.unwrap_or_else(|| trigger_placeholder.clone());
h_flex() h_flex()
.id((trigger_id.clone(), "select-trigger")) .id((trigger_id.clone(), "select-trigger"))
.debug_selector(|| "select-trigger".to_string()) .debug_selector(|| "select-trigger".to_string())
@@ -240,7 +234,7 @@ where
.border_color(rest_border) .border_color(rest_border)
.bg(input) .bg(input)
.text_size(metrics.text_size) .text_size(metrics.text_size)
.text_color(if trigger_selected.is_some() { .text_color(if trigger_has_value {
foreground foreground
} else { } else {
muted_foreground muted_foreground
@@ -249,7 +243,7 @@ where
trigger.shadow(focus_ring(ring)) trigger.shadow(focus_ring(ring))
}) })
.hover(|style| style.bg(muted).border_color(hover_border)) .hover(|style| style.bg(muted).border_color(hover_border))
.child(text) .child(trigger_text.clone())
.child( .child(
icon(IconRole::ChevronDown) icon(IconRole::ChevronDown)
.size(icon_size) .size(icon_size)
+4 -4
View File
@@ -3,8 +3,8 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{App, InteractiveElement as _, IntoElement, RenderOnce, Styled as _, Window, div, px}; use gpui::{App, InteractiveElement as _, IntoElement, RenderOnce, Styled as _, Window, div};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH};
/// Which axis the rule runs along. /// Which axis the rule runs along.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -69,8 +69,8 @@ impl RenderOnce for Separator {
rule.debug_selector(move || selector()) rule.debug_selector(move || selector())
}) })
.map(|rule| match self.orientation { .map(|rule| match self.orientation {
SeparatorOrientation::Horizontal => rule.w_full().h(px(1.)), SeparatorOrientation::Horizontal => rule.w_full().h(BORDER_WIDTH),
SeparatorOrientation::Vertical => rule.self_stretch().w(px(1.)), SeparatorOrientation::Vertical => rule.self_stretch().w(BORDER_WIDTH),
}) })
} }
} }
+3 -13
View File
@@ -79,12 +79,6 @@ impl Slider {
self self
} }
/// Sets the current value; the slider never owns it.
pub fn value(mut self, value: f32) -> Self {
self.value = value;
self
}
/// Renders the slider inert, unfocusable, and dimmed; disabled always wins. /// Renders the slider inert, unfocusable, and dimmed; disabled always wins.
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled; self.disabled = disabled;
@@ -135,16 +129,12 @@ impl RenderOnce for Slider {
0.0 0.0
}; };
// The track bounds, recorded by the overlay canvas each frame,
// turn pointer positions into values inside the listeners.
let bounds_cell = Rc::new(Cell::new(None::<Bounds<Pixels>>)); let bounds_cell = Rc::new(Cell::new(None::<Bounds<Pixels>>));
// Dragging crosses frames, so it lives in keyed element state,
// a per-render flag would reset when the owner's update repaints.
let dragging = window.use_keyed_state("slider-dragging", cx, |_, _| false);
// A held handle gives the arrow-key listener something to check focus against,
// and the click-to-focus behavior a target.
let focus_handle = gpui_native_utils::focus_handle(&self.id, window, cx); let focus_handle = gpui_native_utils::focus_handle(&self.id, window, cx);
let dragging =
window.use_keyed_state((self.id.clone(), "slider-dragging"), cx, |_, _| false);
let set_value: ValueSetter = { let set_value: ValueSetter = {
let bounds_cell = bounds_cell.clone(); let bounds_cell = bounds_cell.clone();
let on_change = self.on_change.clone(); let on_change = self.on_change.clone();
+1 -4
View File
@@ -133,10 +133,7 @@ impl RenderOnce for Switch {
div() div()
.flex_none() .flex_none()
.relative() .relative()
.debug_selector({ .debug_selector(move || format!("{id}-track"))
let name = format!("{id}-track");
move || name
})
.w(track_width) .w(track_width)
.h(track_height) .h(track_height)
.rounded(theme.radius.full) .rounded(theme.radius.full)
+1
View File
@@ -275,6 +275,7 @@ fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful
div() div()
.id(("toast", id)) .id(("toast", id))
.debug_selector(|| "toast-pill".to_string())
.max_w(px(480.)) .max_w(px(480.))
.flex() .flex()
.items_center() .items_center()
+12 -10
View File
@@ -1,9 +1,9 @@
use gpui::AppContext as _; use gpui::AppContext as _;
use gpui::{ use gpui::{
AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, AnyView, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point, px, ParentElement as _, Pixels, Render, SharedString, Styled as _, Window, canvas, div, point,
}; };
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, TOOLTIP_MAX_WIDTH, TOOLTIP_OFFSET};
use gpui_native_utils::{anchor_bounds, record_anchor_bounds}; use gpui_native_utils::{anchor_bounds, record_anchor_bounds};
const PANEL_BOUNDS_ID: &str = "tooltip-panel-bounds"; const PANEL_BOUNDS_ID: &str = "tooltip-panel-bounds";
@@ -34,8 +34,8 @@ impl Tooltip {
{ {
let text = text.into(); let text = text.into();
let anchor_id = anchor_id.into(); let anchor_id = anchor_id.into();
move |_window, cx| { move |window, cx| {
let anchor = anchor_bounds(&anchor_id, cx); let anchor = anchor_bounds(window.window_handle().window_id(), &anchor_id, cx);
cx.new(|_| Self { cx.new(|_| Self {
text: text.clone(), text: text.clone(),
anchor, anchor,
@@ -76,7 +76,7 @@ impl Render for Tooltip {
.debug_selector(|| "tooltip-panel".to_string()) .debug_selector(|| "tooltip-panel".to_string())
.px_2() .px_2()
.py_1() .py_1()
.max_w(px(360.)) .max_w(TOOLTIP_MAX_WIDTH)
.bg(surface) .bg(surface)
.border_1() .border_1()
.border_color(border) .border_color(border)
@@ -89,14 +89,16 @@ impl Render for Tooltip {
let (wrapper, panel) = match self.anchor { let (wrapper, panel) = match self.anchor {
Some(parent) => { Some(parent) => {
let origin = window.mouse_position() + point(px(1.), px(1.)); let origin = window.mouse_position() + point(TOOLTIP_OFFSET, TOOLTIP_OFFSET);
let viewport_height = window.viewport_size().height; let viewport_height = window.viewport_size().height;
let height = anchor_bounds(&ElementId::from(PANEL_BOUNDS_ID), cx) let height = anchor_bounds(
window.window_handle().window_id(),
&ElementId::from(PANEL_BOUNDS_ID),
cx,
)
.map(|bounds| bounds.size.height) .map(|bounds| bounds.size.height)
.unwrap_or_else(|| { .unwrap_or_else(|| text_size * 1.5 + window.rem_size() * 0.5 + BORDER_WIDTH * 2.0);
text_size * 1.5 + window.rem_size() * 0.5 + BORDER_WIDTH * 2.0
});
let below = viewport_height - spacing_unit - parent.bottom(); let below = viewport_height - spacing_unit - parent.bottom();
let above = parent.top() - spacing_unit; let above = parent.top() - spacing_unit;
+133
View File
@@ -322,3 +322,136 @@ fn disabled_trigger_ignores_clicks() {
assert!(changes.borrow().is_empty()); assert!(changes.borrow().is_empty());
assert!(visual.debug_bounds("menu-panel").is_none()); assert!(visual.debug_bounds("menu-panel").is_none());
} }
struct PairHarness {
open_a: bool,
open_b: bool,
changes_a: Changes,
changes_b: Changes,
activated_a: Activated,
activated_b: Activated,
}
impl Render for PairHarness {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (changes_a, changes_b) = (self.changes_a.clone(), self.changes_b.clone());
let (activated_a, activated_b) = (self.activated_a.clone(), self.activated_b.clone());
let menu_a = DropdownMenu::new("pair-a")
.label("Alpha")
.variant(ButtonVariant::Secondary)
.open(self.open_a)
.on_open_change({
let view = cx.entity();
let changes = changes_a.clone();
move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open_a = next;
cx.notify();
});
}
})
.items(move |_, _| {
vec![
MenuItem::new("png").handler({
let activated = activated_a.clone();
move |_, _| activated.borrow_mut().push("png")
}),
MenuItem::new("pdf").handler({
let activated = activated_a.clone();
move |_, _| activated.borrow_mut().push("pdf")
}),
]
});
let menu_b = DropdownMenu::new("pair-b")
.label("Beta")
.variant(ButtonVariant::Secondary)
.open(self.open_b)
.on_open_change({
let view = cx.entity();
let changes = changes_b.clone();
move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.open_b = next;
cx.notify();
});
}
})
.items(move |_, _| {
vec![
MenuItem::new("zip").handler({
let activated = activated_b.clone();
move |_, _| activated.borrow_mut().push("zip")
}),
MenuItem::new("7z").handler({
let activated = activated_b.clone();
move |_, _| activated.borrow_mut().push("7z")
}),
]
});
div().size_full().flex().gap_2().child(menu_a).child(menu_b)
}
}
fn pair_window() -> (
VisualTestContext,
AnyWindowHandle,
Changes,
Changes,
Activated,
Activated,
) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let changes_a = Rc::new(RefCell::new(Vec::new()));
let changes_b = Rc::new(RefCell::new(Vec::new()));
let activated_a = Rc::new(RefCell::new(Vec::new()));
let activated_b = Rc::new(RefCell::new(Vec::new()));
let (changes_a_for_window, changes_b_for_window) = (changes_a.clone(), changes_b.clone());
let (activated_a_for_window, activated_b_for_window) =
(activated_a.clone(), activated_b.clone());
let window = cx.add_window(move |_, _| PairHarness {
open_a: false,
open_b: false,
changes_a: changes_a_for_window,
changes_b: changes_b_for_window,
activated_a: activated_a_for_window,
activated_b: activated_b_for_window,
});
let window = window.into();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
(
VisualTestContext::from_window(window, &cx),
window,
changes_a,
changes_b,
activated_a,
activated_b,
)
}
/// With a closed sibling dropdown on screen, a pointer-opened menu must still
/// arm rows with the keyboard: the menu's open/selection state is per
/// instance, not shared keyed state.
#[test]
fn sibling_dropdowns_keep_independent_menu_state() {
let (mut visual, window, changes_a, changes_b, activated_a, activated_b) = pair_window();
// Menu A sits at the window's top-left; menu B stays closed beside it.
click(&mut visual, point(px(30.), px(14.)));
assert_eq!(*changes_a.borrow(), vec![true]);
draw(&mut visual, window);
visual.simulate_keystrokes("down enter");
assert_eq!(*activated_a.borrow(), vec!["png"]);
assert_eq!(*changes_a.borrow(), vec![true, false]);
assert!(changes_b.borrow().is_empty(), "B must stay closed");
assert!(activated_b.borrow().is_empty());
}
+37 -8
View File
@@ -1,3 +1,5 @@
use std::{cell::Cell, rc::Rc, time::Duration};
use gpui::{ use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div, Styled as _, TestAppContext, VisualTestContext, Window, div,
@@ -9,10 +11,12 @@ use gpui_native_core::input::{Input, InputState};
struct FieldHost { struct FieldHost {
url: Entity<InputState>, url: Entity<InputState>,
renders: Rc<Cell<usize>>,
} }
impl Render for FieldHost { impl Render for FieldHost {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.renders.set(self.renders.get() + 1);
let url_error = self let url_error = self
.url .url
.read(cx) .read(cx)
@@ -34,15 +38,27 @@ impl Render for FieldHost {
/// A field page in a real window; the url input is focused unless /// A field page in a real window; the url input is focused unless
/// `focus` is false, which leaves the page truly idle. /// `focus` is false, which leaves the page truly idle.
fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestContext) { fn field_host(
focus: bool,
) -> (
TestAppContext,
Entity<InputState>,
VisualTestContext,
Rc<Cell<usize>>,
) {
let cx = TestAppContext::single(); let cx = TestAppContext::single();
cx.update(init); cx.update(init);
let renders = Rc::new(Cell::new(0));
let renders_for_host = renders.clone();
let (url, window) = cx.update(|cx| { let (url, window) = cx.update(|cx| {
let url = InputState::new(cx); let url = InputState::new(cx);
let url_for_host = url.clone(); let url_for_host = url.clone();
let window = cx let window = cx
.open_window(gpui::WindowOptions::default(), |_, cx| { .open_window(gpui::WindowOptions::default(), |_, cx| {
cx.new(|_| FieldHost { url: url_for_host }) cx.new(|_| FieldHost {
url: url_for_host,
renders: renders_for_host.clone(),
})
}) })
.unwrap(); .unwrap();
(url, window) (url, window)
@@ -55,12 +71,12 @@ fn field_host(focus: bool) -> (TestAppContext, Entity<InputState>, VisualTestCon
} }
window.draw(cx).clear(cx); window.draw(cx).clear(cx);
}); });
(cx, url, visual) (cx, url, visual, renders)
} }
#[test] #[test]
fn an_idle_field_page_schedules_no_frames() { fn an_idle_field_page_schedules_no_frames() {
let (_cx, _url, mut visual) = field_host(false); let (_cx, _url, mut visual, _renders) = field_host(false);
let frames = visual.update(|window, cx| window.simulate_next_frame(cx)); let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!( assert_eq!(
frames, 0, frames, 0,
@@ -68,12 +84,25 @@ fn an_idle_field_page_schedules_no_frames() {
); );
} }
/// The caret blink is fps-capped: it must never demand a next frame, and its
/// redraws arrive on the executor timer at the blink cadence.
#[test] #[test]
fn a_focused_field_page_schedules_frames_for_its_blink() { fn a_focused_field_blink_is_fps_capped_and_still_redraws() {
let (_cx, _url, mut visual) = field_host(true); let (cx, _url, mut visual, renders) = field_host(true);
let renders_after_focus = renders.get();
let frames = visual.update(|window, cx| window.simulate_next_frame(cx)); let frames = visual.update(|window, cx| window.simulate_next_frame(cx));
assert_eq!(
frames, 0,
"the blink must be fps-capped, not per-frame scheduled"
);
// The blink redraws through the executor timer (4 fps cap).
cx.executor().advance_clock(Duration::from_millis(251));
cx.run_until_parked();
visual.update(|window, cx| window.draw(cx).clear(cx));
assert!( assert!(
frames > 0, renders.get() > renders_after_focus,
"the focused caret's blink needs frames to redraw at the blink cadence" "the capped blink must still redraw its page"
); );
} }
+98 -2
View File
@@ -38,7 +38,7 @@ impl Render for UniformShell {
.h_full() .h_full()
.w_full(), .w_full(),
) )
.child(Scrollbar::vertical(self.handle.clone())), .child(Scrollbar::vertical("rows", self.handle.clone())),
) )
} }
} }
@@ -64,7 +64,7 @@ impl Render for ListShell {
.h_full() .h_full()
.w_full(), .w_full(),
) )
.child(Scrollbar::vertical(self.state.clone())), .child(Scrollbar::vertical("rows", self.state.clone())),
) )
} }
} }
@@ -271,3 +271,99 @@ fn dragging_the_thumb_drives_a_list_state_and_reports_the_drag() {
"the drag ends with the mouse" "the drag ends with the mouse"
); );
} }
struct TwoShell {
left: UniformListScrollHandle,
right: UniformListScrollHandle,
}
impl Render for TwoShell {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.flex_row()
.child(
div()
.relative()
.h(px(VIEWPORT_HEIGHT))
.w(px(300.))
.child(
uniform_list("left-rows", ROWS, |range, _, _| {
range
.map(|ix| div().h(px(ROW_HEIGHT)).child(format!("row {ix}")))
.collect()
})
.track_scroll(&self.left)
.h_full()
.w_full(),
)
.child(Scrollbar::vertical("left", self.left.clone())),
)
.child(
div()
.relative()
.h(px(VIEWPORT_HEIGHT))
.w(px(300.))
.child(
uniform_list("right-rows", ROWS, |range, _, _| {
range
.map(|ix| div().h(px(ROW_HEIGHT)).child(format!("row {ix}")))
.collect()
})
.track_scroll(&self.right)
.h_full()
.w_full(),
)
.child(Scrollbar::vertical("right", self.right.clone())),
)
}
}
fn two_shell() -> (
TestAppContext,
VisualTestContext,
UniformListScrollHandle,
UniformListScrollHandle,
) {
let mut cx = TestAppContext::single();
cx.update(init);
let (left, right) = (
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
);
let (left_for_view, right_for_view) = (left.clone(), right.clone());
let window = cx.add_window(move |_, _| TwoShell {
left: left_for_view,
right: right_for_view,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, visual, left, right)
}
/// Dragging one scrollbar's thumb must never move a sibling scrollbar: the
/// drag state is keyed per scrollbar id, not shared across instances.
#[test]
fn sibling_scrollbars_drag_independently() {
let (cx, mut visual, left, right) = two_shell();
let metrics = Metrics::measure(&cx, &left);
assert!(metrics.max_offset > px(0.));
press(&mut visual, metrics.thumb_center());
drag_to(&mut visual, metrics.full_drag_target());
release(&mut visual, metrics.full_drag_target());
let offset = left.offset().y;
assert!(
(offset.abs() - metrics.max_offset).abs() < px(0.5),
"the left drag reaches the end, got {offset}"
);
assert_eq!(
right.offset().y,
px(0.),
"the left drag leaked into the right scrollbar"
);
}
+129
View File
@@ -153,3 +153,132 @@ fn arrow_keys_step_the_value_while_focused() {
assert_eq!(*changes.borrow(), vec![0.0, 10.0, 20.0, 10.0]); assert_eq!(*changes.borrow(), vec![0.0, 10.0, 20.0, 10.0]);
} }
struct TwoSliders {
changes_a: Rc<RefCell<Vec<f32>>>,
changes_b: Rc<RefCell<Vec<f32>>>,
value_a: f32,
value_b: f32,
}
impl Render for TwoSliders {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (changes_a, changes_b) = (self.changes_a.clone(), self.changes_b.clone());
div()
.size_full()
.child(
div().absolute().top_0().left_0().w(px(200.)).child(
Slider::new("slider-a", self.value_a)
.min(0.0)
.max(100.0)
.step(10.0)
.on_change({
let view = cx.entity();
let changes_a = changes_a.clone();
move |next, _, cx| {
changes_a.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.value_a = next;
cx.notify();
});
}
}),
),
)
.child(
div().absolute().top(px(40.)).left_0().w(px(200.)).child(
Slider::new("slider-b", self.value_b)
.min(0.0)
.max(100.0)
.step(10.0)
.on_change({
let view = cx.entity();
move |next, _, cx| {
changes_b.borrow_mut().push(next);
view.update(cx, |harness, cx| {
harness.value_b = next;
cx.notify();
});
}
}),
),
)
}
}
#[allow(clippy::type_complexity)]
fn two_slider_window() -> (
VisualTestContext,
Rc<RefCell<Vec<f32>>>,
Rc<RefCell<Vec<f32>>>,
) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let changes_a = Rc::new(RefCell::new(Vec::new()));
let changes_b = Rc::new(RefCell::new(Vec::new()));
let (changes_a_for_window, changes_b_for_window) = (changes_a.clone(), changes_b.clone());
let window = cx.add_window(move |_, _| TwoSliders {
changes_a: changes_a_for_window,
changes_b: changes_b_for_window,
value_a: 0.0,
value_b: 0.0,
});
let window = window.into();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
(
VisualTestContext::from_window(window, &cx),
changes_a,
changes_b,
)
}
/// Dragging one slider must never drive a sibling slider: the drag flag is
/// per instance, not shared keyed state.
#[test]
fn two_sliders_drag_independently() {
let (mut cx, changes_a, changes_b) = two_slider_window();
// Slider A occupies y 0..16, slider B starts at y 40.
cx.simulate_mouse_down(
point(px(100.), px(10.)),
MouseButton::Left,
Modifiers::default(),
);
cx.simulate_mouse_move(
point(px(120.), px(10.)),
MouseButton::Left,
Modifiers::default(),
);
cx.simulate_mouse_up(
point(px(120.), px(10.)),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(*changes_a.borrow(), vec![50.0, 60.0]);
assert!(changes_b.borrow().is_empty(), "A's drag leaked into B");
cx.simulate_mouse_down(
point(px(100.), px(50.)),
MouseButton::Left,
Modifiers::default(),
);
cx.simulate_mouse_move(
point(px(40.), px(50.)),
MouseButton::Left,
Modifiers::default(),
);
cx.simulate_mouse_up(
point(px(40.), px(50.)),
MouseButton::Left,
Modifiers::default(),
);
assert_eq!(*changes_b.borrow(), vec![50.0, 20.0]);
assert_eq!(
*changes_a.borrow(),
vec![50.0, 60.0],
"B's drag leaked into A"
);
}
+24 -8
View File
@@ -195,19 +195,33 @@ impl RenderOnce for Sidebar {
.clone() .clone()
.unwrap_or_else(|| SharedString::from(self.id.to_string())); .unwrap_or_else(|| SharedString::from(self.id.to_string()));
let id_text = self.id.to_string();
let scroll_area_id = (self.id.clone(), "entries"); let scroll_area_id = (self.id.clone(), "entries");
// Entry paint reads the theme through this borrow. let row_count = entries
.iter()
.filter(|entry| matches!(entry, SidebarEntry::Row { .. }))
.count();
let mut row_position = 0usize;
let children: Vec<AnyElement> = entries let children: Vec<AnyElement> = entries
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, entry)| { .map(|(index, entry)| {
let is_row = matches!(entry, SidebarEntry::Row { .. });
if is_row {
row_position += 1;
}
let (position_in_set, size_of_set) = if is_row {
(row_position, row_count)
} else {
(0, 0)
};
render_entry( render_entry(
cx, cx,
entry, entry,
index, index,
entries.len(), position_in_set,
size_of_set,
self.selected == Some(index), self.selected == Some(index),
material, material,
&self.on_change, &self.on_change,
@@ -216,9 +230,10 @@ impl RenderOnce for Sidebar {
}) })
.collect(); .collect();
let id = self.id;
div() div()
.id(self.id) .id(id.clone())
.debug_selector(move || format!("sidebar-{id_text}")) .debug_selector(move || format!("sidebar-{id}"))
.size_full() .size_full()
.flex() .flex()
.flex_col() .flex_col()
@@ -258,7 +273,8 @@ fn render_entry(
cx: &App, cx: &App,
entry: &SidebarEntry, entry: &SidebarEntry,
index: usize, index: usize,
count: usize, position_in_set: usize,
size_of_set: usize,
selected: bool, selected: bool,
material: bool, material: bool,
on_change: &Option<ChangeHandler>, on_change: &Option<ChangeHandler>,
@@ -294,8 +310,8 @@ fn render_entry(
.rounded(theme.radius.sm) .rounded(theme.radius.sm)
.role(Role::ListItem) .role(Role::ListItem)
.aria_selected(selected) .aria_selected(selected)
.aria_position_in_set(index + 1) .aria_position_in_set(position_in_set)
.aria_size_of_set(count) .aria_size_of_set(size_of_set)
.map(|row| { .map(|row| {
if material && !*row_disabled { if material && !*row_disabled {
if selected { if selected {
+16 -11
View File
@@ -32,6 +32,8 @@ pub enum TabsVariant {
/// One tab of a [`Tabs`] strip. /// One tab of a [`Tabs`] strip.
pub struct Tab { pub struct Tab {
/// The stable identity for element state; must survive reordering.
key: ElementId,
label: SharedString, label: SharedString,
icon: Option<IconRole>, icon: Option<IconRole>,
lead: Option<AnyElement>, lead: Option<AnyElement>,
@@ -42,12 +44,14 @@ pub struct Tab {
} }
impl Tab { impl Tab {
/// Creates a tab with this label. /// Creates a tab shown as `label`, identified by `key`.
pub fn new<L>(label: L) -> Self pub fn new<K, L>(key: K, label: L) -> Self
where where
K: Into<ElementId>,
L: Into<SharedString>, L: Into<SharedString>,
{ {
Self { Self {
key: key.into(),
label: label.into(), label: label.into(),
icon: None, icon: None,
lead: None, lead: None,
@@ -210,7 +214,6 @@ impl RenderOnce for Tabs {
cx, cx,
tab, tab,
index, index,
self.selected == index,
self.selected, self.selected,
self.variant, self.variant,
&self.on_change, &self.on_change,
@@ -268,7 +271,6 @@ fn render_tab(
cx: &App, cx: &App,
tab: Tab, tab: Tab,
index: usize, index: usize,
selected: bool,
selected_index: usize, selected_index: usize,
variant: TabsVariant, variant: TabsVariant,
on_change: &Option<ChangeHandler>, on_change: &Option<ChangeHandler>,
@@ -276,6 +278,7 @@ fn render_tab(
focus: &gpui::FocusHandle, focus: &gpui::FocusHandle,
) -> AnyElement { ) -> AnyElement {
let mut tab = tab; let mut tab = tab;
let selected = index == selected_index;
let theme = cx.theme(); let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default()); let metrics = theme.sizes.resolve(Size::default());
@@ -301,10 +304,11 @@ fn render_tab(
}; };
let mut strip_tab = div() let mut strip_tab = div()
.id(("tab", index)) .id((tab.key.clone(), "tab"))
.debug_selector({ .debug_selector({
let id = format!("{tabs_id}-tab-{}", tab.label); let tabs_id = tabs_id.clone();
move || id let label = tab.label.clone();
move || format!("{tabs_id}-tab-{}", label)
}) })
.flex() .flex()
.items_center() .items_center()
@@ -364,7 +368,7 @@ fn render_tab(
if tab.busy && !tab.disabled { if tab.busy && !tab.disabled {
strip_tab = strip_tab.child( strip_tab = strip_tab.child(
Spinner::new(("tab-busy", index)) Spinner::new((tab.key.clone(), "tab-busy"))
.small() .small()
.accessibility_label("Loading"), .accessibility_label("Loading"),
); );
@@ -401,14 +405,15 @@ fn render_tab_close(
index: usize, index: usize,
on_close: &Option<CloseHandler>, on_close: &Option<CloseHandler>,
) -> AnyElement { ) -> AnyElement {
Button::new(("tab-close", index)) Button::new((tab.key.clone(), "tab-close"))
.icon(IconRole::Close) .icon(IconRole::Close)
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.small() .small()
.tab_stop(false) .tab_stop(false)
.debug_selector({ .debug_selector({
let id = format!("{tabs_id}-close-{}", tab.label); let tabs_id = tabs_id.clone();
move || id.clone() let label = tab.label.clone();
move || format!("{tabs_id}-close-{}", label)
}) })
.tooltip(Tooltip::text("Close")) .tooltip(Tooltip::text("Close"))
.accessibility_label(SharedString::from(format!("Close {}", tab.label))) .accessibility_label(SharedString::from(format!("Close {}", tab.label)))
+42 -6
View File
@@ -166,14 +166,49 @@ impl RenderOnce for Tree {
.clone() .clone()
.unwrap_or_else(|| SharedString::from(self.id.to_string())); .unwrap_or_else(|| SharedString::from(self.id.to_string()));
let sibling_sets: Vec<(usize, usize)> = {
let mut sets = vec![(0usize, 0usize); items.len()];
let mut start = 0usize;
while start < items.len() {
let depth = items[start].depth;
let mut end = start + 1;
while end < items.len() && items[end].depth > depth {
end += 1;
}
let mut position = 0usize;
for index in start..end {
if items[index].depth == depth {
position += 1;
sets[index] = (position, 0);
}
}
for index in start..end {
if items[index].depth == depth {
sets[index].1 = position;
}
}
start = end;
}
sets
};
let rows: Vec<AnyElement> = items let rows: Vec<AnyElement> = items
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, _item)| { .map(|(index, _item)| {
let (position_in_set, size_of_set) = sibling_sets[index];
render_row( render_row(
cx, cx,
&items, &items[index],
index, index,
position_in_set,
size_of_set,
self.selected == Some(index), self.selected == Some(index),
&self.on_change, &self.on_change,
&self.on_disclosure, &self.on_disclosure,
@@ -228,21 +263,21 @@ impl RenderOnce for Tree {
} }
} }
#[allow(clippy::too_many_arguments)]
fn render_row( fn render_row(
cx: &App, cx: &App,
items: &[TreeItem], item: &TreeItem,
index: usize, index: usize,
position_in_set: usize,
size_of_set: usize,
selected: bool, selected: bool,
on_change: &Option<ChangeHandler>, on_change: &Option<ChangeHandler>,
on_disclosure: &Option<DisclosureHandler>, on_disclosure: &Option<DisclosureHandler>,
focus: &FocusHandle, focus: &FocusHandle,
) -> AnyElement { ) -> AnyElement {
let theme = cx.theme(); let theme = cx.theme();
let item = &items[index];
let count = items.len();
let label_color = selectable_label_color(cx, selected, item.disabled); let label_color = selectable_label_color(cx, selected, item.disabled);
let disabled = item.disabled; let disabled = item.disabled;
// One indent step per depth level, on the theme's horizontal padding. // One indent step per depth level, on the theme's horizontal padding.
let indent = theme.sizes.medium.padding_x * (item.depth as f32 + 1.0); let indent = theme.sizes.medium.padding_x * (item.depth as f32 + 1.0);
@@ -258,7 +293,8 @@ fn render_row(
.role(Role::TreeItem) .role(Role::TreeItem)
.aria_selected(selected) .aria_selected(selected)
.aria_level(item.depth + 1) .aria_level(item.depth + 1)
.aria_size_of_set(count) .aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set)
.when(item.expandable, |row| row.aria_expanded(item.expanded)) .when(item.expandable, |row| row.aria_expanded(item.expanded))
.map(|row| paint_selectable_row(cx, row, selected, disabled)) .map(|row| paint_selectable_row(cx, row, selected, disabled))
.when(!disabled, |row| { .when(!disabled, |row| {
+4 -4
View File
@@ -30,10 +30,10 @@ impl Render for Harness {
}) })
.tabs(|_, _| { .tabs(|_, _| {
vec![ vec![
Tab::new("One"), Tab::new("one", "One"),
Tab::new("Two"), Tab::new("two", "Two"),
Tab::new("Off").disabled(true), Tab::new("off", "Off").disabled(true),
Tab::new("End"), Tab::new("end", "End"),
] ]
}), }),
) )
+17 -46
View File
@@ -11,9 +11,9 @@ use gpui::{
actions, canvas, div, uniform_list, actions, canvas, div, uniform_list,
}; };
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility}; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; use gpui_native_theme::{ActiveTheme as _, ColorTokens, MONO_ADVANCE_RATIO, Ramps};
use crate::highlight::{HighlightedCode, Language, Role, highlight_with_tree}; use crate::highlight::{HighlightedCode, Language, Role, highlight};
use crate::utils::{gutter_cell, run_style}; use crate::utils::{gutter_cell, run_style};
actions!(code_view, [Copy]); actions!(code_view, [Copy]);
@@ -53,8 +53,6 @@ pub struct CodeViewState {
source: SharedString, source: SharedString,
language: Language, language: Language,
code: HighlightedCode, code: HighlightedCode,
/// The parse tree of `source`, so updates reparse incrementally instead of from scratch.
tree: Option<tree_sitter::Tree>,
handle: UniformListScrollHandle, handle: UniformListScrollHandle,
/// The hovered line, driving the active-line wash and number emphasis. /// The hovered line, driving the active-line wash and number emphasis.
active_line: Option<usize>, active_line: Option<usize>,
@@ -74,14 +72,13 @@ impl CodeViewState {
S: Into<SharedString>, S: Into<SharedString>,
{ {
let source = source.into(); let source = source.into();
let (code, tree) = highlight_with_tree(&source, language, None); let code = highlight(&source, language);
Self { Self {
id: id.into(), id: id.into(),
source, source,
language, language,
code, code,
tree,
handle: UniformListScrollHandle::new(), handle: UniformListScrollHandle::new(),
active_line: None, active_line: None,
selection: None, selection: None,
@@ -98,18 +95,9 @@ impl CodeViewState {
{ {
let source = source.into(); let source = source.into();
// The old tree is only reusable when the grammar stays the same. self.code = highlight(&source, language);
let old_tree = if language == self.language {
self.tree.as_ref()
} else {
None
};
let (code, tree) = highlight_with_tree(&source, language, old_tree);
self.source = source; self.source = source;
self.language = language; self.language = language;
self.code = code;
self.tree = tree;
self.selection = None; self.selection = None;
self.handle.scroll_to_item(0, ScrollStrategy::Top); self.handle.scroll_to_item(0, ScrollStrategy::Top);
@@ -134,9 +122,7 @@ impl CodeViewState {
lines.splice(line_ix..line_ix + 1, replacement); lines.splice(line_ix..line_ix + 1, replacement);
self.source = (lines.join("\n") + if trailing_newline { "\n" } else { "" }).into(); self.source = (lines.join("\n") + if trailing_newline { "\n" } else { "" }).into();
let (code, tree) = highlight_with_tree(&self.source, self.language, self.tree.as_ref()); self.code = highlight(&self.source, self.language);
self.code = code;
self.tree = tree;
self.selection = None; self.selection = None;
cx.notify(); cx.notify();
} }
@@ -146,11 +132,6 @@ impl CodeViewState {
&self.source &self.source
} }
/// The language the source highlights as.
pub fn language(&self) -> Language {
self.language
}
/// The highlighted lines. /// The highlighted lines.
pub fn code(&self) -> &HighlightedCode { pub fn code(&self) -> &HighlightedCode {
&self.code &self.code
@@ -223,13 +204,7 @@ impl CodeViewState {
(!selected.is_empty()).then(|| selected.join("\n")) (!selected.is_empty()).then(|| selected.join("\n"))
} }
/// Drops the selection. // Overrides the accessibility label of the view.
pub fn clear_selection(&mut self) {
self.selection = None;
self.selecting = false;
}
/// Overrides the accessibility label of the view.
pub fn accessibility_label<L>(mut self, label: L) -> Self pub fn accessibility_label<L>(mut self, label: L) -> Self
where where
L: Into<SharedString>, L: Into<SharedString>,
@@ -249,8 +224,7 @@ impl CodeViewState {
text_x: Pixels, text_x: Pixels,
) -> SelectionPoint { ) -> SelectionPoint {
let row = (((position.y - bounds.origin.y - scroll_y) / line_height).floor() as isize) let row = (((position.y - bounds.origin.y - scroll_y) / line_height).floor() as isize)
.clamp(0, self.code.lines.len() as isize - 1) .clamp(0, self.code.lines.len() as isize - 1) as usize;
.max(0) as usize;
let chars = self.code.lines[row] let chars = self.code.lines[row]
.spans .spans
@@ -258,9 +232,8 @@ impl CodeViewState {
.map(|span| span.text.chars().count()) .map(|span| span.text.chars().count())
.sum::<usize>(); .sum::<usize>();
let col = (((position.x - text_x) / char_width).round() as isize) let col = (((position.x - text_x) / char_width).round() as isize).clamp(0, chars as isize)
.clamp(0, chars as isize) as usize;
.max(0) as usize;
SelectionPoint { row, col } SelectionPoint { row, col }
} }
@@ -279,7 +252,7 @@ impl Render for CodeViewState {
let mono_font = theme.mono_font; let mono_font = theme.mono_font;
let text_size = theme.sizes.small.text_size; let text_size = theme.sizes.small.text_size;
let line_height = text_size * 1.5; let line_height = text_size * 1.5;
let char_width = text_size * 0.62; let char_width = text_size * MONO_ADVANCE_RATIO;
let root_font_size = theme.root_font_size; let root_font_size = theme.root_font_size;
let id = self.id.clone(); let id = self.id.clone();
@@ -348,7 +321,7 @@ impl Render for CodeViewState {
code.position_at( code.position_at(
*bounds, *bounds,
event.position, event.position,
Pixels::ZERO, ScrollbarHandle::offset(&code.handle).y,
line_height, line_height,
char_width, char_width,
text_x, text_x,
@@ -453,7 +426,8 @@ impl Render for CodeViewState {
) )
.child(rows) .child(rows)
.child( .child(
Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform), Scrollbar::vertical((self.id.clone(), "v-scrollbar"), self.handle.clone())
.visibility(ScrollbarVisibility::Platform),
) )
.when_some(self.a11y_label.clone(), |view, label| { .when_some(self.a11y_label.clone(), |view, label| {
view.aria_label(label) view.aria_label(label)
@@ -475,10 +449,10 @@ fn render_row(
line_height: Pixels, line_height: Pixels,
cx: &App, cx: &App,
) -> gpui::AnyElement { ) -> gpui::AnyElement {
let line = code.code().lines.get(line_ix).cloned().unwrap_or_default(); let line = code.code().lines.get(line_ix);
let mut text = String::new(); let mut text = String::new();
for span in &line.spans { for span in line.map_or(&[][..], |line| line.spans.as_slice()) {
text.push_str(&span.text); text.push_str(&span.text);
} }
@@ -486,7 +460,7 @@ fn render_row(
let mut roles = vec![Role::Text; char_count]; let mut roles = vec![Role::Text; char_count];
let mut offset = 0usize; let mut offset = 0usize;
for span in &line.spans { for span in line.map_or(&[][..], |line| line.spans.as_slice()) {
let chars = span.text.chars().count(); let chars = span.text.chars().count();
for slot in &mut roles[offset..offset + chars] { for slot in &mut roles[offset..offset + chars] {
*slot = span.role; *slot = span.role;
@@ -556,10 +530,7 @@ fn render_row(
}; };
div() div()
.debug_selector({ .debug_selector(move || format!("{id}-row-{line_ix}"))
let name = format!("{id}-row-{line_ix}");
move || name
})
.id(("code-row", line_ix)) .id(("code-row", line_ix))
.w_full() .w_full()
.h(line_height) .h(line_height)
+29 -17
View File
@@ -13,7 +13,7 @@ use gpui::{
point, uniform_list, point, uniform_list,
}; };
use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility}; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility};
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::{ActiveTheme as _, MONO_ADVANCE_RATIO};
use similar::{ChangeTag, TextDiff}; use similar::{ChangeTag, TextDiff};
use std::ops::Range; use std::ops::Range;
@@ -237,6 +237,8 @@ pub fn diff_rows(old: &str, new: &str, language: Language) -> Vec<DiffRow> {
pub struct DiffViewState { pub struct DiffViewState {
id: ElementId, id: ElementId,
rows: Vec<DiffRow>, rows: Vec<DiffRow>,
/// The row whose width drives horizontal scroll extent, `rows`-derived.
widest_row: Option<usize>,
options: DiffViewOptions, options: DiffViewOptions,
/// Drives both scroll axes of the virtualized row list. /// Drives both scroll axes of the virtualized row list.
handle: UniformListScrollHandle, handle: UniformListScrollHandle,
@@ -251,13 +253,32 @@ impl DiffViewState {
O: AsRef<str>, O: AsRef<str>,
N: AsRef<str>, N: AsRef<str>,
{ {
Self { let mut state = Self {
id: id.into(), id: id.into(),
rows: diff_rows(old.as_ref(), new.as_ref(), language), rows: Vec::new(),
widest_row: None,
options: DiffViewOptions::default(), options: DiffViewOptions::default(),
handle: UniformListScrollHandle::new(), handle: UniformListScrollHandle::new(),
a11y_label: None, a11y_label: None,
};
state.set_rows(old, new, language);
state
} }
/// Replaces the rows, recomputing `widest_row` with them.
fn set_rows<O, N>(&mut self, old: O, new: N, language: Language)
where
O: AsRef<str>,
N: AsRef<str>,
{
self.rows = diff_rows(old.as_ref(), new.as_ref(), language);
self.widest_row = self
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.kind != DiffRowKind::Hunk)
.max_by_key(|(_, row)| row.char_count())
.map(|(ix, _)| ix);
} }
/// Sets how changed rows are marked. /// Sets how changed rows are marked.
@@ -284,7 +305,7 @@ impl DiffViewState {
O: AsRef<str>, O: AsRef<str>,
N: AsRef<str>, N: AsRef<str>,
{ {
self.rows = diff_rows(old.as_ref(), new.as_ref(), language); self.set_rows(old, new, language);
self.handle.scroll_to_item(0, ScrollStrategy::Top); self.handle.scroll_to_item(0, ScrollStrategy::Top);
// The replaced versions rarely share the old width; start at the left. // The replaced versions rarely share the old width; start at the left.
let offset = ScrollbarHandle::offset(&self.handle); let offset = ScrollbarHandle::offset(&self.handle);
@@ -328,14 +349,7 @@ impl Render for DiffViewState {
let state = cx.entity(); let state = cx.entity();
let row_count = self.rows.len(); let row_count = self.rows.len();
let options = self.options; let options = self.options;
let widest_row = self.widest_row;
let widest_row = self
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.kind != DiffRowKind::Hunk)
.max_by_key(|(_, row)| row.char_count())
.map(|(ix, _)| ix);
let mut rows = uniform_list((id.clone(), "rows"), row_count, { let mut rows = uniform_list((id.clone(), "rows"), row_count, {
move |range, _window, cx| { move |range, _window, cx| {
@@ -363,13 +377,11 @@ impl Render for DiffViewState {
}) })
.child(rows) .child(rows)
.child( .child(
Scrollbar::vertical(self.handle.clone()) Scrollbar::vertical((self.id.clone(), "v-scrollbar"), self.handle.clone())
.key("v-scrollbar")
.visibility(ScrollbarVisibility::Platform), .visibility(ScrollbarVisibility::Platform),
) )
.child( .child(
Scrollbar::horizontal(self.handle.clone()) Scrollbar::horizontal((self.id.clone(), "h-scrollbar"), self.handle.clone())
.key("h-scrollbar")
.visibility(ScrollbarVisibility::Platform), .visibility(ScrollbarVisibility::Platform),
) )
} }
@@ -387,7 +399,7 @@ fn render_row(
let ramps = theme.ramps; let ramps = theme.ramps;
let text_size = theme.sizes.small.text_size; let text_size = theme.sizes.small.text_size;
let line_height = text_size * 1.5; let line_height = text_size * 1.5;
let char_width = text_size * 0.62; let char_width = text_size * MONO_ADVANCE_RATIO;
let frame = div() let frame = div()
.debug_selector(move || format!("diff-row-{row_ix}")) .debug_selector(move || format!("diff-row-{row_ix}"))
+5 -14
View File
@@ -67,17 +67,8 @@ pub struct HighlightedCode {
/// Highlights `source` with `language`'s tree-sitter grammar. /// Highlights `source` with `language`'s tree-sitter grammar.
pub fn highlight(source: &str, language: Language) -> HighlightedCode { pub fn highlight(source: &str, language: Language) -> HighlightedCode {
highlight_with_tree(source, language, None).0
}
/// Highlights `source`, reusing `old_tree` so only the changed region is re-lexed.
pub fn highlight_with_tree(
source: &str,
language: Language,
old_tree: Option<&tree_sitter::Tree>,
) -> (HighlightedCode, Option<tree_sitter::Tree>) {
let Some((grammar, query_source)) = grammar_for(language) else { let Some((grammar, query_source)) = grammar_for(language) else {
return (plain(source), None); return plain(source);
}; };
HIGHLIGHTERS.with(|cache| { HIGHLIGHTERS.with(|cache| {
@@ -104,11 +95,11 @@ pub fn highlight_with_tree(
.as_mut(); .as_mut();
let Some(highlighter) = highlighter else { let Some(highlighter) = highlighter else {
return (plain(source), None); return plain(source);
}; };
let Some(tree) = highlighter.parser.parse(source, old_tree) else { let Some(tree) = highlighter.parser.parse(source, None) else {
return (plain(source), None); return plain(source);
}; };
// One role per byte; matches arrive in query order // One role per byte; matches arrive in query order
@@ -134,7 +125,7 @@ pub fn highlight_with_tree(
} }
} }
(build_lines(source, &role_at), Some(tree)) build_lines(source, &role_at)
}) })
} }
+21 -44
View File
@@ -18,7 +18,7 @@ use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, T
use std::ops::Range; use std::ops::Range;
use crate::highlight::{HighlightedCode, highlight_with_tree, plain}; use crate::highlight::{HighlightedCode, highlight, plain};
use crate::utils::{code_line, grammar_language}; use crate::utils::{code_line, grammar_language};
/// Heading scale steps relative to the root font size, H1 first. /// Heading scale steps relative to the root font size, H1 first.
@@ -101,8 +101,6 @@ pub enum InlineStyle {
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MarkdownTableCell { pub struct MarkdownTableCell {
pub spans: Vec<InlineSpan>, pub spans: Vec<InlineSpan>,
/// 1-based column position, so renderers can align to the table's columns.
pub column: usize,
} }
/// A table row; the header row precedes the body rows. /// A table row; the header row precedes the body rows.
@@ -277,7 +275,8 @@ impl Render for MarkdownViewState {
.size_full() .size_full()
.child(blocks) .child(blocks)
.child( .child(
Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform), Scrollbar::vertical((self.id.clone(), "v-scrollbar"), self.handle.clone())
.visibility(ScrollbarVisibility::Platform),
) )
.when_some(self.a11y_label.clone(), |view, label| { .when_some(self.a11y_label.clone(), |view, label| {
view.aria_label(label) view.aria_label(label)
@@ -292,24 +291,6 @@ struct ListFrame {
task: Option<bool>, task: Option<bool>,
} }
/// The table being assembled, when the parser is inside one.
enum TableBuilder {
None,
Body { rows: Vec<MarkdownTableRow> },
}
/// One open code block, accumulating its raw text.
#[derive(Default)]
struct CodeBuilder {
text: String,
}
impl CodeBuilder {
fn push(&mut self, text: &str) {
self.text.push_str(text);
}
}
/// Accumulates inline spans across inline events. /// Accumulates inline spans across inline events.
#[derive(Default)] #[derive(Default)]
struct InlineBuilder { struct InlineBuilder {
@@ -402,8 +383,8 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
// Whether each open list item already flushed content rows. // Whether each open list item already flushed content rows.
let mut items: Vec<bool> = Vec::new(); let mut items: Vec<bool> = Vec::new();
let mut quote_depth = 0usize; let mut quote_depth = 0usize;
let mut table = TableBuilder::None; let mut table: Option<Vec<MarkdownTableRow>> = None;
let mut code: Option<CodeBuilder> = None; let mut code: Option<String> = None;
// The fence info string arrives on Start, its text follows. // The fence info string arrives on Start, its text follows.
let mut code_language: Option<SharedString> = None; let mut code_language: Option<SharedString> = None;
@@ -468,7 +449,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
&mut blocks, &mut blocks,
quote_depth, quote_depth,
); );
code = Some(CodeBuilder::default()); code = Some(String::new());
if let CodeBlockKind::Fenced(info) = kind { if let CodeBlockKind::Fenced(info) = kind {
let info = info.trim(); let info = info.trim();
code_language = code_language =
@@ -483,10 +464,10 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
&mut blocks, &mut blocks,
quote_depth, quote_depth,
); );
table = TableBuilder::Body { rows: Vec::new() } table = Some(Vec::new())
} }
Tag::TableHead => { Tag::TableHead => {
if let TableBuilder::Body { rows } = &mut table { if let Some(rows) = &mut table {
rows.push(MarkdownTableRow { rows.push(MarkdownTableRow {
cells: Vec::new(), cells: Vec::new(),
header: true, header: true,
@@ -494,7 +475,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
} }
} }
Tag::TableRow => { Tag::TableRow => {
if let TableBuilder::Body { rows } = &mut table { if let Some(rows) = &mut table {
rows.push(MarkdownTableRow { rows.push(MarkdownTableRow {
cells: Vec::new(), cells: Vec::new(),
header: false, header: false,
@@ -582,13 +563,13 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
} }
TagEnd::CodeBlock => { TagEnd::CodeBlock => {
let built = code.take().unwrap_or_default(); let built = code.take().unwrap_or_default();
let text = built.text.trim_end_matches('\n'); let text = built.trim_end_matches('\n');
let language = code_language.take(); let language = code_language.take();
let lines = match highlight_code let lines = match highlight_code
.then(|| language.as_deref().and_then(grammar_language)) .then(|| language.as_deref().and_then(grammar_language))
.flatten() .flatten()
{ {
Some(grammar) => highlight_with_tree(text, grammar, None).0, Some(grammar) => highlight(text, grammar),
None => plain(text), None => plain(text),
}; };
blocks.push(MarkdownBlock::Code { blocks.push(MarkdownBlock::Code {
@@ -599,8 +580,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
}); });
} }
TagEnd::Table => { TagEnd::Table => {
let built = std::mem::replace(&mut table, TableBuilder::None); if let Some(rows) = table.take() {
if let TableBuilder::Body { rows } = built {
blocks.push(MarkdownBlock::Table { blocks.push(MarkdownBlock::Table {
table: MarkdownTable { rows }, table: MarkdownTable { rows },
indent: lists.len(), indent: lists.len(),
@@ -610,16 +590,13 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
} }
TagEnd::TableCell => { TagEnd::TableCell => {
let spans = inline.finish(); let spans = inline.finish();
let TableBuilder::Body { rows } = &mut table else { let Some(rows) = &mut table else {
continue; continue;
}; };
let Some(row) = rows.last_mut() else { let Some(row) = rows.last_mut() else {
continue; continue;
}; };
row.cells.push(MarkdownTableCell { row.cells.push(MarkdownTableCell { spans });
spans,
column: row.cells.len() + 1,
});
} }
TagEnd::Emphasis TagEnd::Emphasis
| TagEnd::Strong | TagEnd::Strong
@@ -629,8 +606,8 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
_ => {} _ => {}
}, },
Event::Text(text) => { Event::Text(text) => {
if let Some(built) = code.as_mut() { if let Some(buffer) = code.as_mut() {
built.push(&text); buffer.push_str(&text);
} else { } else {
inline.push_text(&text); inline.push_text(&text);
} }
@@ -659,9 +636,9 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
frame.task = Some(checked); frame.task = Some(checked);
} }
} }
Event::FootnoteReference(label) => inline.push_text(&label), // Footnotes are disabled in the parser options.
// HTML and math render nothing in this view. Event::FootnoteReference(_)
Event::InlineHtml(_) | Event::InlineHtml(_)
| Event::Html(_) | Event::Html(_)
| Event::InlineMath(_) | Event::InlineMath(_)
| Event::DisplayMath(_) => {} | Event::DisplayMath(_) => {}
@@ -783,8 +760,8 @@ fn block_frame(
let theme = cx.theme(); let theme = cx.theme();
let content = div() let content = div()
.debug_selector({ .debug_selector({
let name = SharedString::from(format!("{id}")); let id = id.clone();
move || name.to_string() move || id.to_string()
}) })
.w_full() .w_full()
.flex() .flex()
-8
View File
@@ -305,14 +305,6 @@ fn tables_parse_header_first_with_column_positions() {
.map(|cell| spans_text(&cell.spans)) .map(|cell| spans_text(&cell.spans))
.collect(); .collect();
assert_eq!(header, vec!["a", "b"]); assert_eq!(header, vec!["a", "b"]);
assert_eq!(
table.rows[0]
.cells
.iter()
.map(|cell| cell.column)
.collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4"); assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4");
} }
+3 -2
View File
@@ -17,10 +17,9 @@ gpui = { workspace = true, features = ["test-support"] }
[features] [features]
default = ["full"] default = ["full"]
full = ["dock", "resize_handle", "resizable", "shell", "window_controls"] full = ["dock", "resize_handle", "resizable", "shell", "window_controls", "toast", "alert"]
dock = [ dock = [
"resize_handle", "resize_handle",
"resizable",
"dep:gpui-native-core", "dep:gpui-native-core",
"dep:gpui-native-data", "dep:gpui-native-data",
"dep:gpui-native-icons", "dep:gpui-native-icons",
@@ -37,4 +36,6 @@ shell = [
"dep:gpui-native-data", "dep:gpui-native-data",
"dep:gpui-native-icons", "dep:gpui-native-icons",
] ]
toast = ["shell", "gpui-native-core/toast"]
alert = ["shell", "gpui-native-core/alert"]
window_controls = ["dep:gpui-native-icons"] window_controls = ["dep:gpui-native-icons"]
+40 -22
View File
@@ -192,10 +192,8 @@ impl DockState {
} }
/// Registers a panel descriptor, replacing any earlier one with its id. /// Registers a panel descriptor, replacing any earlier one with its id.
pub fn register_panel(&mut self, panel: PanelDescriptor, cx: &mut Context<Self>) { pub fn register_panel(&mut self, panel: PanelDescriptor) {
if self.panels.insert(panel.id.clone(), panel).is_none() { self.panels.insert(panel.id.clone(), panel);
cx.notify();
}
} }
/// Opens a panel into the active pane and activates its tab. /// Opens a panel into the active pane and activates its tab.
@@ -330,6 +328,17 @@ impl DockState {
}; };
if changed { if changed {
// The kept tab may have no right view, show the first remaining one.
self.right_view = self
.locate(id)
.and_then(|(pane_key, _)| self.pane(pane_key))
.and_then(|pane| {
pane.active.as_deref().and_then(|active| {
pane.tabs.iter().find(|tab| tab.panel.as_ref() == active)
})
})
.and_then(|tab| tab.right_view.clone())
.or_else(|| first_right_view(&self.root));
cx.notify(); cx.notify();
} }
} }
@@ -354,10 +363,14 @@ impl DockState {
} }
self.detach_pane(pane_key); self.detach_pane(pane_key);
if self.active_pane == pane_key { if self.active_pane == pane_key {
self.active_pane = self.first_pane_key().unwrap_or(0); self.active_pane = self.first_pane_key().unwrap_or(0);
} }
// The closed pane's views are gone; show the first remaining one.
self.right_view = first_right_view(&self.root);
cx.notify(); cx.notify();
} }
@@ -526,6 +539,19 @@ impl DockState {
} }
} }
fn pane(&self, key: u64) -> Option<&Pane> {
Self::pane_in(&self.root, key)
}
fn pane_in(node: &Node, key: u64) -> Option<&Pane> {
match node {
Node::Pane(pane) => (pane.key == key).then_some(pane),
Node::Split(split) => {
Self::pane_in(&split.a, key).or_else(|| Self::pane_in(&split.b, key))
}
}
}
fn pane_mut(&mut self, key: u64) -> Option<&mut Pane> { fn pane_mut(&mut self, key: u64) -> Option<&mut Pane> {
Self::pane_mut_in(&mut self.root, key) Self::pane_mut_in(&mut self.root, key)
} }
@@ -969,15 +995,10 @@ fn render_pane(
.map(|tab| tab.view.clone()); .map(|tab| tab.view.clone());
// The cache keeps an un-notified panel from re-rendering on dock frames. // The cache keeps an un-notified panel from re-rendering on dock frames.
let content_id = SharedString::from(format!("{dock_id}-content-{}", pane.key));
let content = div() let content = div()
.id(SharedString::from(format!( .id(content_id.clone())
"{dock_id}-content-{}", .debug_selector(move || content_id.to_string())
pane.key
)))
.debug_selector({
let id = SharedString::from(format!("{dock_id}-content-{}", pane.key));
move || id.to_string()
})
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
@@ -999,6 +1020,7 @@ fn render_pane(
let pane = pane.clone(); let pane = pane.clone();
let titlebar = *titlebar; let titlebar = *titlebar;
let padding_x = metrics.dense_padding_x; let padding_x = metrics.dense_padding_x;
move |window, cx| { move |window, cx| {
let selected = pane let selected = pane
.active .active
@@ -1016,7 +1038,7 @@ fn render_pane(
move |_, _| { move |_, _| {
tabs.iter() tabs.iter()
.map(|tab| { .map(|tab| {
let mut entry = Tab::new(tab.title.clone()); let mut entry = Tab::new(tab.panel.clone(), tab.title.clone());
if let Some(role) = tab.icon { if let Some(role) = tab.icon {
entry = entry.icon(role); entry = entry.icon(role);
} }
@@ -1065,21 +1087,17 @@ fn render_pane(
} }
}); });
let bar_id = SharedString::from(format!("{dock_id}-pane-{}-bar", pane.key));
h_flex() h_flex()
.id(SharedString::from(format!( .id(bar_id.clone())
"{dock_id}-pane-{}-bar", .debug_selector(move || bar_id.to_string())
pane.key
)))
.debug_selector({
let id = SharedString::from(format!("{dock_id}-pane-{}-bar", pane.key));
move || id.to_string()
})
.flex_none() .flex_none()
.w_full() .w_full()
.h(titlebar.height) .h(titlebar.height)
.items_center() .items_center()
.pl(if titlebar.traffic_light_pad { .pl(if titlebar.traffic_light_pad {
px(TRAFFIC_LIGHT_PAD) TRAFFIC_LIGHT_PAD
} else { } else {
padding_x padding_x
}) })
+14 -6
View File
@@ -10,11 +10,11 @@ use gpui::{
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, deferred, div, SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, deferred, div,
fill, point, px, fill, point, px,
}; };
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, RESIZE_KEYBOARD_STEP, Size};
use gpui_native_utils::{focus_handle, focus_ring}; use gpui_native_utils::{focus_handle, focus_ring};
/// How far one keyboard arrow step moves the seam. /// How far one keyboard arrow step moves the seam.
const KEYBOARD_STEP: f32 = 32.0; const KEYBOARD_STEP: Pixels = RESIZE_KEYBOARD_STEP;
type ResizeHandler = Rc<dyn Fn(Pixels, &mut Window, &mut App)>; type ResizeHandler = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
type ResetHandler = Rc<dyn Fn(&mut Window, &mut App)>; type ResetHandler = Rc<dyn Fn(&mut Window, &mut App)>;
@@ -180,10 +180,10 @@ impl RenderOnce for ResizeHandle {
let on_resize = self.on_resize.clone(); let on_resize = self.on_resize.clone();
move |event: &KeyDownEvent, window, cx| { move |event: &KeyDownEvent, window, cx| {
let delta = match (axis, event.keystroke.key.as_str()) { let delta = match (axis, event.keystroke.key.as_str()) {
(Axis::Horizontal, "left") => Some(px(-KEYBOARD_STEP)), (Axis::Horizontal, "left") => Some(-KEYBOARD_STEP),
(Axis::Horizontal, "right") => Some(px(KEYBOARD_STEP)), (Axis::Horizontal, "right") => Some(KEYBOARD_STEP),
(Axis::Vertical, "up") => Some(px(-KEYBOARD_STEP)), (Axis::Vertical, "up") => Some(-KEYBOARD_STEP),
(Axis::Vertical, "down") => Some(px(KEYBOARD_STEP)), (Axis::Vertical, "down") => Some(KEYBOARD_STEP),
_ => None, _ => None,
}; };
if let Some(delta) = delta { if let Some(delta) = delta {
@@ -255,6 +255,14 @@ impl RenderOnce for ResizeHandle {
return; return;
} }
if event.pressed_button != Some(MouseButton::Left) {
drag.update(cx, |drag, cx| {
drag.dragging = false;
cx.notify();
});
return;
}
let delta = match axis { let delta = match axis {
Axis::Horizontal => event.position.x - last.x, Axis::Horizontal => event.position.x - last.x,
Axis::Vertical => event.position.y - last.y, Axis::Vertical => event.position.y - last.y,
+70 -34
View File
@@ -4,11 +4,14 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _, AnyElement, App, AppContext as _, Context, ElementId, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, Styled as _, Window, div, IntoElement, ParentElement as _, Pixels, RenderOnce, Styled as _, Window, div, px,
px,
}; };
#[cfg(feature = "alert")]
use gpui_native_core::alert::{Alert, Alerts, AlertsView};
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
#[cfg(feature = "toast")]
use gpui_native_core::toast::{Toast, Toasts, ToastsView};
use gpui_native_data::sidebar::Sidebar; use gpui_native_data::sidebar::Sidebar;
use gpui_native_icons::IconRole; use gpui_native_icons::IconRole;
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, Size};
@@ -30,16 +33,24 @@ pub struct LayoutState {
right_visible: bool, right_visible: bool,
left_width: Pixels, left_width: Pixels,
right_width: Pixels, right_width: Pixels,
#[cfg(feature = "toast")]
toasts: Entity<Toasts>,
#[cfg(feature = "alert")]
alerts: Entity<Alerts>,
} }
impl LayoutState { impl LayoutState {
/// Creates the state: left sidebar visible, right sidebar hidden. /// Creates the state: left sidebar visible, right sidebar hidden.
pub fn new(cx: &mut App) -> Entity<Self> { pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { cx.new(|cx| Self {
left_visible: true, left_visible: true,
right_visible: false, right_visible: false,
left_width: px(DEFAULT_SIDEBAR_WIDTH), left_width: px(DEFAULT_SIDEBAR_WIDTH),
right_width: px(DEFAULT_SIDEBAR_WIDTH), right_width: px(DEFAULT_SIDEBAR_WIDTH),
#[cfg(feature = "toast")]
toasts: cx.new(|_| Toasts::default()),
#[cfg(feature = "alert")]
alerts: cx.new(|_| Alerts::default()),
}) })
} }
@@ -92,6 +103,42 @@ impl LayoutState {
(self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH)); (self.right_width - delta).clamp(px(MIN_SIDEBAR_WIDTH), px(MAX_SIDEBAR_WIDTH));
cx.notify(); cx.notify();
} }
/// Raises a toast in the shell's overlay. Returns its id.
#[cfg(feature = "toast")]
pub fn push_toast(&mut self, toast: Toast, cx: &mut Context<Self>) -> usize {
self.toasts.update(cx, |toasts, cx| toasts.push(toast, cx))
}
/// Dismisses one toast by id.
#[cfg(feature = "toast")]
pub fn dismiss_toast(&mut self, id: usize, cx: &mut Context<Self>) {
self.toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
}
/// Dismisses the toast on screen.
#[cfg(feature = "toast")]
pub fn clear_toasts(&mut self, cx: &mut Context<Self>) {
self.toasts.update(cx, |toasts, cx| toasts.clear(cx));
}
/// Pushes an alert onto the shell's stack. Returns its id.
#[cfg(feature = "alert")]
pub fn push_alert(&mut self, alert: Alert, cx: &mut Context<Self>) -> usize {
self.alerts.update(cx, |alerts, cx| alerts.push(alert, cx))
}
/// Dismisses one alert by id, running its dismiss handler.
#[cfg(feature = "alert")]
pub fn dismiss_alert(&mut self, id: usize, cx: &mut Context<Self>) {
self.alerts.update(cx, |alerts, cx| alerts.dismiss(id, cx));
}
/// Dismisses every alert, running each entry's `on_dismiss` handler.
#[cfg(feature = "alert")]
pub fn clear_alerts(&mut self, cx: &mut Context<Self>) {
self.alerts.update(cx, |alerts, cx| alerts.clear(cx));
}
} }
/// Which sidebar a region or toggle belongs to. /// Which sidebar a region or toggle belongs to.
@@ -108,20 +155,6 @@ impl Side {
Side::Right => "right", Side::Right => "right",
} }
} }
fn toggle_icon(self) -> IconRole {
match self {
Side::Left => IconRole::PanelLeft,
Side::Right => IconRole::PanelRight,
}
}
fn toggle_label(self) -> SharedString {
match self {
Side::Left => SharedString::from("Hide left sidebar"),
Side::Right => SharedString::from("Hide right sidebar"),
}
}
} }
/// The application shell. /// The application shell.
@@ -204,6 +237,7 @@ impl RenderOnce for Shell {
let width = state.read(cx).left_sidebar_width(); let width = state.read(cx).left_sidebar_width();
layout layout
.child(sidebar_region( .child(sidebar_region(
(id.clone(), "left"),
width, width,
left.take() left.take()
.map(|sidebar| sidebar.into_any_element()) .map(|sidebar| sidebar.into_any_element())
@@ -242,6 +276,7 @@ impl RenderOnce for Shell {
} }
})) }))
.child(sidebar_region( .child(sidebar_region(
(id.clone(), "right"),
width, width,
right.take().unwrap_or_else(|| div().into_any_element()), right.take().unwrap_or_else(|| div().into_any_element()),
Side::Right, Side::Right,
@@ -254,14 +289,23 @@ impl RenderOnce for Shell {
)) ))
}); });
// Wraps the layout in a window frame. // Wraps the layout in a window frame
frame(layout, window, cx) frame(layout, window, cx)
.when(cfg!(feature = "toast"), |this| {
let toasts = state.read(cx).toasts.clone();
this.child(ToastsView::new((id.clone(), "toasts"), toasts))
})
.when(cfg!(feature = "alert"), |this| {
let alerts = state.read(cx).alerts.clone();
this.child(AlertsView::new((id, "alerts"), alerts))
})
} }
} }
/// One sidebar: a header strip with the hide toggle over the app's content. /// One sidebar: a header strip with the hide toggle over the app's content.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn sidebar_region( fn sidebar_region(
id: impl Into<ElementId>,
width: Pixels, width: Pixels,
content: AnyElement, content: AnyElement,
side: Side, side: Side,
@@ -272,6 +316,7 @@ fn sidebar_region(
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> AnyElement { ) -> AnyElement {
let id = id.into();
let theme = cx.theme(); let theme = cx.theme();
let background = theme.colors.background; let background = theme.colors.background;
let strip_background = match (side, material) { let strip_background = match (side, material) {
@@ -280,7 +325,7 @@ fn sidebar_region(
}; };
let strip = h_flex() let strip = h_flex()
.id(SharedString::from(format!("sidebar-strip-{}", side.name()))) .id((id.clone(), "strip"))
.debug_selector(move || format!("sidebar-strip-{}", side.name())) .debug_selector(move || format!("sidebar-strip-{}", side.name()))
.flex_none() .flex_none()
.h(strip_height) .h(strip_height)
@@ -288,32 +333,23 @@ fn sidebar_region(
.items_center() .items_center()
.map(|strip| match side { .map(|strip| match side {
Side::Left => strip.when(cfg!(target_os = "macos"), |strip| { Side::Left => strip.when(cfg!(target_os = "macos"), |strip| {
strip.pl(px(TRAFFIC_LIGHT_PAD)) strip.pl(TRAFFIC_LIGHT_PAD)
}), }),
Side::Right => strip, Side::Right => strip,
}) })
.px(padding_x) .px(padding_x)
.child(titlebar_strip( .child(titlebar_strip(
div() div().id((id.clone(), "strip-drag")).flex_1().self_stretch(),
.id(SharedString::from(format!(
"sidebar-strip-drag-{}",
side.name()
)))
.flex_1()
.self_stretch(),
window, window,
cx, cx,
)) ))
.map(|strip| match side { .map(|strip| match side {
Side::Left => strip.child( Side::Left => strip.child(
Button::new(SharedString::from(format!( Button::new((id, "sidebar-toggle"))
"sidebar-toggle-{}", .icon(IconRole::PanelLeft)
side.name()
)))
.icon(side.toggle_icon())
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
.small() .small()
.accessibility_label(side.toggle_label()) .accessibility_label("Hide left sidebar")
.on_click({ .on_click({
let state = state.clone(); let state = state.clone();
move |_, _, cx| { move |_, _, cx| {
@@ -321,7 +357,7 @@ fn sidebar_region(
} }
}), }),
), ),
Side::Right => strip.child(WindowControls::new("sidebar-window-controls")), Side::Right => strip.child(WindowControls::new((id, "window-controls"))),
}); });
v_flex() v_flex()
+4 -15
View File
@@ -23,13 +23,9 @@ use gpui::Decorations;
use gpui::{SharedString, px, rgba}; use gpui::{SharedString, px, rgba};
/// macOS traffic-lights zone: the pad a leading titlebar strip reserves. /// macOS traffic-lights zone: the pad a leading titlebar strip reserves.
pub const TRAFFIC_LIGHT_PAD: f32 = 78.0; pub use gpui_native_theme::TRAFFIC_LIGHT_PAD;
/// The width of a Windows caption button.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
const CAPTION_WIDTH: f32 = 36.0; use gpui_native_theme::{WINDOWS_CAPTION_WIDTH as CAPTION_WIDTH, WINDOWS_CLOSE_HOVER_HEX};
/// Windows paints its caption close hover red in every theme.
#[cfg(target_os = "windows")]
const WINDOWS_CLOSE_HOVER: (f32, f32, f32) = (232.0 / 255.0, 17.0 / 255.0, 32.0 / 255.0);
/// The window controls for the trailing end of a titlebar strip. /// The window controls for the trailing end of a titlebar strip.
#[derive(IntoElement)] #[derive(IntoElement)]
@@ -150,7 +146,7 @@ impl RenderOnce for WindowControls {
} }
/// Double-click on a titlebar strip maximizes where the platform does not do it. /// Double-click on a titlebar strip maximizes where the platform does not do it.
pub fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static { fn maximize_on_double_click() -> impl Fn(&ClickEvent, &mut Window, &mut App) + 'static {
move |event, window, _cx| { move |event, window, _cx| {
if event.click_count() != 2 { if event.click_count() != 2 {
return; return;
@@ -210,16 +206,9 @@ fn caption_button(
) -> AnyElement { ) -> AnyElement {
let theme = cx.theme(); let theme = cx.theme();
let glyph_size = theme.sizes.small.caption_size; let glyph_size = theme.sizes.small.caption_size;
let (close_r, close_g, close_b) = WINDOWS_CLOSE_HOVER;
let close_hover = rgba((
(close_r * 255.0) as u8,
(close_g * 255.0) as u8,
(close_b * 255.0) as u8,
0xff,
));
let hover = if close { let hover = if close {
close_hover rgba(WINDOWS_CLOSE_HOVER_HEX)
} else { } else {
theme.colors.muted_hover theme.colors.muted_hover
}; };
+6 -10
View File
@@ -7,12 +7,8 @@ use gpui::{
IntoElement, MouseButton, ParentElement as _, Pixels, Point, ResizeEdge, Size, Styled as _, IntoElement, MouseButton, ParentElement as _, Pixels, Point, ResizeEdge, Size, Styled as _,
Tiling, Window, canvas, div, point, px, size, Tiling, Window, canvas, div, point, px, size,
}; };
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, WINDOW_RESIZE_INSET};
/// The width of the band inside the window's edge that resizes it.
const RESIZE_INSET: f32 = 4.0;
/// The hairline around the content.
const BORDER_WIDTH: f32 = 1.0;
/// How much of the shadow's strength survives losing window focus. /// How much of the shadow's strength survives losing window focus.
const INACTIVE_SHADOW_OPACITY: f32 = 0.7; const INACTIVE_SHADOW_OPACITY: f32 = 0.7;
@@ -28,7 +24,7 @@ pub fn frame(child: impl IntoElement, window: &mut Window, cx: &App) -> Div {
tiling tiling
}; };
let inset = px(RESIZE_INSET); let inset = WINDOW_RESIZE_INSET;
let border = cx.theme().colors.border; let border = cx.theme().colors.border;
let radius = cx.theme().radius.overlay; let radius = cx.theme().radius.overlay;
@@ -75,10 +71,10 @@ pub fn frame(child: impl IntoElement, window: &mut Window, cx: &App) -> Div {
.size_full() .size_full()
.overflow_hidden() .overflow_hidden()
.border_color(border) .border_color(border)
.when(!tiling.top, |surface| surface.border_t(px(BORDER_WIDTH))) .when(!tiling.top, |surface| surface.border_t(BORDER_WIDTH))
.when(!tiling.bottom, |surface| surface.border_b(px(BORDER_WIDTH))) .when(!tiling.bottom, |surface| surface.border_b(BORDER_WIDTH))
.when(!tiling.left, |surface| surface.border_l(px(BORDER_WIDTH))) .when(!tiling.left, |surface| surface.border_l(BORDER_WIDTH))
.when(!tiling.right, |surface| surface.border_r(px(BORDER_WIDTH))) .when(!tiling.right, |surface| surface.border_r(BORDER_WIDTH))
.when(!(tiling.top || tiling.left), |surface| { .when(!(tiling.top || tiling.left), |surface| {
surface.rounded_tl(radius) surface.rounded_tl(radius)
}) })
+8 -8
View File
@@ -112,9 +112,9 @@ fn drag(visual: &mut VisualTestContext, from: gpui::Point<Pixels>, to: gpui::Poi
fn register(dock: &Entity<DockState>, tags: &[&'static str], cx: &mut TestAppContext) { fn register(dock: &Entity<DockState>, tags: &[&'static str], cx: &mut TestAppContext) {
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| {
for tag in tags { for tag in tags {
dock.register_panel(descriptor(tag), cx); dock.register_panel(descriptor(tag));
} }
}); });
}); });
@@ -492,9 +492,9 @@ fn activating_a_panel_with_a_right_view_opens_the_sidebar() {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let (dock, opened, mut visual) = sidebar_window(&mut cx); let (dock, opened, mut visual) = sidebar_window(&mut cx);
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| {
dock.register_panel(descriptor_with_right_sidebar("overview"), cx); dock.register_panel(descriptor_with_right_sidebar("overview"));
dock.register_panel(descriptor("inbox"), cx); dock.register_panel(descriptor("inbox"));
}); });
}); });
@@ -519,9 +519,9 @@ fn closing_the_displayed_tab_falls_back_to_another_right_view() {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let (dock, opened, mut visual) = sidebar_window(&mut cx); let (dock, opened, mut visual) = sidebar_window(&mut cx);
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| {
dock.register_panel(descriptor_with_right_sidebar("overview"), cx); dock.register_panel(descriptor_with_right_sidebar("overview"));
dock.register_panel(descriptor_with_right_sidebar("explore"), cx); dock.register_panel(descriptor_with_right_sidebar("explore"));
}); });
}); });
open(&mut visual, &dock, "overview"); open(&mut visual, &dock, "overview");
+22
View File
@@ -151,6 +151,28 @@ fn moves_without_a_drag_report_nothing() {
assert!(deltas.borrow().is_empty()); assert!(deltas.borrow().is_empty());
} }
/// The button may be released outside the window, where no MouseUp is
/// delivered — the drag must end when a move reports no pressed button.
#[test]
fn releasing_outside_the_window_ends_the_drag() {
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
let start = handle_center(&mut visual);
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
visual.simulate_event(MouseMoveEvent {
position: point(start.x + px(30.), start.y),
pressed_button: None,
modifiers: Modifiers::default(),
});
visual.simulate_event(MouseMoveEvent {
position: point(start.x + px(60.), start.y),
pressed_button: None,
modifiers: Modifiers::default(),
});
assert!(deltas.borrow().is_empty(), "the drag outlived its button");
}
#[test] #[test]
fn arrow_keys_move_the_seam_when_focused() { fn arrow_keys_move_the_seam_when_focused() {
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false); let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
+85
View File
@@ -235,3 +235,88 @@ fn the_right_seam_grows_the_right_sidebar_leftwards() {
let region = bounds(&mut visual, "test-right").expect("right region missing"); let region = bounds(&mut visual, "test-right").expect("right region missing");
assert_eq!(region.size.width, grown); assert_eq!(region.size.width, grown);
} }
/// Toasts and alerts live on the shell's [`LayoutState`]; pushing one must
/// show up in the rendered shell with no observing or manual overlay.
#[cfg(feature = "toast")]
mod toasts {
use super::*;
use gpui_native_core::toast::{Toast, ToastAction};
use std::time::Duration;
#[test]
fn a_pushed_toast_renders_in_the_shell() {
let mut cx = TestAppContext::single();
let (state, mut visual) = layout_window(&mut cx);
let id = cx.update(|cx| {
state.update(cx, |state, cx| {
state.push_toast(
Toast::new("Draft deleted").action(ToastAction::new("Undo", |_| {
// A real app restores the draft here.
})),
cx,
)
})
});
// No redraw, no observation: the shell repaints its own overlay.
cx.run_until_parked();
assert!(
bounds(&mut visual, "toast-pill").is_some(),
"the pushed toast must appear in the shell's overlay"
);
cx.update(|cx| {
state.update(cx, |state, cx| state.dismiss_toast(id, cx));
});
cx.run_until_parked();
// The fade-out keeps the pill mounted briefly.
assert!(
bounds(&mut visual, "toast-pill").is_some(),
"the dismissed toast must stay mounted through its fade-out"
);
cx.executor().advance_clock(Duration::from_secs(2));
cx.run_until_parked();
assert!(
bounds(&mut visual, "toast-pill").is_none(),
"the dismissed toast must leave the overlay once faded"
);
}
}
#[cfg(feature = "alert")]
mod alerts {
use super::*;
use gpui_native_core::alert::{Alert, AlertSeverity};
#[test]
fn a_pushed_alert_renders_in_the_shell() {
let mut cx = TestAppContext::single();
let (state, mut visual) = layout_window(&mut cx);
cx.update(|cx| {
state.update(cx, |state, cx| {
state.push_alert(
Alert::new("shell-alert")
.title("Backed up")
.severity(AlertSeverity::Info),
cx,
);
})
});
cx.run_until_parked();
assert!(
bounds(&mut visual, "alert-entry").is_some(),
"the pushed alert must appear in the shell's overlay"
);
cx.update(|cx| {
state.update(cx, |state, cx| state.clear_alerts(cx));
});
cx.run_until_parked();
assert!(
bounds(&mut visual, "alert-entry").is_none(),
"cleared alerts must leave the overlay"
);
}
}
+6 -14
View File
@@ -74,9 +74,7 @@ fn the_pill_bar_pads_for_traffic_lights_when_it_leads_the_window() {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let (dock, mut visual) = dock_window(false, false, &mut cx); let (dock, mut visual) = dock_window(false, false, &mut cx);
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| dock.register_panel(descriptor("overview")));
dock.register_panel(descriptor("overview"), cx)
});
}); });
open(&mut visual, &dock, "overview"); open(&mut visual, &dock, "overview");
@@ -87,9 +85,7 @@ fn the_pill_bar_pads_for_traffic_lights_when_it_leads_the_window() {
let (dock, mut visual) = dock_window(false, true, &mut cx); let (dock, mut visual) = dock_window(false, true, &mut cx);
let _ = &dock; let _ = &dock;
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| dock.register_panel(descriptor("overview")));
dock.register_panel(descriptor("overview"), cx)
});
}); });
open(&mut visual, &dock, "overview"); open(&mut visual, &dock, "overview");
@@ -101,11 +97,11 @@ fn the_pill_bar_pads_for_traffic_lights_when_it_leads_the_window() {
// The bare bar keeps the standard leading padding; the padded bar clears // The bare bar keeps the standard leading padding; the padded bar clears
// the traffic lights. // the traffic lights.
assert!( assert!(
bare_inset < px(TRAFFIC_LIGHT_PAD), bare_inset < TRAFFIC_LIGHT_PAD,
"the bare bar must not reserve the traffic-light pad (bare {bare_inset:?})" "the bare bar must not reserve the traffic-light pad (bare {bare_inset:?})"
); );
assert!( assert!(
padded_inset >= px(TRAFFIC_LIGHT_PAD) - px(1.), padded_inset >= TRAFFIC_LIGHT_PAD - px(1.),
"the pad must clear the traffic lights (bare {bare_inset:?}, padded {padded_inset:?})" "the pad must clear the traffic lights (bare {bare_inset:?}, padded {padded_inset:?})"
); );
} }
@@ -115,9 +111,7 @@ fn window_controls_render_only_when_the_flag_is_set() {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let (dock, mut visual) = dock_window(false, false, &mut cx); let (dock, mut visual) = dock_window(false, false, &mut cx);
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| dock.register_panel(descriptor("overview")));
dock.register_panel(descriptor("overview"), cx)
});
}); });
open(&mut visual, &dock, "overview"); open(&mut visual, &dock, "overview");
@@ -138,9 +132,7 @@ fn the_flag_hosts_the_platform_controls_in_the_bar() {
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let (dock, mut visual) = dock_window(true, false, &mut cx); let (dock, mut visual) = dock_window(true, false, &mut cx);
cx.update(|cx| { cx.update(|cx| {
dock.update(cx, |dock, cx| { dock.update(cx, |dock, _cx| dock.register_panel(descriptor("overview")));
dock.register_panel(descriptor("overview"), cx)
});
}); });
open(&mut visual, &dock, "overview"); open(&mut visual, &dock, "overview");
+32 -2
View File
@@ -23,9 +23,39 @@ pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemThe
use gpui::{App, Global, Hsla, Pixels, WindowAppearance, WindowBackgroundAppearance, px}; use gpui::{App, Global, Hsla, Pixels, WindowAppearance, WindowBackgroundAppearance, px};
use std::time::Duration; use std::time::Duration;
/// The hairline width of the control border every button and field paints; /// The hairline width of the control border every button and field paints.
/// sized outside the stated metrics so control sizes match across platforms.
pub const BORDER_WIDTH: Pixels = px(1.0); pub const BORDER_WIDTH: Pixels = px(1.0);
/// The tallest an anchored popup's list may grow.
pub const POPUP_MAX_HEIGHT: Pixels = px(320.0);
/// The widest a tooltip may grow.
pub const TOOLTIP_MAX_WIDTH: Pixels = px(360.0);
/// How far a tooltip sits from the pointer.
pub const TOOLTIP_OFFSET: Pixels = px(1.0);
/// The caret's thickness.
pub const CARET_WIDTH: Pixels = px(1.5);
/// The IME preedit underline's thickness.
pub const UNDERLINE_WIDTH: Pixels = px(1.0);
/// How far one wheel line scrolls, as a multiple of the line height.
pub const WHEEL_LINE_HEIGHT: f32 = 20.0;
/// How far one keyboard arrow step moves a resize seam.
pub const RESIZE_KEYBOARD_STEP: Pixels = px(32.0);
/// The inset around a window's edge that starts a resize gesture.
pub const WINDOW_RESIZE_INSET: Pixels = px(4.0);
/// The pad macOS reserves for its traffic lights at a strip's leading edge.
pub const TRAFFIC_LIGHT_PAD: Pixels = px(78.0);
/// The width of a Windows caption button.
pub const WINDOWS_CAPTION_WIDTH: Pixels = px(36.0);
/// Windows paints its caption close-button hover red in every theme.
pub const WINDOWS_CLOSE_HOVER_HEX: u32 = 0xE81120FF;
/// The monospace advance estimate as a share of the text size.
pub const MONO_ADVANCE_RATIO: f32 = 0.62;
/// How much a pixel avatar's tint washes over its background.
pub const PIXEL_AVATAR_TINT_ALPHA: f32 = 0.16;
/// Pixel avatar cell lightness multipliers.
pub const PIXEL_AVATAR_LIGHT_LIFT: f32 = 1.6;
pub const PIXEL_AVATAR_LIGHT_CAP: f32 = 0.95;
pub const PIXEL_AVATAR_DARK_FACTOR: f32 = 0.45;
pub const PIXEL_AVATAR_DARK_FLOOR: f32 = 0.18;
/// Light or dark, resolved from the system appearance. /// Light or dark, resolved from the system appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
+16 -18
View File
@@ -1,7 +1,8 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
App, Bounds, BoxShadow, Div, ElementId, FocusHandle, Global, Hsla, IntoElement, Pixels, App, Bounds, BoxShadow, Div, ElementId, FocusHandle, Global, Hsla, IntoElement, Pixels,
Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, Refineable as _, StatefulInteractiveElement, StyleRefinement, Styled, Window, WindowId, div,
px,
}; };
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
use std::collections::HashMap; use std::collections::HashMap;
@@ -36,39 +37,36 @@ pub fn focus_handle(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusH
.clone() .clone()
} }
/// The painted bounds of elements that tooltips anchor to, keyed by element id. /// The painted bounds of elements that tooltips anchor to, per window and element id.
#[derive(Clone, Default)] #[derive(Default)]
struct AnchorBoundsRegistry(HashMap<ElementId, Bounds<Pixels>>); struct AnchorBoundsRegistry(HashMap<(WindowId, ElementId), Bounds<Pixels>>);
impl Global for AnchorBoundsRegistry {} impl Global for AnchorBoundsRegistry {}
impl AnchorBoundsRegistry { impl AnchorBoundsRegistry {
fn record(&mut self, id: ElementId, bounds: Bounds<Pixels>) { fn record(&mut self, window: WindowId, id: ElementId, bounds: Bounds<Pixels>) {
self.0.insert(id, bounds); self.0.insert((window, id), bounds);
} }
fn get(&self, id: &ElementId) -> Option<Bounds<Pixels>> { fn get(&self, window: WindowId, id: &ElementId) -> Option<Bounds<Pixels>> {
self.0.get(id).copied() self.0.get(&(window, id.clone())).copied()
} }
} }
/// The last bounds an element painted at. /// The last bounds an element painted at, in the given window.
pub fn anchor_bounds(id: &ElementId, cx: &mut App) -> Option<Bounds<Pixels>> { pub fn anchor_bounds(window: WindowId, id: &ElementId, cx: &App) -> Option<Bounds<Pixels>> {
cx.try_global::<AnchorBoundsRegistry>() cx.try_global::<AnchorBoundsRegistry>()
.and_then(|registry| registry.get(id)) .and_then(|registry| registry.get(window, id))
} }
/// A canvas prepaint closure recording the element's painted bounds. /// A canvas prepaint closure recording the element's painted bounds.
pub fn record_anchor_bounds( pub fn record_anchor_bounds(
id: ElementId, id: ElementId,
) -> impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static { ) -> impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static {
move |bounds, _window, cx| { move |bounds, window, cx| {
let mut registry = cx let window_id = window.window_handle().window_id();
.try_global::<AnchorBoundsRegistry>() cx.default_global::<AnchorBoundsRegistry>()
.cloned() .record(window_id, id.clone(), bounds);
.unwrap_or_default();
registry.record(id.clone(), bounds);
cx.set_global(registry);
} }
} }
+148 -256
View File
@@ -7,7 +7,7 @@ use gpui::{
SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds, SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds,
WindowOptions, actions, div, list, px, size, uniform_list, WindowOptions, actions, div, list, px, size, uniform_list,
}; };
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; use gpui_native_core::alert::{Alert, AlertSeverity};
use gpui_native_core::avatar::Avatar; use gpui_native_core::avatar::Avatar;
use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::badge::{Badge, BadgeVariant};
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
@@ -36,7 +36,7 @@ use gpui_native_core::slider::Slider;
use gpui_native_core::spinner::{Spinner, SpinnerVariant}; use gpui_native_core::spinner::{Spinner, SpinnerVariant};
use gpui_native_core::status_dot::{StatusDot, StatusDotVariant}; use gpui_native_core::status_dot::{StatusDot, StatusDotVariant};
use gpui_native_core::switch::Switch; use gpui_native_core::switch::Switch;
use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView}; use gpui_native_core::toast::{Toast, ToastAction};
use gpui_native_core::toggle::Toggle; use gpui_native_core::toggle::Toggle;
use gpui_native_core::tooltip::Tooltip; use gpui_native_core::tooltip::Tooltip;
use gpui_native_data::list::{List, ListItem}; use gpui_native_data::list::{List, ListItem};
@@ -217,8 +217,6 @@ fn gallery_commands() -> Vec<Command> {
struct Gallery { struct Gallery {
layout_state: Entity<LayoutState>, layout_state: Entity<LayoutState>,
dock_state: Entity<DockState>, dock_state: Entity<DockState>,
alerts: Entity<Alerts>,
toasts: Entity<Toasts>,
palette: Option<Entity<CommandPaletteState>>, palette: Option<Entity<CommandPaletteState>>,
palette_runs: Vec<SharedString>, palette_runs: Vec<SharedString>,
dialog_open: bool, dialog_open: bool,
@@ -395,8 +393,6 @@ impl Gallery {
fn register_panels( fn register_panels(
dock: &mut DockState, dock: &mut DockState,
layout_state: Entity<LayoutState>, layout_state: Entity<LayoutState>,
alerts: Entity<Alerts>,
toasts: Entity<Toasts>,
palette: Entity<CommandPaletteState>, palette: Entity<CommandPaletteState>,
gallery: WeakEntity<Self>, gallery: WeakEntity<Self>,
cx: &mut Context<DockState>, cx: &mut Context<DockState>,
@@ -424,135 +420,83 @@ impl Gallery {
}) })
.into() .into()
}), }),
cx,
); );
dock.register_panel( dock.register_panel(PanelDescriptor::new("button", "Button", |_, cx| {
PanelDescriptor::new("button", "Button", |_, cx| {
cx.new(|_| ButtonDemo { clicks: 0 }).into() cx.new(|_| ButtonDemo { clicks: 0 }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("input", "Input", |_, cx| {
); cx.new(InputDemo::new).into()
dock.register_panel( }));
PanelDescriptor::new("input", "Input", |_, cx| cx.new(InputDemo::new).into()), dock.register_panel(PanelDescriptor::new("field", "Field", |window, cx| {
cx,
);
dock.register_panel(
PanelDescriptor::new("field", "Field", |window, cx| {
cx.new(|cx| FieldDemo::new(window, cx)).into() cx.new(|cx| FieldDemo::new(window, cx)).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new(
); "combobox",
dock.register_panel( "Combobox",
PanelDescriptor::new("combobox", "Combobox", |window, cx| { |window, cx| cx.new(|cx| ComboboxDemo::new(window, cx)).into(),
cx.new(|cx| ComboboxDemo::new(window, cx)).into() ));
}), dock.register_panel(PanelDescriptor::new("status-dot", "Status dot", |_, cx| {
cx,
);
dock.register_panel(
PanelDescriptor::new("status-dot", "Status dot", |_, cx| {
cx.new(|_| StatusDotDemo).into() cx.new(|_| StatusDotDemo).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("checkbox", "Checkbox", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("checkbox", "Checkbox", |_, cx| {
cx.new(|_| CheckboxDemo { checked: false }).into() cx.new(|_| CheckboxDemo { checked: false }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("radio", "Radio", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("radio", "Radio", |_, cx| {
cx.new(|_| RadioDemo { cx.new(|_| RadioDemo {
plan: SharedString::from("starter"), plan: SharedString::from("starter"),
}) })
.into() .into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("switch", "Switch", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("switch", "Switch", |_, cx| {
cx.new(|_| SwitchDemo { checked: true }).into() cx.new(|_| SwitchDemo { checked: true }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("toggle", "Toggle", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("toggle", "Toggle", |_, cx| {
cx.new(|_| ToggleDemo { pressed: false }).into() cx.new(|_| ToggleDemo { pressed: false }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("slider", "Slider", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("slider", "Slider", |_, cx| {
cx.new(|_| SliderDemo { volume: 40.0 }).into() cx.new(|_| SliderDemo { volume: 40.0 }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("segmented", "Segmented", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("segmented", "Segmented", |_, cx| {
cx.new(|_| SegmentedDemo { cx.new(|_| SegmentedDemo {
filter: ExploreFilter::default(), filter: ExploreFilter::default(),
}) })
.into() .into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("select", "Select", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("select", "Select", |_, cx| {
cx.new(|_| SelectDemo { cx.new(|_| SelectDemo {
strategy: Some(Strategy::Curated), strategy: Some(Strategy::Curated),
}) })
.into() .into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("link", "Link", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("link", "Link", |_, cx| {
cx.new(|_| LinkDemo { clicks: 0 }).into() cx.new(|_| LinkDemo { clicks: 0 }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("clipboard", "Clipboard", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("clipboard", "Clipboard", |_, cx| {
cx.new(|_| ClipboardDemo).into() cx.new(|_| ClipboardDemo).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("badge", "Badge", |_, cx| {
); cx.new(|_| BadgeDemo).into()
dock.register_panel( }));
PanelDescriptor::new("badge", "Badge", |_, cx| cx.new(|_| BadgeDemo).into()), dock.register_panel(PanelDescriptor::new("avatar", "Avatar", |_, cx| {
cx, cx.new(|_| AvatarDemo).into()
); }));
dock.register_panel( dock.register_panel(PanelDescriptor::new("progress", "Progress", |_, cx| {
PanelDescriptor::new("avatar", "Avatar", |_, cx| cx.new(|_| AvatarDemo).into()),
cx,
);
dock.register_panel(
PanelDescriptor::new("progress", "Progress", |_, cx| {
cx.new(|_| ProgressDemo).into() cx.new(|_| ProgressDemo).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("spinner", "Spinner", |_, cx| {
); cx.new(|_| SpinnerDemo).into()
dock.register_panel( }));
PanelDescriptor::new("spinner", "Spinner", |_, cx| cx.new(|_| SpinnerDemo).into()), dock.register_panel(PanelDescriptor::new("list", "List", |_, cx| {
cx,
);
dock.register_panel(
PanelDescriptor::new("list", "List", |_, cx| {
cx.new(|_| ListDemo { selected: None }).into() cx.new(|_| ListDemo { selected: None }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("tree", "Tree", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("tree", "Tree", |_, cx| {
cx.new(|_| TreeDemo { cx.new(|_| TreeDemo {
nodes: TreeDemo::demo(), nodes: TreeDemo::demo(),
selected: None, selected: None,
}) })
.into() .into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("tabs", "Tabs", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("tabs", "Tabs", |_, cx| {
cx.new(|_| TabsDemo { cx.new(|_| TabsDemo {
selected: 0, selected: 0,
pill_tabs: vec![ pill_tabs: vec![
@@ -563,36 +507,27 @@ impl Gallery {
pill_selected: 0, pill_selected: 0,
}) })
.into() .into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("alert", "Alert", {
); let layout_state = layout_state.clone();
dock.register_panel(
PanelDescriptor::new("alert", "Alert", {
let alerts = alerts.clone();
move |_, cx| { move |_, cx| {
cx.new(|_| AlertDemo { cx.new(|_| AlertDemo {
alerts: alerts.clone(), layout_state: layout_state.clone(),
dismissed: false, dismissed: false,
}) })
.into() .into()
} }
}), }));
cx, dock.register_panel(PanelDescriptor::new("toast", "Toast", {
); let layout_state = layout_state.clone();
dock.register_panel(
PanelDescriptor::new("toast", "Toast", {
let toasts = toasts.clone();
move |_, cx| { move |_, cx| {
cx.new(|_| ToastDemo { cx.new(|_| ToastDemo {
toasts: toasts.clone(), layout_state: layout_state.clone(),
}) })
.into() .into()
} }
}), }));
cx, dock.register_panel(PanelDescriptor::new("dialog", "Dialog", {
);
dock.register_panel(
PanelDescriptor::new("dialog", "Dialog", {
let gallery = gallery.clone(); let gallery = gallery.clone();
move |_, cx| { move |_, cx| {
cx.new(|_| DialogDemo { cx.new(|_| DialogDemo {
@@ -600,118 +535,89 @@ impl Gallery {
}) })
.into() .into()
} }
}), }));
cx, dock.register_panel(PanelDescriptor::new(
); "command-palette",
dock.register_panel( "Command palette",
PanelDescriptor::new("command-palette", "Command palette", { {
let palette = palette.clone(); let palette = palette.clone();
move |_, cx| { move |_, cx| {
cx.new(|cx| CommandPaletteDemo::new(gallery.clone(), palette.clone(), cx)) cx.new(|cx| CommandPaletteDemo::new(gallery.clone(), palette.clone(), cx))
.into() .into()
} }
}), },
cx, ));
); dock.register_panel(PanelDescriptor::new("menu", "Menu", |_, cx| {
dock.register_panel(
PanelDescriptor::new("menu", "Menu", |_, cx| {
cx.new(|_| MenuDemo { open: false }).into() cx.new(|_| MenuDemo { open: false }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new(
); "dropdown-menu",
dock.register_panel( "Dropdown menu",
PanelDescriptor::new("dropdown-menu", "Dropdown menu", |_, cx| { |_, cx| {
cx.new(|_| DropdownMenuDemo { cx.new(|_| DropdownMenuDemo {
open_menu: None, open_menu: None,
status: None, status: None,
}) })
.into() .into()
}), },
cx, ));
); dock.register_panel(PanelDescriptor::new(
dock.register_panel( "context-menu",
PanelDescriptor::new("context-menu", "Context menu", |_, cx| { "Context menu",
|_, cx| {
cx.new(|_| ContextMenuDemo { cx.new(|_| ContextMenuDemo {
action: SharedString::from("Right-click the card"), action: SharedString::from("Right-click the card"),
}) })
.into() .into()
}), },
cx, ));
); dock.register_panel(PanelDescriptor::new("popover", "Popover", |_, cx| {
dock.register_panel(
PanelDescriptor::new("popover", "Popover", |_, cx| {
cx.new(|_| PopoverDemo { open: false }).into() cx.new(|_| PopoverDemo { open: false }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("tooltip", "Tooltip", |_, cx| {
); cx.new(|_| TooltipDemo).into()
dock.register_panel( }));
PanelDescriptor::new("tooltip", "Tooltip", |_, cx| cx.new(|_| TooltipDemo).into()), dock.register_panel(PanelDescriptor::new(
cx, "empty-state",
); "Empty state",
dock.register_panel( |_, cx| cx.new(|_| EmptyStateDemo).into(),
PanelDescriptor::new("empty-state", "Empty state", |_, cx| { ));
cx.new(|_| EmptyStateDemo).into() dock.register_panel(PanelDescriptor::new(
}), "scroll-area",
cx, "Scroll area",
); |_, cx| cx.new(|_| ScrollAreaDemo).into(),
dock.register_panel( ));
PanelDescriptor::new("scroll-area", "Scroll area", |_, cx| { dock.register_panel(PanelDescriptor::new("scrollbar", "Scrollbar", |_, cx| {
cx.new(|_| ScrollAreaDemo).into()
}),
cx,
);
dock.register_panel(
PanelDescriptor::new("scrollbar", "Scrollbar", |_, cx| {
cx.new(|_| ScrollbarDemo::new()).into() cx.new(|_| ScrollbarDemo::new()).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("code-view", "Code view", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("code-view", "Code view", |_, cx| {
cx.new(CodeViewDemo::new).into() cx.new(CodeViewDemo::new).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("diff-view", "Diff view", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("diff-view", "Diff view", |_, cx| {
cx.new(DiffViewDemo::new).into() cx.new(DiffViewDemo::new).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new(
); "markdown-view",
dock.register_panel( "Markdown view",
PanelDescriptor::new("markdown-view", "Markdown view", |_, cx| { |_, cx| cx.new(MarkdownViewDemo::new).into(),
cx.new(MarkdownViewDemo::new).into() ));
}), dock.register_panel(PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
cx,
);
dock.register_panel(
PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
cx.new(|_| SidebarDemo { selected: None }).into() cx.new(|_| SidebarDemo { selected: None }).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new(
); "resize-handle",
dock.register_panel( "Resize handle",
PanelDescriptor::new("resize-handle", "Resize handle", |_, cx| { |_, cx| cx.new(|_| ResizeDemo { width: px(200.) }).into(),
cx.new(|_| ResizeDemo { width: px(200.) }).into() ));
}), dock.register_panel(PanelDescriptor::new("resizable", "Resizable", |_, cx| {
cx,
);
dock.register_panel(
PanelDescriptor::new("resizable", "Resizable", |_, cx| {
cx.new(ResizableDemo::new).into() cx.new(ResizableDemo::new).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("separator", "Separator", |_, cx| {
);
dock.register_panel(
PanelDescriptor::new("separator", "Separator", |_, cx| {
cx.new(|_| SeparatorDemo).into() cx.new(|_| SeparatorDemo).into()
}), }));
cx, dock.register_panel(PanelDescriptor::new("theme", "Theme", |_, cx| {
); cx.new(|_| ThemeDemo).into()
dock.register_panel( }));
PanelDescriptor::new("theme", "Theme", |_, cx| cx.new(|_| ThemeDemo).into()),
cx,
);
} }
} }
@@ -739,8 +645,6 @@ impl Render for Gallery {
.child(dialog) .child(dialog)
.child(alert_dialog) .child(alert_dialog)
.when_some(self.palette.clone(), |root, palette| root.child(palette)) .when_some(self.palette.clone(), |root, palette| root.child(palette))
.child(AlertsView::new("gallery-alerts", self.alerts.clone()))
.child(ToastsView::new("gallery-toasts", self.toasts.clone()))
} }
} }
@@ -2309,10 +2213,10 @@ impl Render for TabsDemo {
}) })
.tabs(|_, _| { .tabs(|_, _| {
vec![ vec![
Tab::new("Overview"), Tab::new("overview", "Overview"),
Tab::new("Details"), Tab::new("details", "Details"),
Tab::new("Settings"), Tab::new("settings", "Settings"),
Tab::new("Locked").disabled(true), Tab::new("locked", "Locked").disabled(true),
] ]
}), }),
)) ))
@@ -2356,7 +2260,7 @@ impl Render for TabsDemo {
labels labels
.iter() .iter()
.map(|label| { .map(|label| {
Tab::new(label.clone()) Tab::new(label.clone(), label.clone())
.closeable(true) .closeable(true)
.busy(label == "Syncing") .busy(label == "Syncing")
}) })
@@ -2377,16 +2281,16 @@ impl Render for TabsDemo {
} }
struct AlertDemo { struct AlertDemo {
alerts: Entity<Alerts>, layout_state: Entity<LayoutState>,
dismissed: bool, dismissed: bool,
} }
impl Render for AlertDemo { impl Render for AlertDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity(); let view = cx.entity();
let alerts_info = self.alerts.clone(); let alerts_info = self.layout_state.clone();
let alerts_warning = self.alerts.clone(); let alerts_warning = self.layout_state.clone();
let alerts_clear = self.alerts.clone(); let alerts_clear = self.layout_state.clone();
let colors = cx.theme().colors; let colors = cx.theme().colors;
ScrollArea::new("alert-demo").size_full().p_4().child( ScrollArea::new("alert-demo").size_full().p_4().child(
@@ -2429,8 +2333,8 @@ impl Render for AlertDemo {
.label("Push info") .label("Push info")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
alerts_info.update(cx, |alerts, cx| { alerts_info.update(cx, |state, cx| {
alerts.push( state.push_alert(
Alert::new("alert-demo-pushed-info") Alert::new("alert-demo-pushed-info")
.title("Backed up") .title("Backed up")
.message("Everything is in sync.") .message("Everything is in sync.")
@@ -2445,8 +2349,8 @@ impl Render for AlertDemo {
.label("Push warning") .label("Push warning")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
alerts_warning.update(cx, |alerts, cx| { alerts_warning.update(cx, |state, cx| {
alerts.push( state.push_alert(
Alert::new("alert-demo-pushed-warning") Alert::new("alert-demo-pushed-warning")
.title("Storage almost full") .title("Storage almost full")
.message("1.2 GB remaining on this device.") .message("1.2 GB remaining on this device.")
@@ -2461,7 +2365,7 @@ impl Render for AlertDemo {
.label("Clear all") .label("Clear all")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
alerts_clear.update(cx, |alerts, cx| alerts.clear(cx)); alerts_clear.update(cx, |state, cx| state.clear_alerts(cx));
}), }),
), ),
)), )),
@@ -2470,14 +2374,14 @@ impl Render for AlertDemo {
} }
struct ToastDemo { struct ToastDemo {
toasts: Entity<Toasts>, layout_state: Entity<LayoutState>,
} }
impl Render for ToastDemo { impl Render for ToastDemo {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let toasts_undo = self.toasts.clone(); let toasts_undo = self.layout_state.clone();
let toasts_saved = self.toasts.clone(); let toasts_saved = self.layout_state.clone();
let toasts_connection = self.toasts.clone(); let toasts_connection = self.layout_state.clone();
let colors = cx.theme().colors; let colors = cx.theme().colors;
ScrollArea::new("toast-demo") ScrollArea::new("toast-demo")
@@ -2494,8 +2398,8 @@ impl Render for ToastDemo {
.label("Deleted (with undo)") .label("Deleted (with undo)")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toasts_undo.update(cx, |toasts, cx| { toasts_undo.update(cx, |state, cx| {
toasts.push( state.push_toast(
Toast::new("Draft deleted").action(ToastAction::new( Toast::new("Draft deleted").action(ToastAction::new(
"Undo", "Undo",
|cx| { |cx| {
@@ -2513,8 +2417,8 @@ impl Render for ToastDemo {
.label("Saved (with icon)") .label("Saved (with icon)")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toasts_saved.update(cx, |toasts, cx| { toasts_saved.update(cx, |state, cx| {
toasts.push( state.push_toast(
Toast::new("Workspace saved").icon(IconRole::Check), Toast::new("Workspace saved").icon(IconRole::Check),
cx, cx,
); );
@@ -2526,8 +2430,8 @@ impl Render for ToastDemo {
.label("Connection lost (sticky)") .label("Connection lost (sticky)")
.variant(ButtonVariant::Secondary) .variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| { .on_click(move |_, _, cx| {
toasts_connection.update(cx, |toasts, cx| { toasts_connection.update(cx, |state, cx| {
toasts.push( state.push_toast(
Toast::new("Connection lost") Toast::new("Connection lost")
.icon(IconRole::Warning) .icon(IconRole::Warning)
.duration(Duration::ZERO), .duration(Duration::ZERO),
@@ -3228,7 +3132,7 @@ impl Render for ScrollbarDemo {
.h_full() .h_full()
.w_full(), .w_full(),
) )
.child(Scrollbar::vertical(self.uniform.clone()).visibility(visibility)), .child(Scrollbar::vertical("uniform", self.uniform.clone()).visibility(visibility)),
)) ))
.child(demo_section( .child(demo_section(
"list", "list",
@@ -3254,7 +3158,7 @@ impl Render for ScrollbarDemo {
.h_full() .h_full()
.w_full(), .w_full(),
) )
.child(Scrollbar::vertical(self.list.clone()).visibility(visibility)), .child(Scrollbar::vertical("list", self.list.clone()).visibility(visibility)),
)) ))
.child( .child(
div() div()
@@ -4115,24 +4019,14 @@ fn main() {
cx.refresh_windows(); cx.refresh_windows();
}) })
.detach(); .detach();
let toasts = cx.new(|_| Toasts::default()); let gallery = cx.new(|_| Gallery {
let alerts = cx.new(|_| Alerts::default());
let gallery = cx.new(|cx| {
// Toast pushes and dismissals repaint the stack.
cx.observe(&toasts, |_, _, cx| cx.notify()).detach();
// Alert pushes and dismissals repaint it.
cx.observe(&alerts, |_, _, cx| cx.notify()).detach();
Gallery {
layout_state: layout_state.clone(), layout_state: layout_state.clone(),
dock_state: dock_state.clone(), dock_state: dock_state.clone(),
alerts: alerts.clone(),
toasts: toasts.clone(),
palette: None, palette: None,
palette_runs: Vec::new(), palette_runs: Vec::new(),
dialog_open: false, dialog_open: false,
alert_open: false, alert_open: false,
sidebar_transparent: true, sidebar_transparent: true,
}
}); });
let palette = cx.new(|cx| { let palette = cx.new(|cx| {
let matcher = |query: &str, commands: &[Command]| { let matcher = |query: &str, commands: &[Command]| {
@@ -4166,8 +4060,6 @@ fn main() {
Gallery::register_panels( Gallery::register_panels(
dock, dock,
layout_state.clone(), layout_state.clone(),
alerts.clone(),
toasts.clone(),
palette.clone(), palette.clone(),
gallery.downgrade(), gallery.downgrade(),
cx, cx,