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()