Fix shared element state across component instances

This commit is contained in:
2026-11-09 14:04:25 +07:00
parent f022f08947
commit 73f7dc31d4
20 changed files with 489 additions and 136 deletions
+4
View File
@@ -509,9 +509,13 @@ 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 = (!state.visible.is_empty()).then_some(0usize);
} }
cx.notify(); cx.notify();
+11 -25
View File
@@ -1,14 +1,13 @@
//! 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, px, 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 _, Size};
@@ -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
@@ -406,9 +406,6 @@ impl Render for CommandPaletteState {
.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({
@@ -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()
} }
} }
+2 -1
View File
@@ -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();
+6 -2
View File
@@ -1799,7 +1799,9 @@ impl EntityInputHandler for InputState {
) -> Option<usize> { ) -> Option<usize> {
let layout = self.layout.borrow(); let layout = self.layout.borrow();
let layout = layout.as_ref()?; let layout = layout.as_ref()?;
Some(real_index(&layout.mask_map, layout.index_at(window_point)).min(self.text.len())) let index =
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(
@@ -2516,7 +2518,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.
+4 -3
View File
@@ -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)
+14 -22
View File
@@ -128,33 +128,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 +165,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 +173,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 +188,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 +213,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 +261,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 {
+3 -7
View File
@@ -135,16 +135,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();
+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());
}
+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"
);
}
+9 -5
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,
@@ -301,7 +305,7 @@ 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 id = format!("{tabs_id}-tab-{}", tab.label);
move || id move || id
@@ -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,7 +405,7 @@ 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()
+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"),
] ]
}), }),
) )
+7 -20
View File
@@ -13,7 +13,7 @@ use gpui::{
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, 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();
} }
@@ -348,7 +334,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 +439,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)
+2 -4
View File
@@ -363,13 +363,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),
) )
} }
+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)
}) })
} }
+4 -3
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.
@@ -277,7 +277,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)
@@ -588,7 +589,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec<MarkdownBlock> {
.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 {
+1 -1
View File
@@ -1016,7 +1016,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);
} }
+8
View File
@@ -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,
+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);
+7 -7
View File
@@ -2309,10 +2309,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 +2356,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")
}) })
@@ -3228,7 +3228,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 +3254,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()