diff --git a/crates/core/src/combobox.rs b/crates/core/src/combobox.rs index 2e19081..4bae38b 100644 --- a/crates/core/src/combobox.rs +++ b/crates/core/src/combobox.rs @@ -510,8 +510,12 @@ where if state.highlight.is_some() { state.highlight = None; } else { - let query = state.query.read(cx).value().to_string(); - state.refilter(&query); + if state.selected.is_some() { + state.refilter(""); + } else { + let query = state.query.read(cx).value().to_string(); + state.refilter(&query); + } state.highlight = (!state.visible.is_empty()).then_some(0usize); } cx.notify(); diff --git a/crates/core/src/command_palette.rs b/crates/core/src/command_palette.rs index 1fc6568..da6a388 100644 --- a/crates/core/src/command_palette.rs +++ b/crates/core/src/command_palette.rs @@ -1,14 +1,13 @@ //! The command palette: a modal query field over the application's actions. -use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, - KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, - ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, - actions, canvas, div, px, relative, + AnyElement, App, Context, ElementId, Entity, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement as _, Render, Role, ScrollHandle, SharedString, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, deferred, + div, px, relative, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; @@ -322,6 +321,7 @@ impl Render for CommandPaletteState { let id = self.id.clone(); let state = cx.entity(); + let query_focus = self.query.read(cx).focus_handle(); let rows: Vec = self .matches @@ -406,9 +406,6 @@ impl Render for CommandPaletteState { .into_any_element() }; - let panel_bounds: Rc>>> = Rc::new(RefCell::new(None)); - let query_focus = self.query.read(cx).focus_handle(); - let panel = v_flex() .id((id.clone(), "panel")) .debug_selector({ @@ -460,20 +457,14 @@ impl Render for CommandPaletteState { ) .child(rows) .child( - canvas( - { - let cell = panel_bounds.clone(); - move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) - }, - { - let focus = query_focus.clone(); - move |_, _, window, cx| { - if !focus.contains_focused(window, cx) { - window.focus(&focus, cx); - } + canvas(|_, _, _| {}, { + let focus = query_focus.clone(); + move |_, _, window, cx| { + if !focus.contains_focused(window, cx) { + window.focus(&focus, cx); } - }, - ) + } + }) .absolute() .inset_0(), ); @@ -485,28 +476,23 @@ impl Render for CommandPaletteState { .occlude() .on_mouse_down(MouseButton::Left, { let state = state.clone(); - let panel_bounds = panel_bounds.clone(); - move |event: &MouseDownEvent, _window, cx| { - let on_panel = panel_bounds - .borrow() - .is_some_and(|bounds| bounds.contains(&event.position)); - if on_panel { - return; - } + move |_, _window, cx| { state.update(cx, |state, cx| state.close(cx)); cx.stop_propagation(); } }); - div() - .absolute() - .inset_0() - .flex() - .justify_center() - .items_start() - .pt_10() - .child(scrim) - .child(panel) - .into_any_element() + deferred( + div() + .absolute() + .inset_0() + .flex() + .justify_center() + .items_start() + .pt_10() + .child(scrim) + .child(panel), + ) + .into_any_element() } } diff --git a/crates/core/src/dropdown_menu.rs b/crates/core/src/dropdown_menu.rs index 26a154b..d890356 100644 --- a/crates/core/src/dropdown_menu.rs +++ b/crates/core/src/dropdown_menu.rs @@ -207,7 +207,8 @@ impl RenderOnce for DropdownMenu { let a11y_label = self.a11y_label.or_else(|| self.label.clone()); 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. - 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 focus = focus.clone(); diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index b9c7015..d7ebb8d 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -1799,7 +1799,9 @@ impl EntityInputHandler for InputState { ) -> Option { let layout = self.layout.borrow(); 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( @@ -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. fn utf8_range_to_utf16(text: &str, range: Range) -> Range { - 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. diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index cc5d414..70b5d27 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -382,7 +382,7 @@ pub(crate) fn anchored_menu_panel( chrome: &MenuChrome, enter_duration: std::time::Duration, ) -> Option { - 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 { 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(); // Keyboard focus rides the panel across frames let focus = gpui_native_utils::focus_handle(&(id.clone(), "panel").into(), window, cx); - let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new()); - let selection = window.use_keyed_state("menu-selection", cx, |_, _| None); + let scroll = + window.use_keyed_state((id.clone(), "menu-scroll"), cx, |_, _| ScrollHandle::new()); + let selection = window.use_keyed_state((id.clone(), "menu-selection"), cx, |_, _| None); let selected = if *was_open.read(cx) { *selection.read(cx) diff --git a/crates/core/src/scrollbar.rs b/crates/core/src/scrollbar.rs index d63d77c..6ccbd6a 100644 --- a/crates/core/src/scrollbar.rs +++ b/crates/core/src/scrollbar.rs @@ -128,33 +128,35 @@ pub struct Scrollbar { axis: Axis, handle: Rc, visibility: ScrollbarVisibility, - key: ElementId, + id: ElementId, } impl Scrollbar { - /// A vertical scrollbar driven by `handle`. - pub fn vertical(handle: H) -> Self + /// A vertical scrollbar named by `id`, driven by `handle`. + pub fn vertical(id: I, handle: H) -> Self where + I: Into, H: ScrollbarHandle, { Self { axis: Axis::Vertical, handle: Rc::new(handle), visibility: ScrollbarVisibility::default(), - key: ElementId::Name("scrollbar".into()), + id: id.into(), } } - /// A horizontal scrollbar driven by `handle`. - pub fn horizontal(handle: H) -> Self + /// A horizontal scrollbar named by `id`, driven by `handle`. + pub fn horizontal(id: I, handle: H) -> Self where + I: Into, H: ScrollbarHandle, { Self { axis: Axis::Horizontal, handle: Rc::new(handle), visibility: ScrollbarVisibility::default(), - key: ElementId::Name("scrollbar".into()), + id: id.into(), } } @@ -163,12 +165,6 @@ impl Scrollbar { self.visibility = visibility; self } - - /// Names this scrollbar's state. - pub fn key(mut self, key: impl Into) -> Self { - self.key = key.into(); - self - } } impl RenderOnce for Scrollbar { @@ -177,8 +173,8 @@ impl RenderOnce for Scrollbar { let handle = self.handle; let axis = self.axis; let visibility = self.visibility; - let key = self.key; - move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &key, window, cx) + let id = self.id; + move |_, _, window, cx| paint_scrollbar(&handle, axis, visibility, &id, window, cx) }) .absolute() .inset_0() @@ -192,7 +188,7 @@ fn paint_scrollbar( handle: &Rc, axis: Axis, visibility: ScrollbarVisibility, - key: &ElementId, + id: &ElementId, window: &mut Window, cx: &mut App, ) { @@ -217,12 +213,10 @@ fn paint_scrollbar( ScrollbarVisibility::Always => false, ScrollbarVisibility::Autohide => true, }; - 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 (thumb_length, thumb_travel) = thumb_geometry( viewport_along, viewport_along + max_offset, @@ -267,9 +261,7 @@ fn paint_scrollbar( } }; - let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", key), cx, |_, _| { - Drag::None - }); + let drag = window.use_keyed_state(scrollbar_state_key("thumb-drag", id), cx, |_, _| Drag::None); let dragging = drag.read(cx).is_some(); let alpha = match &autohide_state { diff --git a/crates/core/src/slider.rs b/crates/core/src/slider.rs index 3a0173e..e246226 100644 --- a/crates/core/src/slider.rs +++ b/crates/core/src/slider.rs @@ -135,16 +135,12 @@ impl RenderOnce for Slider { 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::>)); - // 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 dragging = + window.use_keyed_state((self.id.clone(), "slider-dragging"), cx, |_, _| false); + let set_value: ValueSetter = { let bounds_cell = bounds_cell.clone(); let on_change = self.on_change.clone(); diff --git a/crates/core/tests/dropdown_menu.rs b/crates/core/tests/dropdown_menu.rs index 227b6c1..7f84de9 100644 --- a/crates/core/tests/dropdown_menu.rs +++ b/crates/core/tests/dropdown_menu.rs @@ -322,3 +322,136 @@ fn disabled_trigger_ignores_clicks() { assert!(changes.borrow().is_empty()); 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) -> 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()); +} diff --git a/crates/core/tests/scrollbar.rs b/crates/core/tests/scrollbar.rs index 65fa880..d84c94d 100644 --- a/crates/core/tests/scrollbar.rs +++ b/crates/core/tests/scrollbar.rs @@ -38,7 +38,7 @@ impl Render for UniformShell { .h_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() .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" ); } + +struct TwoShell { + left: UniformListScrollHandle, + right: UniformListScrollHandle, +} + +impl Render for TwoShell { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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" + ); +} diff --git a/crates/core/tests/slider.rs b/crates/core/tests/slider.rs index a03db3a..d3df044 100644 --- a/crates/core/tests/slider.rs +++ b/crates/core/tests/slider.rs @@ -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]); } + +struct TwoSliders { + changes_a: Rc>>, + changes_b: Rc>>, + value_a: f32, + value_b: f32, +} + +impl Render for TwoSliders { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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>>, + Rc>>, +) { + 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" + ); +} diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs index 841ff81..9ae9d69 100644 --- a/crates/data/src/tabs.rs +++ b/crates/data/src/tabs.rs @@ -32,6 +32,8 @@ pub enum TabsVariant { /// One tab of a [`Tabs`] strip. pub struct Tab { + /// The stable identity for element state; must survive reordering. + key: ElementId, label: SharedString, icon: Option, lead: Option, @@ -42,12 +44,14 @@ pub struct Tab { } impl Tab { - /// Creates a tab with this label. - pub fn new(label: L) -> Self + /// Creates a tab shown as `label`, identified by `key`. + pub fn new(key: K, label: L) -> Self where + K: Into, L: Into, { Self { + key: key.into(), label: label.into(), icon: None, lead: None, @@ -301,7 +305,7 @@ fn render_tab( }; let mut strip_tab = div() - .id(("tab", index)) + .id((tab.key.clone(), "tab")) .debug_selector({ let id = format!("{tabs_id}-tab-{}", tab.label); move || id @@ -364,7 +368,7 @@ fn render_tab( if tab.busy && !tab.disabled { strip_tab = strip_tab.child( - Spinner::new(("tab-busy", index)) + Spinner::new((tab.key.clone(), "tab-busy")) .small() .accessibility_label("Loading"), ); @@ -401,7 +405,7 @@ fn render_tab_close( index: usize, on_close: &Option, ) -> AnyElement { - Button::new(("tab-close", index)) + Button::new((tab.key.clone(), "tab-close")) .icon(IconRole::Close) .variant(ButtonVariant::Ghost) .small() diff --git a/crates/data/tests/tabs.rs b/crates/data/tests/tabs.rs index 2d8d555..dbeeaae 100644 --- a/crates/data/tests/tabs.rs +++ b/crates/data/tests/tabs.rs @@ -30,10 +30,10 @@ impl Render for Harness { }) .tabs(|_, _| { vec![ - Tab::new("One"), - Tab::new("Two"), - Tab::new("Off").disabled(true), - Tab::new("End"), + Tab::new("one", "One"), + Tab::new("two", "Two"), + Tab::new("off", "Off").disabled(true), + Tab::new("end", "End"), ] }), ) diff --git a/crates/editor/src/code_view.rs b/crates/editor/src/code_view.rs index 7520d38..b0335a1 100644 --- a/crates/editor/src/code_view.rs +++ b/crates/editor/src/code_view.rs @@ -13,7 +13,7 @@ use gpui::{ use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility}; 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}; actions!(code_view, [Copy]); @@ -53,8 +53,6 @@ pub struct CodeViewState { source: SharedString, language: Language, code: HighlightedCode, - /// The parse tree of `source`, so updates reparse incrementally instead of from scratch. - tree: Option, handle: UniformListScrollHandle, /// The hovered line, driving the active-line wash and number emphasis. active_line: Option, @@ -74,14 +72,13 @@ impl CodeViewState { S: Into, { let source = source.into(); - let (code, tree) = highlight_with_tree(&source, language, None); + let code = highlight(&source, language); Self { id: id.into(), source, language, code, - tree, handle: UniformListScrollHandle::new(), active_line: None, selection: None, @@ -98,18 +95,9 @@ impl CodeViewState { { let source = source.into(); - // The old tree is only reusable when the grammar stays the same. - let old_tree = if language == self.language { - self.tree.as_ref() - } else { - None - }; - - let (code, tree) = highlight_with_tree(&source, language, old_tree); + self.code = highlight(&source, language); self.source = source; self.language = language; - self.code = code; - self.tree = tree; self.selection = None; self.handle.scroll_to_item(0, ScrollStrategy::Top); @@ -134,9 +122,7 @@ impl CodeViewState { lines.splice(line_ix..line_ix + 1, replacement); 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 = code; - self.tree = tree; + self.code = highlight(&self.source, self.language); self.selection = None; cx.notify(); } @@ -348,7 +334,7 @@ impl Render for CodeViewState { code.position_at( *bounds, event.position, - Pixels::ZERO, + ScrollbarHandle::offset(&code.handle).y, line_height, char_width, text_x, @@ -453,7 +439,8 @@ impl Render for CodeViewState { ) .child(rows) .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| { view.aria_label(label) diff --git a/crates/editor/src/diff_view.rs b/crates/editor/src/diff_view.rs index 674b4cb..13e67cb 100644 --- a/crates/editor/src/diff_view.rs +++ b/crates/editor/src/diff_view.rs @@ -363,13 +363,11 @@ impl Render for DiffViewState { }) .child(rows) .child( - Scrollbar::vertical(self.handle.clone()) - .key("v-scrollbar") + Scrollbar::vertical((self.id.clone(), "v-scrollbar"), self.handle.clone()) .visibility(ScrollbarVisibility::Platform), ) .child( - Scrollbar::horizontal(self.handle.clone()) - .key("h-scrollbar") + Scrollbar::horizontal((self.id.clone(), "h-scrollbar"), self.handle.clone()) .visibility(ScrollbarVisibility::Platform), ) } diff --git a/crates/editor/src/highlight.rs b/crates/editor/src/highlight.rs index 9ccfbd2..45b87ae 100644 --- a/crates/editor/src/highlight.rs +++ b/crates/editor/src/highlight.rs @@ -67,17 +67,8 @@ pub struct HighlightedCode { /// Highlights `source` with `language`'s tree-sitter grammar. 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) { let Some((grammar, query_source)) = grammar_for(language) else { - return (plain(source), None); + return plain(source); }; HIGHLIGHTERS.with(|cache| { @@ -104,11 +95,11 @@ pub fn highlight_with_tree( .as_mut(); let Some(highlighter) = highlighter else { - return (plain(source), None); + return plain(source); }; - let Some(tree) = highlighter.parser.parse(source, old_tree) else { - return (plain(source), None); + let Some(tree) = highlighter.parser.parse(source, None) else { + return plain(source); }; // 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) }) } diff --git a/crates/editor/src/markdown.rs b/crates/editor/src/markdown.rs index 924df75..6c5b0e5 100644 --- a/crates/editor/src/markdown.rs +++ b/crates/editor/src/markdown.rs @@ -18,7 +18,7 @@ use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, T 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}; /// Heading scale steps relative to the root font size, H1 first. @@ -277,7 +277,8 @@ impl Render for MarkdownViewState { .size_full() .child(blocks) .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| { view.aria_label(label) @@ -588,7 +589,7 @@ fn parse(source: &str, highlight_code: bool) -> Vec { .then(|| language.as_deref().and_then(grammar_language)) .flatten() { - Some(grammar) => highlight_with_tree(text, grammar, None).0, + Some(grammar) => highlight(text, grammar), None => plain(text), }; blocks.push(MarkdownBlock::Code { diff --git a/crates/layout/src/dock.rs b/crates/layout/src/dock.rs index d4de65d..46d5847 100644 --- a/crates/layout/src/dock.rs +++ b/crates/layout/src/dock.rs @@ -1016,7 +1016,7 @@ fn render_pane( move |_, _| { tabs.iter() .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 { entry = entry.icon(role); } diff --git a/crates/layout/src/resize_handle.rs b/crates/layout/src/resize_handle.rs index a02d99f..377cad1 100644 --- a/crates/layout/src/resize_handle.rs +++ b/crates/layout/src/resize_handle.rs @@ -255,6 +255,14 @@ impl RenderOnce for ResizeHandle { return; } + if event.pressed_button != Some(MouseButton::Left) { + drag.update(cx, |drag, cx| { + drag.dragging = false; + cx.notify(); + }); + return; + } + let delta = match axis { Axis::Horizontal => event.position.x - last.x, Axis::Vertical => event.position.y - last.y, diff --git a/crates/layout/tests/resize_handle.rs b/crates/layout/tests/resize_handle.rs index 860f4d9..a53df18 100644 --- a/crates/layout/tests/resize_handle.rs +++ b/crates/layout/tests/resize_handle.rs @@ -151,6 +151,28 @@ fn moves_without_a_drag_report_nothing() { 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] fn arrow_keys_move_the_seam_when_focused() { let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false); diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 6f1a053..23dbe46 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -2309,10 +2309,10 @@ impl Render for TabsDemo { }) .tabs(|_, _| { vec![ - Tab::new("Overview"), - Tab::new("Details"), - Tab::new("Settings"), - Tab::new("Locked").disabled(true), + Tab::new("overview", "Overview"), + Tab::new("details", "Details"), + Tab::new("settings", "Settings"), + Tab::new("locked", "Locked").disabled(true), ] }), )) @@ -2356,7 +2356,7 @@ impl Render for TabsDemo { labels .iter() .map(|label| { - Tab::new(label.clone()) + Tab::new(label.clone(), label.clone()) .closeable(true) .busy(label == "Syncing") }) @@ -3228,7 +3228,7 @@ impl Render for ScrollbarDemo { .h_full() .w_full(), ) - .child(Scrollbar::vertical(self.uniform.clone()).visibility(visibility)), + .child(Scrollbar::vertical("uniform", self.uniform.clone()).visibility(visibility)), )) .child(demo_section( "list", @@ -3254,7 +3254,7 @@ impl Render for ScrollbarDemo { .h_full() .w_full(), ) - .child(Scrollbar::vertical(self.list.clone()).visibility(visibility)), + .child(Scrollbar::vertical("list", self.list.clone()).visibility(visibility)), )) .child( div()