fix(ui): dragging a selection auto-scrolls near the viewport edges (#688) (#758)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
max
2026-10-12 01:20:30 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent d9b76d77f9
commit 0b950a7f0b
2 changed files with 170 additions and 4 deletions
+117 -4
View File
@@ -18,6 +18,15 @@ pub const PX_PER_PT: f32 = 96.0 / 72.0;
const GAP: f32 = 18.0;
const RULER: f32 = 22.0;
/// Drag-select auto-scroll (#688): the pointer within this many points of the viewport's edge
/// starts scrolling, like Word.
const EDGE_MARGIN: f32 = 32.0;
/// Auto-scroll speed (points/s) at the margin's inner boundary and at (or past) the edge.
const EDGE_SPEED_MIN: f32 = 45.0;
const EDGE_SPEED_MAX: f32 = 720.0;
/// Longest frame time trusted for auto-scroll (a stall shouldn't jump the page).
const EDGE_MAX_DT: f32 = 0.1;
pub struct CanvasState {
textures: HashMap<usize, (u64, TextureHandle)>,
pub scroll_to_caret: bool,
@@ -65,6 +74,9 @@ pub struct CanvasState {
/// The scroll offset and its maximum at the end of last frame.
pub(crate) scroll_offset: egui::Vec2,
scroll_max: egui::Vec2,
/// The viewport (the content's visible rect on screen) at the end of last frame; the
/// drag-select edge auto-scroll decides this frame's scroll from it (#688).
pub(crate) viewport: Option<egui::Rect>,
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
pub cols: usize,
/// The comment whose balloon is selected (its text is editable in place, like Word's).
@@ -134,6 +146,7 @@ impl Default for CanvasState {
wheel: Default::default(),
scroll_offset: egui::Vec2::ZERO,
scroll_max: egui::Vec2::ZERO,
viewport: None,
cols: 1,
balloon: None,
balloon_focus: false,
@@ -505,17 +518,34 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none();
let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT);
let opts = ui.ctx().options(|o| o.input_options);
let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
let wheel_delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
// Dragging a selection near the viewport's edge scrolls on (#688): from a crawl at the margin
// to full speed at the edge, through the same offset the wheel writes.
let (down, pointer, dt) = ui.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.stable_dt));
let auto_delta = if app.canvas.drag_selecting()
&& down
&& let Some(vp) = app.canvas.viewport
&& let Some(p) = pointer
{
vec2(edge_scroll(p.x, vp.min.x, vp.max.x, dt), edge_scroll(p.y, vp.min.y, vp.max.y, dt))
} else {
egui::Vec2::ZERO
};
let delta = wheel_delta + auto_delta;
let mut scroll_area = egui::ScrollArea::both()
.id_salt("canvas_scroll")
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() });
if delta != egui::Vec2::ZERO {
let before = app.canvas.scroll_offset;
let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max);
if after == before {
let after = clamped_scroll(before, delta, app.canvas.scroll_max);
if after == before && wheel_delta != egui::Vec2::ZERO {
app.canvas.wheel.hit_edge();
}
if auto_delta != egui::Vec2::ZERO && after != before {
// The scroll keeps going without new input.
ui.ctx().request_repaint();
}
scroll_area = scroll_area.scroll_offset(after);
}
if app.canvas.wheel.is_animating() {
@@ -810,6 +840,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.scroll_offset = out.state.offset;
crate::ink::replay_bar(app, ui, out.inner_rect);
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
// The viewport barely moves between frames; the auto-scroll decides this frame's scroll before
// the scroll area is built, so it reads last frame's viewport.
app.canvas.viewport = Some(out.inner_rect);
let (resp, rects) = out.inner;
// A click on the page leaves the selected comment balloon.
if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() {
@@ -1234,6 +1267,32 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
best.map(|(i, _)| i)
}
/// The drag auto-scroll along one axis for a pointer at `p` inside the viewport band `lo..hi`, in
/// the wheel's convention (positive = content moves down the screen, toward the document's start):
/// nothing while the pointer is [`EDGE_MARGIN`] or more inside, then ramping from
/// [`EDGE_SPEED_MIN`] at the margin to [`EDGE_SPEED_MAX`] at the edge (and capping beyond it),
/// applied over `dt` seconds. Junk numbers scroll nothing.
fn edge_scroll(p: f32, lo: f32, hi: f32, dt: f32) -> f32 {
if !(p.is_finite() && lo.is_finite() && hi.is_finite() && dt.is_finite()) || hi <= lo {
return 0.0;
}
let dt = dt.clamp(0.0, EDGE_MAX_DT);
let speed = |into: f32| egui::emath::lerp(EDGE_SPEED_MIN..=EDGE_SPEED_MAX, (into / EDGE_MARGIN).clamp(0.0, 1.0));
if p < lo + EDGE_MARGIN {
speed(lo + EDGE_MARGIN - p) * dt
} else if p > hi - EDGE_MARGIN {
-speed(p - (hi - EDGE_MARGIN)) * dt
} else {
0.0
}
}
/// The scroll offset after `delta` (the wheel's convention) reaches `before`: clamped to the
/// scrollable range, so a document shorter than the viewport never moves.
fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::Vec2) -> egui::Vec2 {
(before - delta).clamp(egui::Vec2::ZERO, max)
}
fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) {
// A pen or the eraser (Draw tab) owns the pointer.
if crate::ink::pointer(app, ui, resp, rects, layout, scale) {
@@ -1248,6 +1307,17 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
return;
}
let Some(p) = pointer else { return };
// While drag-selecting, the pointer may sit past the viewport's edge (or the window's):
// hit-test where it would be inside the view, so as the edge auto-scroll moves the content the
// selection keeps growing under a stationary pointer (#688).
let p = if app.canvas.dragging
&& ui.input(|i| i.pointer.primary_down())
&& let Some(vp) = app.canvas.viewport
{
vp.clamp(p)
} else {
p
};
let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return };
let mods = ui.input(|i| i.modifiers);
let story = app.session.sel.focus.story;
@@ -1429,7 +1499,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
app.session.page_hint = page;
app.canvas.caret_visible_since = crate::now_ms();
}
// Auto-scroll near the edges is handled by egui's scroll area drag.
// The pointer was clamped into the viewport above, so when the edge auto-scroll moves the
// content under it, this hit-test keeps the selection growing (#688).
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
app.canvas.dragging = false;
app.canvas.column_drag = None;
@@ -2227,6 +2298,48 @@ mod tests {
}
}
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
/// the scrollable range.
#[test]
fn edge_auto_scroll_ramps_from_the_margins_and_clamps() {
let dt = 1.0 / 60.0;
let (lo, hi) = (100.0, 500.0);
// Nothing while the pointer is inside, including just within the margin.
for y in [(lo + hi) / 2.0, lo + EDGE_MARGIN, hi - EDGE_MARGIN] {
assert_eq!(edge_scroll(y, lo, hi, dt), 0.0, "y = {y}");
}
// Direction: above the top edge scrolls back (positive), below the bottom scrolls on
// (negative), in the wheel's convention.
assert!(edge_scroll(lo + 1.0, lo, hi, dt) > 0.0);
assert!(edge_scroll(hi - 1.0, lo, hi, dt) < 0.0);
// The ramp grows monotonically from the margin to the edge and caps past it.
let above: Vec<f32> = (0..=8).map(|k| edge_scroll(lo + EDGE_MARGIN - k as f32 * EDGE_MARGIN / 8.0, lo, hi, dt)).collect();
assert_eq!(above[0], 0.0);
assert!(above.windows(2).all(|w| w[0] < w[1] && w[1] > 0.0), "{above:?}");
assert_eq!(above[8], EDGE_SPEED_MAX * dt, "the hard edge scrolls at full speed");
assert_eq!(edge_scroll(lo - 3.0 * EDGE_MARGIN, lo, hi, dt), above[8], "past the edge the speed caps");
let below: Vec<f32> = (0..=4).map(|k| edge_scroll(hi - EDGE_MARGIN + k as f32 * EDGE_MARGIN / 4.0, lo, hi, dt)).collect();
assert_eq!(below[0], 0.0);
assert!(below.windows(2).all(|w| w[0] > w[1] && w[1] < 0.0), "{below:?}");
assert_eq!(below[4], -EDGE_SPEED_MAX * dt);
assert_eq!(edge_scroll(hi + 3.0 * EDGE_MARGIN, lo, hi, dt), below[4], "past the edge the speed caps");
// A stalled frame doesn't jump the page.
assert!(edge_scroll(hi - 1.0, lo, hi, 100.0) <= EDGE_SPEED_MAX * EDGE_MAX_DT + 1e-3);
// Junk in, nothing out; neither an empty (or inverted) band.
for (p, lo, hi, dt) in
[(f32::NAN, lo, hi, dt), (200.0, f32::NAN, hi, dt), (200.0, lo, f32::INFINITY, dt), (200.0, lo, hi, f32::NAN), (200.0, 500.0, 100.0, dt)]
{
assert_eq!(edge_scroll(p, lo, hi, dt), 0.0);
}
// Applying it clamps to the scrollable range; a document that fits never scrolls.
let max = vec2(0.0, 400.0);
assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), max).y, 0.0, "never past the top");
assert_eq!(clamped_scroll(vec2(0.0, 400.0), vec2(0.0, -50.0), max).y, 400.0, "never past the bottom");
assert_eq!(clamped_scroll(vec2(0.0, 300.0), vec2(0.0, 50.0), max).y, 250.0);
assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), egui::Vec2::ZERO).y, 0.0, "nothing to scroll");
}
/// Issue #123: zoomed out, pages sit side by side in rows when the width allows; at 100% a
/// letter page in an ordinary window stays one per row.
#[test]
+53
View File
@@ -1700,6 +1700,59 @@ mod tests {
assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at);
}
/// Issue #688: dragging a selection past the viewport's bottom edge scrolls on, and the
/// selection keeps growing as the content moves under the stationary pointer.
#[test]
fn dragging_a_selection_past_the_viewport_edge_auto_scrolls() {
let ctx = egui::Context::default();
let mut a = app();
// A few pages of text, so there is content below the fold.
a.run("text.insert", json!({"text": "lorem ipsum dolor sit amet consectetur ".repeat(1200)})).unwrap();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
for _ in 0..3 {
frame(&mut a, Vec::new());
}
let (Some(vp), Some(page)) = (a.canvas.viewport, a.canvas.page_rects.first()) else { panic!("the canvas laid out") };
let start = page.left_top() + egui::vec2(120.0, 150.0);
frame(&mut a, vec![egui::Event::PointerMoved(start)]);
frame(
&mut a,
vec![egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }],
);
assert!(a.canvas.drag_selecting(), "the press started a drag-select");
let base = a.session.sel.focus.clone();
// Hold the pointer just past the viewport's bottom edge and let the frames run.
let below = egui::pos2(vp.center().x, vp.max.y + 40.0);
for _ in 0..30 {
frame(&mut a, vec![egui::Event::PointerMoved(below)]);
}
assert!(a.canvas.scroll_offset.y > 0.0, "the view scrolled toward later content");
assert!(a.session.sel.focus > base, "the selection followed the content");
// The pointer never moves; the content keeps scrolling under it, so the selection must
// keep growing.
let grown = a.session.sel.focus.clone();
for _ in 0..15 {
frame(&mut a, vec![egui::Event::PointerMoved(below)]);
}
assert!(a.session.sel.focus > grown, "the selection keeps growing under a stationary pointer");
frame(
&mut a,
vec![egui::Event::PointerButton { pos: below, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }],
);
assert!(!a.canvas.drag_selecting());
assert!(!a.session.sel.is_collapsed(), "the drag left a selection");
}
/// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer.
#[test]
fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() {