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..c488057314 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(crate) 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; @@ -832,6 +843,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, } @@ -996,6 +1008,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 { @@ -1142,7 +1168,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 +1481,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 +1489,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 +1514,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 +1547,50 @@ impl WindowSelectionState { }); } + /// Drives the anchor participant's own scrolling, measured against the + /// visible portion of the participant's element bounds. fn update_participant_auto_scroll(&self, position: Point, cx: &mut App) { - 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((participant, registration)) = self.anchor_registration() else { return; }; - let Some(registration) = self.participants.get(&participant.entity_id()) else { - return; + 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 }; - let delta = AutoScroll::compute_delta(position.y, registration.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()); @@ -1871,12 +1909,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 84a1994e33..bb1dcccc76 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,12 +760,52 @@ mod tests { } } - #[gpui::test] - fn compatibility_text_view_drag_selection_auto_scrolls_both_directions( - cx: &mut TestAppContext, - ) { - use gpui::ListOffset; + /// 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 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::>() @@ -773,11 +817,14 @@ mod tests { }); Root::new(view, window, cx) }); - let view = root.read_with(cx, |root, _| { + 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(); @@ -788,30 +835,82 @@ 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) - }); 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); + } - 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) + #[gpui::test] + fn compatibility_text_view_drag_selection_auto_scrolls_both_directions( + cx: &mut TestAppContext, + ) { + use gpui::ListOffset; + + 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 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); }); - 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 bounds = cx + .debug_bounds("scrollable-text-view") + .expect("scrollable TextView bounds"); + // 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.)); + 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 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 +918,155 @@ 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:?}" + ); + } + + 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:?}" ); }