From b0f10b4dbbe4389a6ecd5131c4db4e5dab72351a Mon Sep 17 00:00:00 2001 From: guyverino Date: Thu, 27 Aug 2026 16:31:25 +0200 Subject: [PATCH] fix(scroll-area): let inspector picking see through the scrollbar layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrollbar overlay stretches a transparent element across the whole viewport, purely so its two tracks have something to be absolute inside. Painted after the content, that element is the topmost hitbox at every point of the surface — and inspector picking takes the topmost hitbox that carries an inspector id. So the inspector answered "scrollbar" for every row of every list. Measured on a terminal that documents its own interface through the inspector: six scrollable surfaces — the Connections core table, the Screener and Orders tables, the Profit Monitor rows, the strategy tree and the detect feed — were all recorded at the size of their panel instead of their bar, and not one control inside any of them was ever named. Neither half of the obvious fix works. Dropping the layer's id changes only the name it reports: an InspectorElementId comes from the element's source location (element.rs), not from `.id()`. And a hitbox is not optional either, because picking deliberately gives one to every element so that any of them can be selected (div.rs). So gpui gains the missing third option beside `occlude` and `block_mouse_except_scroll`: an element may declare that picking looks straight through it. It changes nothing outside picking — a decorative layer has no listener, no hover style and no cursor, so it took no hitbox in an ordinary frame either way, and the mouse has always passed through it. --- crates/moon-gpui/src/elements/div.rs | 34 +++++++++++++- crates/moon-gpui/src/elements/div/tests.rs | 47 +++++++++++++++++++ .../src/moon/scroll_area.rs | 7 +++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/moon-gpui/src/elements/div.rs b/crates/moon-gpui/src/elements/div.rs index 004fd90..76f00b1 100644 --- a/crates/moon-gpui/src/elements/div.rs +++ b/crates/moon-gpui/src/elements/div.rs @@ -1127,6 +1127,28 @@ pub trait InteractiveElement: Sized { self } + /// Take this element out of inspector picking, so a click or a hover selects whatever is + /// beneath it. + /// + /// For an element that is there to POSITION other things rather than to be one: a transparent + /// layer stretched over a viewport so two scrollbar tracks can be absolute inside it, a spacer, + /// a gradient painted across a list. Picking has no way to tell those from a control, because + /// while it is on every element is given a hitbox — see [`Interactivity::should_insert_hitbox`] + /// — and the pick takes the topmost hitbox that carries an inspector id. Anything painted over + /// the content therefore answers for every point of it. + /// + /// Measured: a terminal that documents its own interface through the inspector could not name + /// one control inside any scrollable surface — six tables and lists came back as their + /// scrollbar, at the size of the whole panel. + /// + /// This changes NOTHING outside picking. A decorative layer has no listeners, no hover style + /// and no cursor, so it gets no hitbox in an ordinary frame either way, and the mouse has + /// always passed through it. + fn inspector_transparent(mut self) -> Self { + self.interactivity().inspector_transparent = true; + self + } + /// Block the mouse from all interactions with elements behind this element's hitbox. Typically /// `block_mouse_except_scroll` should be preferred. /// The fluent API equivalent to [`Interactivity::occlude_mouse`]. @@ -1859,6 +1881,12 @@ pub struct Interactivity { pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, pub(crate) hitbox_behavior: HitboxBehavior, + /// Whether inspector picking should look straight through this element. + /// + /// See [`InteractiveElement::inspector_transparent`]. Not behind `cfg(debug_assertions)`: a + /// `bool` on a struct this size costs nothing, and gating it would put a `cfg` on every element + /// that sets it. + pub(crate) inspector_transparent: bool, pub(crate) tab_index: Option, pub(crate) tab_group: bool, pub(crate) tab_stop: bool, @@ -2082,7 +2110,11 @@ impl Interactivity { || self.drag_listener.is_some() || !self.drop_listeners.is_empty() || self.tooltip_builder.is_some() - || window.is_inspector_picking(cx) + // Picking gives EVERY element a hitbox, so that any of them can be selected — except + // the ones that asked not to be. Without the exception a transparent layer laid over a + // list is the topmost hitbox at every point of it, and the inspector can only ever name + // the layer. See [`InteractiveElement::inspector_transparent`]. + || (window.is_inspector_picking(cx) && !self.inspector_transparent) } fn clamp_scroll_position( diff --git a/crates/moon-gpui/src/elements/div/tests.rs b/crates/moon-gpui/src/elements/div/tests.rs index 55f0b1b..e864314 100644 --- a/crates/moon-gpui/src/elements/div/tests.rs +++ b/crates/moon-gpui/src/elements/div/tests.rs @@ -448,3 +448,50 @@ fn tooltip_hides_after_mouse_leaves_origin() { assert!(active_tooltip.borrow().is_none()); } + +/// Catches dropping the `inspector_transparent` term from `Interactivity::should_insert_hitbox`, +/// which would put a hitbox back on every decorative overlay while the inspector is picking. +/// +/// The consequence is not visible and not local: picking takes the topmost hitbox that carries an +/// inspector id, so a transparent layer stretched over a list answers for every row under it. A +/// terminal that documents its own interface through the inspector could name nothing inside any +/// scrollable surface — six tables and lists all came back as their scrollbar, sized to the whole +/// panel. +#[test] +fn inspector_picking_looks_through_an_element_that_declared_itself_transparent() { + let mut test_app = TestAppContext::single(); + let window = test_app.add_window(move |_, _| TestTooltipView); + let any_window = window.into(); + + test_app + .update_window(any_window, |_, window, cx| { + let style = Style::default(); + let plain = Interactivity::default(); + let transparent = Interactivity { + inspector_transparent: true, + ..Default::default() + }; + + // Outside picking neither of them is interactive, so neither takes a hitbox — the + // half of the behaviour the flag must leave exactly as it was. + assert!(!plain.should_insert_hitbox(&style, window, cx)); + assert!(!transparent.should_insert_hitbox(&style, window, cx)); + + // `Inspector::new` starts in picking mode, which is what gives every element a hitbox + // so that any of them can be selected. + window.toggle_inspector(cx); + assert!( + window.is_inspector_picking(cx), + "the inspector opens already picking" + ); + assert!( + plain.should_insert_hitbox(&style, window, cx), + "picking offers every element" + ); + assert!( + !transparent.should_insert_hitbox(&style, window, cx), + "except one that asked to be looked through" + ); + }) + .unwrap(); +} diff --git a/crates/moon-ui-components/src/moon/scroll_area.rs b/crates/moon-ui-components/src/moon/scroll_area.rs index 40291d1..65e46e3 100644 --- a/crates/moon-ui-components/src/moon/scroll_area.rs +++ b/crates/moon-ui-components/src/moon/scroll_area.rs @@ -183,8 +183,15 @@ pub fn moon_scrollbar_overlay_with_palette( window.request_animation_frame(); } + // Transparent to picking, because this layer is not a control: it spans the whole viewport for + // the sole purpose of giving the two tracks something to be absolute inside, and it carries no + // handler, no hover style and no state of its own. Painted after the content, it would + // otherwise be the topmost hitbox at every point of a list — and the inspector would answer + // "scrollbar" for every row in it. The track and the thumb below keep their own ids and are + // what a pointer is meant to find. let mut layer = div() .id(ElementId::from(id.clone())) + .inspector_transparent() .absolute() .top_0() .right_0()