From bee77eaa61ae23e5661a07edb9913e708f3b223c Mon Sep 17 00:00:00 2001 From: Oleg Kossoy Date: Sat, 5 Sep 2026 09:23:56 +0000 Subject: [PATCH 1/3] text_selection: drive self-scrolling participants directly during drag auto-scroll `update_auto_scroll` synthesized a wheel event at a position clamped inside the anchor participant's content mask. For a scrollable `TextView` whose list ends inside that mask (vertical padding on the view, or any layout where the view does not reach its clipping ancestor's edge), the clamped position lands in the band between the list's bottom and the mask's bottom, the list hitbox is not in the hit-test set, and the wheel never scrolls it. Registrations now carry a `self_scroll` flag; `TextView` sets it when `scrollable`. Dispatch is exclusive: a self-scrolling participant is notified through `TextSelectionEvent::AutoScroll` with a delta measured against its own bounds (as `update_participant_auto_scroll` already did), and the synthetic wheel is reserved for participants that scroll through an ancestor. Exactly one timer writes the list. `update_auto_scroll` takes `&Window`; the anchor lookup is shared by `anchor_participant` / `anchor_registration`. Tests: the existing harness test asserts a magnitude bound (ticks x per-frame delta) so a second writer fails it; a new test reproduces the padded reader layout and fails on main. --- crates/base/src/text/selection_adapter.rs | 5 +- crates/base/src/text/text_view.rs | 4 +- crates/base/src/text_selection.rs | 79 +++++--- crates/component/src/text/window_selection.rs | 172 ++++++++++++++---- 4 files changed, 191 insertions(+), 69 deletions(-) diff --git a/crates/base/src/text/selection_adapter.rs b/crates/base/src/text/selection_adapter.rs index d1b2df7e17..dbe9c3078b 100644 --- a/crates/base/src/text/selection_adapter.rs +++ b/crates/base/src/text/selection_adapter.rs @@ -190,12 +190,14 @@ impl TextViewSelectionAdapter { self.text_bounds.extend(bounds); } + #[allow(clippy::too_many_arguments)] pub(super) fn register( &self, hitbox: Hitbox, bounds: Bounds, scroll_offset: Point, document_order: u64, + self_scroll: bool, window: &mut Window, cx: &mut App, ) { @@ -203,7 +205,8 @@ impl TextViewSelectionAdapter { TextSelectionRegistration::new(hitbox, bounds) .with_scroll_offset(scroll_offset) .with_document_order(document_order) - .with_text_bounds(self.text_bounds.clone()), + .with_text_bounds(self.text_bounds.clone()) + .with_self_scroll(self_scroll), window, cx, ); diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index caed75b42f..496884c9f7 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -710,12 +710,13 @@ impl Element for TextView { GlobalState::global_mut(cx).text_view_state_stack.pop(); if self.selectable { - let (adapter, scroll_offset, content_bounds) = { + let (adapter, scroll_offset, content_bounds, self_scroll) = { let state = state.read(cx); ( state.selection_adapter.clone(), state.scroll_offset(), state.bounds(), + state.scrollable, ) }; let document_order = GlobalState::global_mut(cx).next_selection_document_order(); @@ -724,6 +725,7 @@ impl Element for TextView { content_bounds, scroll_offset, document_order, + self_scroll, window, cx, ); diff --git a/crates/base/src/text_selection.rs b/crates/base/src/text_selection.rs index 180812e8b9..f037346186 100644 --- a/crates/base/src/text_selection.rs +++ b/crates/base/src/text_selection.rs @@ -208,6 +208,7 @@ pub struct TextSelectionRegistration { scope: TextSelectionScopeId, document_order: u64, text_bounds: Vec>, + self_scroll: bool, } impl TextSelectionRegistration { @@ -220,9 +221,19 @@ impl TextSelectionRegistration { scope: TextSelectionScopeId::default(), document_order: 0, text_bounds: Vec::new(), + self_scroll: false, } } + /// Marks a participant that scrolls its own content in response to + /// [`TextSelectionEvent::AutoScroll`]. Drag auto-scroll then drives it + /// directly, measured against its own bounds, instead of synthesizing a + /// wheel event for the nearest scrollable ancestor. + pub fn with_self_scroll(mut self, self_scroll: bool) -> Self { + self.self_scroll = self_scroll; + self + } + /// Sets the participant's content scroll offset. pub fn with_scroll_offset(mut self, scroll_offset: Point) -> Self { self.scroll_offset = scroll_offset; @@ -272,6 +283,11 @@ impl TextSelectionRegistration { self.document_order } + /// Returns whether the participant scrolls its own content on auto-scroll. + pub const fn self_scroll(&self) -> bool { + self.self_scroll + } + /// Returns the glyph-bearing bounds used to reject blank-only gestures. pub fn text_bounds(&self) -> &[Bounds] { &self.text_bounds @@ -1142,7 +1158,7 @@ impl WindowSelectionState { ) { if !cx.has_active_drag() { self.update_impl(position, Some(window), cx); - self.update_auto_scroll(position, Some(window), cx); + self.update_auto_scroll(position, window, cx); } } @@ -1455,7 +1471,7 @@ impl WindowSelectionState { fn update_auto_scroll( &mut self, position: Point, - window: Option<&Window>, + window: &Window, cx: &mut Context, ) { // A finished gesture keeps its anchor for shift-click extension; only @@ -1463,20 +1479,21 @@ impl WindowSelectionState { if !self.is_selecting { return; } - let Some(anchor) = self.anchor.as_ref().filter(|anchor| anchor.inside) else { - return; - }; - let Some(participant) = anchor.participant.as_ref().and_then(WeakEntity::upgrade) else { + let Some((_, registration)) = self.anchor_registration() else { return; }; - let Some(registration) = self.participants.get(&participant.entity_id()) else { + // Exactly one writer per drag: a participant that scrolls its own + // content is notified directly; anything else gets a synthetic wheel. + if registration.self_scroll { + self.auto_scroll.stop(); + self.update_participant_auto_scroll(position, cx); return; - }; + } // The content mask is the nearest clipping viewport established by a // scrollable ancestor. It remains stable as the participant itself // moves, so selection keeps scrolling the same related region even // after the anchor text has moved out of view. - let visible_bounds = registration.registration.hitbox.content_mask.bounds; + let visible_bounds = registration.hitbox.content_mask.bounds; // Keeps the synthesized wheel event hit-testing inside the mask. const HIT_TEST_INSET: Pixels = px(1.); // A collapsed mask leaves an empty clamp range below — stop. @@ -1487,11 +1504,6 @@ impl WindowSelectionState { return; } let delta = AutoScroll::compute_delta(position.y, visible_bounds); - let Some(window) = window else { - participant.update(cx, |state, cx| state.set_auto_scroll(delta, cx)); - return; - }; - let event_position = point( position.x.clamp( visible_bounds.left() + HIT_TEST_INSET, @@ -1525,34 +1537,43 @@ impl WindowSelectionState { }); } + /// Drives the anchor participant's own scrolling, measured against the + /// participant's element bounds rather than any ancestor viewport. fn update_participant_auto_scroll(&self, position: Point, cx: &mut App) { - let Some(anchor) = self.anchor.as_ref().filter(|anchor| anchor.inside) else { + let Some((participant, registration)) = self.anchor_registration() else { return; }; - let Some(participant) = anchor.participant.as_ref().and_then(WeakEntity::upgrade) else { - return; - }; - let Some(registration) = self.participants.get(&participant.entity_id()) else { - return; - }; - let delta = AutoScroll::compute_delta(position.y, registration.registration.bounds); + let delta = AutoScroll::compute_delta(position.y, registration.bounds); participant.update(cx, |state, cx| state.set_auto_scroll(delta, cx)); } fn stop_anchor_auto_scroll(&mut self, cx: &mut App) { self.auto_scroll.stop(); - let Some(participant) = self - .anchor - .as_ref() - .filter(|anchor| anchor.inside) - .and_then(|anchor| anchor.participant.as_ref()) - .and_then(WeakEntity::upgrade) - else { + let Some(participant) = self.anchor_participant() else { return; }; participant.update(cx, |state, cx| state.set_auto_scroll(None, cx)); } + /// The live participant owning the anchor of the current gesture. + fn anchor_participant(&self) -> Option> { + self.anchor + .as_ref() + .filter(|anchor| anchor.inside)? + .participant + .as_ref()? + .upgrade() + } + + /// The anchor participant together with its current frame registration. + fn anchor_registration( + &self, + ) -> Option<(Entity, Rc)> { + let participant = self.anchor_participant()?; + let registration = self.participants.get(&participant.entity_id())?; + Some((participant, registration.registration.clone())) + } + fn prune_dead_participants(&mut self) { self.participants .retain(|_, registration| registration.participant.upgrade().is_some()); diff --git a/crates/component/src/text/window_selection.rs b/crates/component/src/text/window_selection.rs index 84a1994e33..308397464f 100644 --- a/crates/component/src/text/window_selection.rs +++ b/crates/component/src/text/window_selection.rs @@ -11,11 +11,11 @@ mod tests { GlobalElementId, Hitbox, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Render, SharedString, Styled as _, StyledText, TestAppContext, VisualTestContext, Window, - div, point, px, + div, point, px, relative, }; use gpui_base::{ - TextSelection, TextSelectionHandle, TextSelectionRegistration, TextSelectionRun, - TextSelectionScopeId, + AutoScroll, TextSelection, TextSelectionHandle, TextSelectionRegistration, + TextSelectionRun, TextSelectionScopeId, }; use std::cell::Cell; use std::rc::Rc; @@ -704,6 +704,10 @@ mod tests { text_view: Entity, } + struct PaddedReaderAutoScrollTest { + text_view: Entity, + } + struct PaddedScrollableTextViewTest { text_view: Entity, } @@ -756,6 +760,50 @@ mod tests { } } + /// Scroll offset in pixels from the top of the list (positive = scrolled down). + fn list_scroll_px(text_view: &Entity, cx: &mut VisualTestContext) -> Pixels { + text_view.read_with(cx, |state, _| { + -state.list_state().scroll_px_offset_for_scrollbar().y + }) + } + + /// Drags from `start` to `edge`, holds for `ticks` auto-scroll frames, and + /// returns how far the list moved. One 16 ms timer drives the list, so the + /// distance is bounded by `ticks` times the per-frame delta; a second + /// writer shows up as an overshoot. + fn drag_and_hold( + text_view: &Entity, + start: gpui::Point, + edge: gpui::Point, + ticks: u32, + cx: &mut VisualTestContext, + ) -> Pixels { + let before = list_scroll_px(text_view, cx); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move(edge, Some(MouseButton::Left), Modifiers::default()); + cx.executor() + .advance_clock(Duration::from_millis(16 * u64::from(ticks))); + cx.run_until_parked(); + let after = list_scroll_px(text_view, cx); + cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default()); + after - before + } + + fn assert_auto_scroll_magnitude(moved: Pixels, per_tick: Pixels, ticks: u32, label: &str) { + let max = per_tick * ticks as f32; + if per_tick > px(0.) { + assert!( + moved > px(0.) && moved <= max, + "{label}: moved {moved:?}, expected within (0, {max:?}] for {ticks} ticks of {per_tick:?}" + ); + } else { + assert!( + moved < px(0.) && moved >= max, + "{label}: moved {moved:?}, expected within [{max:?}, 0) for {ticks} ticks of {per_tick:?}" + ); + } + } + #[gpui::test] fn compatibility_text_view_drag_selection_auto_scrolls_both_directions( cx: &mut TestAppContext, @@ -788,30 +836,17 @@ mod tests { let bounds = cx .debug_bounds("scrollable-text-view") .expect("scrollable TextView bounds"); - let before = view.read_with(cx, |view, cx| { - let state = view.text_view.read(cx); - let offset = state.list_state().logical_scroll_top(); - (offset.item_ix, offset.offset_in_item) - }); + // The TextView fills the viewport, so the per-frame delta the drag + // requests is the one measured against the viewport edge. + const TICKS: u32 = 4; let start = point(bounds.left() + px(30.), bounds.top() + px(30.)); let edge = point(bounds.left() + px(60.), bounds.bottom() - px(2.)); - cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); - cx.simulate_mouse_move(edge, Some(MouseButton::Left), Modifiers::default()); - cx.executor().advance_clock(Duration::from_millis(64)); - cx.run_until_parked(); + let per_tick = AutoScroll::compute_delta(edge.y, bounds).expect("edge zone"); + let text_view = view.read_with(cx, |view, _| view.text_view.clone()); + let moved = drag_and_hold(&text_view, start, edge, TICKS, cx); + assert_auto_scroll_magnitude(moved, per_tick, TICKS, "downward"); - let after = view.read_with(cx, |view, cx| { - let offset = view.text_view.read(cx).list_state().logical_scroll_top(); - (offset.item_ix, offset.offset_in_item) - }); - cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default()); - assert_ne!( - before, after, - "dragging at the viewport edge must auto-scroll" - ); - - let list_state = - view.read_with(cx, |view, cx| view.text_view.read(cx).list_state().clone()); + let list_state = text_view.read_with(cx, |state, _| state.list_state().clone()); list_state.scroll_to(ListOffset { item_ix: 99, offset_in_item: px(0.), @@ -819,24 +854,85 @@ mod tests { cx.update(|window, cx| { let _ = window.draw(cx); }); - let before_up = view.read_with(cx, |view, cx| { - let offset = view.text_view.read(cx).list_state().logical_scroll_top(); - (offset.item_ix, offset.offset_in_item) - }); let start = point(bounds.left() + px(30.), bounds.bottom() - px(30.)); let edge = point(bounds.left() + px(60.), bounds.top() + px(2.)); - cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); - cx.simulate_mouse_move(edge, Some(MouseButton::Left), Modifiers::default()); - cx.executor().advance_clock(Duration::from_millis(64)); + let per_tick = AutoScroll::compute_delta(edge.y, bounds).expect("edge zone"); + let moved = drag_and_hold(&text_view, start, edge, TICKS, cx); + assert_auto_scroll_magnitude(moved, per_tick, TICKS, "upward"); + } + + /// The reader layout from the field report: a clipping row that centers a + /// vertically padded, full-height scrollable TextView. The list's edges + /// sit inside the padding, so a wheel event synthesized at the clipping + /// ancestor's edge misses the list hitbox; only the participant path + /// scrolls it. + impl Render for PaddedReaderAutoScrollTest { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child( + div() + .debug_selector(|| "reader".into()) + .flex() + .flex_row() + .h(px(300.)) + .min_w(px(0.)) + .overflow_hidden() + .justify_center() + .child( + TextView::new(&self.text_view) + .scrollable(true) + .selectable(true) + .px_5() + .py_3() + .h_full() + .w_full() + .max_w(relative(0.85)) + .mx_auto(), + ), + ) + } + } + + #[gpui::test] + fn padded_reader_text_view_auto_scrolls_at_the_clipping_edge(cx: &mut TestAppContext) { + let source = (0..100) + .map(|ix| format!("Paragraph {ix} with enough text to select")) + .collect::>() + .join("\n\n"); + cx.update(crate::init); + let (root, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| PaddedReaderAutoScrollTest { + text_view: cx.new(|cx| TextViewState::markdown(&source, cx)), + }); + Root::new(view, window, cx) + }); + let view = root.read_with(cx, |root, _| { + root.view() + .clone() + .downcast::() + .unwrap() + }); + let cx: &mut VisualTestContext = cx; cx.run_until_parked(); - let after_up = view.read_with(cx, |view, cx| { - let offset = view.text_view.read(cx).list_state().logical_scroll_top(); - (offset.item_ix, offset.offset_in_item) + cx.update(|window, cx| { + let _ = window.draw(cx); }); - cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default()); - assert_ne!( - before_up, after_up, - "dragging at the top viewport edge must auto-scroll upward" + + let reader = cx.debug_bounds("reader").expect("reader bounds"); + let text_view = view.read_with(cx, |view, _| view.text_view.clone()); + let list_bounds = text_view.read_with(cx, |state, _| state.list_state().viewport_bounds()); + assert!( + list_bounds.bottom() < reader.bottom() - px(2.), + "the list must end inside the padding: list {list_bounds:?}, reader {reader:?}" + ); + + // Below the list but inside the clipping ancestor: the synthetic wheel + // is clamped to this band and misses the list hitbox. + let start = point(reader.center().x, reader.top() + px(40.)); + let edge = point(reader.center().x, reader.bottom() - px(2.)); + let moved = drag_and_hold(&text_view, start, edge, 4, cx); + assert!( + moved > px(0.), + "dragging to the clipping edge must scroll the padded list, moved {moved:?}" ); } From 586fc9cdd654d0f7f7529ba3473b930c01f98700 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 9 Sep 2026 11:10:10 +0800 Subject: [PATCH 2/3] fix(text-selection): extend held selections during clipped auto-scroll Refresh the held cursor after changed participant geometry is registered, and measure auto-scroll against the visible intersection of participant and clipping bounds. Add UI regressions for held selection, mouse-up stopping, and clipped readers. Co-authored-by: Codex --- crates/base/src/text_selection.rs | 41 +++++- crates/component/src/text/window_selection.rs | 134 ++++++++++++++++++ 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/crates/base/src/text_selection.rs b/crates/base/src/text_selection.rs index f037346186..fd70ba3e5b 100644 --- a/crates/base/src/text_selection.rs +++ b/crates/base/src/text_selection.rs @@ -848,6 +848,7 @@ struct WindowSelectionState { did_hit_text: bool, frame_generation: u64, finish_frame_scheduled: bool, + refresh_held_cursor: bool, mouse_down_prepared: bool, auto_scroll: AutoScroll, } @@ -1012,6 +1013,20 @@ impl WindowSelectionState { cx: &mut App, ) { self.prune_dead_participants(); + if self.is_selecting + && registration.self_scroll + && self.anchor.as_ref().and_then(SelectionEndpoint::entity_id) + == Some(selection.entity_id()) + && self + .participants + .get(&selection.entity_id()) + .is_some_and(|previous| { + previous.registration.scroll_offset != registration.scroll_offset + || previous.registration.bounds != registration.bounds + }) + { + self.refresh_held_cursor = true; + } self.participants.insert( selection.entity_id(), ParticipantRegistration { @@ -1538,12 +1553,19 @@ impl WindowSelectionState { } /// Drives the anchor participant's own scrolling, measured against the - /// participant's element bounds rather than any ancestor viewport. + /// visible portion of the participant's element bounds. fn update_participant_auto_scroll(&self, position: Point, cx: &mut App) { let Some((participant, registration)) = self.anchor_registration() else { return; }; - let delta = AutoScroll::compute_delta(position.y, registration.bounds); + let visible_bounds = registration + .bounds + .intersect(®istration.hitbox.content_mask.bounds); + let delta = if visible_bounds.size.width > px(0.) && visible_bounds.size.height > px(0.) { + AutoScroll::compute_delta(position.y, visible_bounds) + } else { + None + }; participant.update(cx, |state, cx| state.set_auto_scroll(delta, cx)); } @@ -1892,12 +1914,25 @@ fn retain_text_selection_state( fn paint_text_selection(state: &Entity, window: &mut Window, cx: &mut App) { if state.update(cx, |state, _| state.schedule_finish_frame()) { let state = state.downgrade(); - window.defer(cx, move |_, cx| { + window.defer(cx, move |window, cx| { let Some(state) = state.upgrade() else { return; }; let handlers = state.update(cx, |state, cx| state.finish_frame(cx)); dispatch_clear_handlers(handlers, cx); + // Direct participant scrolling produces no wheel event. Refresh + // the held cursor after paint registers the new scroll geometry. + let refresh_cursor = state.update(cx, |state, cx| { + if std::mem::take(&mut state.refresh_held_cursor) && state.is_selecting { + state.update_in_window(window.mouse_position(), window, cx); + true + } else { + false + } + }); + if refresh_cursor { + WindowSelectionState::resolve_content_keys(&state, cx); + } }); } diff --git a/crates/component/src/text/window_selection.rs b/crates/component/src/text/window_selection.rs index 308397464f..bb1dcccc76 100644 --- a/crates/component/src/text/window_selection.rs +++ b/crates/component/src/text/window_selection.rs @@ -804,6 +804,70 @@ mod tests { } } + #[gpui::test] + fn held_text_view_drag_auto_scroll_extends_selection(cx: &mut TestAppContext) { + let source = (0..100) + .map(|ix| format!("Paragraph {ix} with enough text to select")) + .collect::>() + .join("\n\n"); + cx.update(crate::init); + let (root, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| AutoScrollTextViewTest { + text_view: cx.new(|cx| TextViewState::markdown(&source, cx)), + }); + Root::new(view, window, cx) + }); + let text_view = root.read_with(cx, |root, cx| { + root.view() + .clone() + .downcast::() + .unwrap() + .read(cx) + .text_view + .clone() + }); + let cx: &mut VisualTestContext = cx; + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + + let bounds = cx + .debug_bounds("scrollable-text-view") + .expect("scrollable TextView bounds"); + let start = point(bounds.left() + px(30.), bounds.top() + px(30.)); + let edge = point(bounds.left() + px(60.), bounds.bottom() - px(2.)); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move(edge, Some(MouseButton::Left), Modifiers::default()); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let before = window_selected_text(cx); + assert!(!before.is_empty(), "the drag must start a text selection"); + // Each tick paints the newly scrolled blocks, with no further mouse moves. + for _ in 0..12 { + cx.executor().advance_clock(Duration::from_millis(16)); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + } + let after = window_selected_text(cx); + cx.simulate_mouse_up(edge, MouseButton::Left, Modifiers::default()); + assert!( + after.len() > before.len(), + "holding at edge must expand selection: before={before:?}, after={after:?}" + ); + let stopped_at = list_scroll_px(&text_view, cx); + cx.executor().advance_clock(Duration::from_millis(64)); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(list_scroll_px(&text_view, cx), stopped_at); + assert_eq!(window_selected_text(cx), after); + } + #[gpui::test] fn compatibility_text_view_drag_selection_auto_scrolls_both_directions( cx: &mut TestAppContext, @@ -936,6 +1000,76 @@ mod tests { ); } + struct ClippedReaderAutoScrollTest { + text_view: Entity, + } + + // A TextView taller than its clipping ancestor. + impl Render for ClippedReaderAutoScrollTest { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child( + div() + .debug_selector(|| "reader".into()) + .flex() + .flex_row() + .h(px(300.)) + .min_w(px(0.)) + .overflow_hidden() + .justify_center() + .child( + TextView::new(&self.text_view) + .scrollable(true) + .selectable(true) + .h(px(600.)) + .flex_none() + .w_full() + .max_w(relative(0.85)) + .mx_auto(), + ), + ) + } + } + + #[gpui::test] + fn clipped_reader_text_view_auto_scrolls_at_the_visible_edge(cx: &mut TestAppContext) { + let source = (0..100) + .map(|ix| format!("Paragraph {ix} with enough text to select")) + .collect::>() + .join("\n\n"); + cx.update(crate::init); + let (root, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| ClippedReaderAutoScrollTest { + text_view: cx.new(|cx| TextViewState::markdown(&source, cx)), + }); + Root::new(view, window, cx) + }); + let view = root.read_with(cx, |root, _| { + root.view() + .clone() + .downcast::() + .unwrap() + }); + let cx: &mut VisualTestContext = cx; + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + + let reader = cx.debug_bounds("reader").expect("reader bounds"); + let text_view = view.read_with(cx, |view, _| view.text_view.clone()); + let list_bounds = text_view.read_with(cx, |state, _| state.list_state().viewport_bounds()); + assert!(list_bounds.bottom() > reader.bottom() + px(100.)); + + // The visible edge is well above the TextView's own bottom. + let start = point(reader.center().x, reader.top() + px(40.)); + let edge = point(reader.center().x, reader.bottom() - px(2.)); + let moved = drag_and_hold(&text_view, start, edge, 4, cx); + assert!( + moved > px(0.), + "dragging to the clipping edge must scroll the clipped list, moved {moved:?}" + ); + } + impl Render for PaddedScrollableTextViewTest { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().size_full().child( From 6d6e470a1b9ff2165c11f0083f2f4fd5b3b4497f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 9 Sep 2026 11:12:09 +0800 Subject: [PATCH 3/3] refactor(text-selection): keep self-scroll registration internal Co-authored-by: Codex --- crates/base/src/text_selection.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/base/src/text_selection.rs b/crates/base/src/text_selection.rs index fd70ba3e5b..c488057314 100644 --- a/crates/base/src/text_selection.rs +++ b/crates/base/src/text_selection.rs @@ -229,7 +229,7 @@ impl TextSelectionRegistration { /// [`TextSelectionEvent::AutoScroll`]. Drag auto-scroll then drives it /// directly, measured against its own bounds, instead of synthesizing a /// wheel event for the nearest scrollable ancestor. - pub fn with_self_scroll(mut self, self_scroll: bool) -> Self { + pub(crate) fn with_self_scroll(mut self, self_scroll: bool) -> Self { self.self_scroll = self_scroll; self } @@ -283,11 +283,6 @@ impl TextSelectionRegistration { self.document_order } - /// Returns whether the participant scrolls its own content on auto-scroll. - pub const fn self_scroll(&self) -> bool { - self.self_scroll - } - /// Returns the glyph-bearing bounds used to reject blank-only gestures. pub fn text_bounds(&self) -> &[Bounds] { &self.text_bounds