Skip to content

Commit bb2fcf3

Browse files
huacnleecodex
andauthored
mobile: Support mobile text selection and scrollbar dragging (longbridge#3058)
## Summary Mobile TextViews did not respond to long presses, and dragging a scrollbar thumb was interpreted as content panning, moving in the opposite direction. - Select a word on long press, capture the gesture, extend the selection while dragging, and release it on end/cancel. Register rendered text geometry so rich-text word hit testing works and the initial word selection survives redraw. - Claim touch drags that start on scrollbar thumbs and map finger movement to the scroll offset, preserving ordinary content panning. - Use the native iOS font for the FPS HUD so mobile hosts no longer need a vendored font workaround. Uses existing GPUI APIs without changing GPUI core or dependency versions. Native platform touch delivery is supplied by the companion [gpui-mobile PR #6](longbridge/gpui-mobile#6). ## Test Plan - Passed: all 893 `gpui-base` library tests with the identical five-file selection/scrollbar patch in an isolated worktree using GPUI 0.3.4. - Added regressions for long-press word selection followed by drag expansion, and downward scrollbar dragging followed by cancellation. - Passed: iOS and Android ai-chat host compile checks using these local sources, including the iOS FPS HUD. - Passed: formatting and `git diff --check`. - Simulator/device interaction checks are pending; this PR does not claim native gesture or IME end-to-end validation. Co-authored-by: Codex <[email protected]>
1 parent d71d874 commit bb2fcf3

6 files changed

Lines changed: 227 additions & 7 deletions

File tree

crates/base/src/scrollbar.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use gpui::{
1111
Anchor, App, Axis, Background, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element,
1212
ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement,
1313
IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels,
14-
Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window,
15-
fill, point, prelude::FluentBuilder, px, relative, size,
14+
Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, TouchDragEvent, TouchPhase,
15+
UniformListScrollHandle, Window, fill, point, prelude::FluentBuilder, px, relative, size,
1616
};
1717
use schemars::JsonSchema;
1818
use serde::{Deserialize, Serialize};
@@ -1531,6 +1531,73 @@ impl Element for Scrollbar {
15311531

15321532
let safe_range = (-scroll_area_size + container_size)..px(0.);
15331533

1534+
// A thumb follows the finger, unlike content panning. Claim
1535+
// this touch before the window turns it into wheel deltas.
1536+
window.on_mouse_event({
1537+
let state = scrollbar_state.clone();
1538+
let scroll_handle = self.scroll_handle.clone();
1539+
move |event: &TouchDragEvent, phase, window, cx| {
1540+
if !phase.bubble() {
1541+
return;
1542+
}
1543+
if event.phase == TouchPhase::Started {
1544+
if !is_visible
1545+
|| window.default_prevented()
1546+
|| !thumb_bounds.contains(&event.start_position)
1547+
{
1548+
return;
1549+
}
1550+
scroll_handle.start_drag();
1551+
state.set(state.get().with_drag_pos(
1552+
axis,
1553+
event.start_position - thumb_bounds.origin,
1554+
));
1555+
} else if state.get().dragged_axis != Some(axis) {
1556+
return;
1557+
} else {
1558+
if matches!(event.phase, TouchPhase::Moved | TouchPhase::Ended) {
1559+
let drag_pos = state.get().drag_pos;
1560+
let (position, origin, grab, track) = if is_vertical {
1561+
(
1562+
event.position.y,
1563+
bounds.origin.y,
1564+
drag_pos.y,
1565+
bounds.size.height - thumb_size,
1566+
)
1567+
} else {
1568+
(
1569+
event.position.x,
1570+
bounds.origin.x,
1571+
drag_pos.x,
1572+
bounds.size.width - thumb_size - margin_end,
1573+
)
1574+
};
1575+
if track > px(0.) {
1576+
let percentage =
1577+
((position - origin - grab) / track).clamp(0., 1.);
1578+
let mut offset = scroll_handle.offset();
1579+
let value =
1580+
-(scroll_area_size - container_size) * percentage;
1581+
if is_vertical {
1582+
offset.y = value;
1583+
} else {
1584+
offset.x = value;
1585+
}
1586+
scroll_handle.set_offset(offset);
1587+
}
1588+
}
1589+
if matches!(event.phase, TouchPhase::Ended | TouchPhase::Cancelled)
1590+
{
1591+
scroll_handle.end_drag();
1592+
state.set(state.get().with_unset_drag_pos(Instant::now()));
1593+
}
1594+
}
1595+
window.prevent_default();
1596+
cx.stop_propagation();
1597+
cx.notify(view_id);
1598+
}
1599+
});
1600+
15341601
if is_visible {
15351602
window.on_mouse_event({
15361603
let state = scrollbar_state.clone();
@@ -2371,6 +2438,35 @@ mod tests {
23712438
assert!(handle.offset().y < px(0.));
23722439
}
23732440

2441+
#[gpui::test]
2442+
fn touch_thumb_drag_moves_down_and_cancel_releases_handle(cx: &mut TestAppContext) {
2443+
let (cx, handle) = harness(
2444+
cx,
2445+
ScrollbarAxis::Vertical,
2446+
ScrollbarMode::Always,
2447+
size(px(100.), px(500.)),
2448+
);
2449+
let start_position = point(px(95.), px(20.));
2450+
for (phase, position) in [
2451+
(TouchPhase::Started, start_position),
2452+
(TouchPhase::Moved, point(px(95.), px(45.))),
2453+
(TouchPhase::Cancelled, point(px(95.), px(45.))),
2454+
] {
2455+
cx.simulate_event(TouchDragEvent {
2456+
phase,
2457+
start_position,
2458+
position,
2459+
});
2460+
}
2461+
assert!(
2462+
handle.offset().y < px(0.),
2463+
"thumb down must scroll toward later content"
2464+
);
2465+
assert_eq!(handle.offset().x, px(0.));
2466+
assert_eq!(handle.drag_starts.get(), 1);
2467+
assert_eq!(handle.drag_ends.get(), 1);
2468+
}
2469+
23742470
#[gpui::test]
23752471
fn thumb_drag_notifies_handle_start_and_end(cx: &mut TestAppContext) {
23762472
let (cx, handle) = harness(

crates/base/src/text/inline.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,13 @@ impl Element for Inline {
677677
);
678678
text_view_state.update(cx, |state, _| {
679679
state.selection_adapter.register_inline(text_bounds);
680+
state
681+
.selection_adapter
682+
.register_text_run(crate::TextSelectionRun::new(
683+
self.text.clone(),
684+
text_layout.clone(),
685+
hitbox.bounds,
686+
));
680687
});
681688
}
682689

crates/base/src/text/selection_adapter.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::{cell::RefCell, ops::RangeInclusive, rc::Rc};
22

33
use crate::{
44
TextSelectionContentKey, TextSelectionCoverage, TextSelectionEndpoint, TextSelectionEvent,
5-
TextSelectionHandle, TextSelectionRegistration, TextSelectionSnapshot,
5+
TextSelectionHandle, TextSelectionRegistration, TextSelectionRun, TextSelectionSnapshot,
66
};
77
use gpui::{App, Bounds, EntityId, Hitbox, Pixels, Point, WeakEntity, Window};
88

@@ -73,6 +73,7 @@ impl VirtualBlockSelection {
7373
pub(super) struct TextViewSelectionAdapter {
7474
selection: TextSelectionHandle,
7575
text_bounds: Vec<Bounds<Pixels>>,
76+
text_runs: Vec<TextSelectionRun>,
7677
layout_revision: Option<usize>,
7778
}
7879

@@ -169,6 +170,7 @@ impl TextViewSelectionAdapter {
169170
Self {
170171
selection,
171172
text_bounds: Vec::new(),
173+
text_runs: Vec::new(),
172174
layout_revision: None,
173175
}
174176
}
@@ -185,6 +187,11 @@ impl TextViewSelectionAdapter {
185187

186188
pub(super) fn begin_frame(&mut self) {
187189
self.text_bounds.clear();
190+
self.text_runs.clear();
191+
}
192+
193+
pub(super) fn register_text_run(&mut self, run: TextSelectionRun) {
194+
self.text_runs.push(run);
188195
}
189196

190197
pub(super) fn register_inline(&mut self, bounds: Vec<Bounds<Pixels>>) {
@@ -202,6 +209,7 @@ impl TextViewSelectionAdapter {
202209
window: &mut Window,
203210
cx: &mut App,
204211
) {
212+
self.selection.set_hit_test_runs(&self.text_runs, cx);
205213
self.selection.register(
206214
TextSelectionRegistration::new(hitbox, bounds)
207215
.with_scroll_offset(scroll_offset)

crates/base/src/text/text_view.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,6 +2562,58 @@ mod tests {
25622562
assert_eq!(selected_text.trim(), "quick");
25632563
}
25642564

2565+
#[gpui::test]
2566+
fn long_press_selects_word_then_drag_extends_selection(cx: &mut TestAppContext) {
2567+
struct TouchRoot {
2568+
text_view: Entity<TextViewState>,
2569+
}
2570+
impl Render for TouchRoot {
2571+
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2572+
div()
2573+
.w(px(300.))
2574+
.child(crate::TextSelectionLayer)
2575+
.child(TextView::new(&self.text_view).selectable(true))
2576+
}
2577+
}
2578+
cx.update(crate::init);
2579+
let (view, cx) = cx.add_window_view(|_, cx| TouchRoot {
2580+
text_view: cx.new(|cx| TextViewState::markdown("quick select value", cx)),
2581+
});
2582+
cx.run_until_parked();
2583+
cx.update(|window, cx| {
2584+
let _ = window.draw(cx);
2585+
});
2586+
let start_position = point(px(10.), px(16.));
2587+
cx.simulate_event(gpui::LongPressEvent {
2588+
phase: gpui::TouchPhase::Started,
2589+
start_position,
2590+
position: start_position,
2591+
});
2592+
cx.update(|window, cx| {
2593+
let _ = window.draw(cx);
2594+
});
2595+
assert_eq!(
2596+
view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2597+
.trim(),
2598+
"quick"
2599+
);
2600+
for phase in [gpui::TouchPhase::Moved, gpui::TouchPhase::Ended] {
2601+
cx.simulate_event(gpui::LongPressEvent {
2602+
phase,
2603+
start_position,
2604+
position: point(px(220.), px(16.)),
2605+
});
2606+
}
2607+
cx.update(|window, cx| {
2608+
let _ = window.draw(cx);
2609+
});
2610+
assert_eq!(
2611+
view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text())
2612+
.trim(),
2613+
"quick select value"
2614+
);
2615+
}
2616+
25652617
#[gpui::test]
25662618
fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
25672619
cx.update(crate::init);

crates/base/src/text_selection.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ use std::{
88
use gpui::{
99
App, AppContext as _, Bounds, Context, Element, ElementId, Entity, EntityId, EventEmitter,
1010
Global, GlobalElementId, Half, Hitbox, InputEvent as _, InspectorElementId, IntoElement,
11-
LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
12-
ScrollDelta, ScrollWheelEvent, SharedString, Style, Subscription, TextLayout, WeakEntity,
13-
Window, point, px,
11+
LayoutId, LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
12+
Point, ScrollDelta, ScrollWheelEvent, SharedString, Style, Subscription, TextLayout,
13+
TouchPhase, WeakEntity, Window, point, px,
1414
};
1515

1616
use crate::text_boundary::{line_range_at, word_range_at};
@@ -729,6 +729,11 @@ impl TextSelectionHandle {
729729
self.0.update(cx, |state, _| state.update_runs(runs))
730730
}
731731

732+
// Rich text renders its own selection; retain geometry only for word hit testing.
733+
pub(crate) fn set_hit_test_runs(&self, runs: &[TextSelectionRun], cx: &mut App) {
734+
self.0.update(cx, |state, _| state.runs = runs.to_vec());
735+
}
736+
732737
/// Subscribes to participant selection notifications.
733738
pub fn subscribe(
734739
&self,
@@ -1972,6 +1977,55 @@ fn paint_text_selection(state: &Entity<WindowSelectionState>, window: &mut Windo
19721977
}
19731978
});
19741979

1980+
// Touch panning remains scrolling until a long press actually hits text.
1981+
// Claiming the gesture keeps subsequent moves out of the pan recognizer.
1982+
let long_press_state = state.downgrade();
1983+
window.on_mouse_event(move |event: &LongPressEvent, phase, window, cx| {
1984+
if !phase.bubble() {
1985+
return;
1986+
}
1987+
let Some(state) = long_press_state.upgrade() else {
1988+
return;
1989+
};
1990+
if event.phase == TouchPhase::Started {
1991+
if window.default_prevented()
1992+
|| !state.update(cx, |state, cx| {
1993+
state
1994+
.endpoint(event.start_position, Some(window), cx)
1995+
.inside_text
1996+
})
1997+
{
1998+
return;
1999+
}
2000+
GlobalState::init(cx);
2001+
GlobalState::reset_text_selection_suppression(cx);
2002+
let handlers = state.update(cx, |state, cx| state.prepare_for_mouse_down(false, cx));
2003+
dispatch_clear_handlers(handlers, cx);
2004+
let selected = state.update(cx, |state, cx| {
2005+
state.select_at(event.start_position, 2, window, cx);
2006+
state.anchor.is_some()
2007+
});
2008+
if !selected {
2009+
return;
2010+
}
2011+
window.capture_long_press(&state);
2012+
} else if !window.has_long_press_capture(&state) {
2013+
return;
2014+
} else {
2015+
state.update(cx, |state, cx| match event.phase {
2016+
TouchPhase::Moved => {
2017+
state.is_selecting = true;
2018+
state.update_in_window(event.position, window, cx);
2019+
}
2020+
TouchPhase::Ended | TouchPhase::Cancelled => state.end(cx),
2021+
_ => {}
2022+
});
2023+
}
2024+
window.prevent_default();
2025+
cx.stop_propagation();
2026+
WindowSelectionState::resolve_content_keys(&state, cx);
2027+
});
2028+
19752029
let mouse_move_state = state.downgrade();
19762030
window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
19772031
if phase.bubble()

crates/fps/src/monitor.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ const READOUT_INTERVAL: Duration = Duration::from_millis(500);
8888
const DEFAULT_FONT: &str = "Menlo";
8989
#[cfg(target_os = "windows")]
9090
const DEFAULT_FONT: &str = "Consolas";
91-
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
91+
// The iOS backend resolves this native family but not the generic monospace alias.
92+
#[cfg(target_os = "ios")]
93+
const DEFAULT_FONT: &str = ".SystemUIFont";
94+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "ios")))]
9295
const DEFAULT_FONT: &str = "monospace";
9396

9497
/// A realtime performance HUD: frames per second, a rolling frame time chart,

0 commit comments

Comments
 (0)