drag auto-scroll at window edges (Q#M7)

While a drag sits within 24px of the text area's top or the
surface bottom, about_to_wait ticks every 35ms: one line of scroll
toward the pointer plus a re-run of the drag hit-test at the
current pointer position — CursorMoved alone would stall the
selection the moment the mouse stops past the edge. Armed/disarmed
from the drag's vertical position; the loop returns to plain Wait
whenever it isn't scrolling, so the tick costs nothing outside the
gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-12 12:42:48 -04:00
parent d47b7f5cd5
commit 212c17664c
1 changed files with 104 additions and 0 deletions

View File

@ -90,6 +90,11 @@ const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92];
const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82];
const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18];
const MINIMAP_THUMB_BORDER: [f32; 4] = [0.86, 0.86, 0.96, 0.7];
/// Q#M7 — dragging within this many pixels of the text area's top or
/// bottom edge auto-scrolls toward the pointer.
const EDGE_SCROLL_BAND: f32 = 24.0;
/// Q#M7 — one line per tick while edge-scrolling.
const EDGE_SCROLL_TICK: std::time::Duration = std::time::Duration::from_millis(35);
const QUAD_SHADER: &str = r"
struct VertexOut {
@builtin(position) pos: vec4<f32>,
@ -419,6 +424,14 @@ struct State {
/// selection, until release. Never sends `Pointer` events —
/// the viewport is frontend-owned.
minimap_scrub_active: bool,
/// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge
/// band; `about_to_wait` ticks the viewport one line toward the
/// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag
/// hit-test (the mouse may be stationary — `CursorMoved` alone
/// would stall the selection).
edge_scroll_dir: Option<i64>,
/// When the last edge-scroll tick fired.
edge_scroll_last: Option<std::time::Instant>,
/// Q#R2 — the per-line surgery path skips rebuilding the pointer
/// hit map (clicks are rare next to keystrokes); this marks it
/// stale so `hit_test_source_byte` rebuilds on demand from the
@ -632,6 +645,10 @@ impl ApplicationHandler<AppEvent> for App {
if !state.pointer_drag_active {
return;
}
// Q#M7 — arm/disarm edge auto-scroll from the drag's
// vertical position; `about_to_wait` runs the ticks.
state.edge_scroll_dir =
edge_scroll_direction(position.y as f32, state.config.height);
// Drag coalescing (predicted finding #4): pixel-rate
// motion only ships when the hit byte changes.
let Some(byte) = state.hit_test_source_byte(position.x, position.y) else {
@ -700,6 +717,8 @@ impl ApplicationHandler<AppEvent> for App {
return;
}
state.pointer_drag_active = false;
state.edge_scroll_dir = None;
state.edge_scroll_last = None;
let byte = state
.hit_test_source_byte(x, y)
.or(state.last_pointer_sent_byte);
@ -745,6 +764,61 @@ impl ApplicationHandler<AppEvent> for App {
}
}
/// Q#M7 — the edge auto-scroll tick. Armed only while a drag
/// sits in the top/bottom edge band; otherwise the loop stays in
/// plain `Wait`. Each due tick scrolls one line toward the
/// pointer and re-runs the drag hit-test at the *current*
/// pointer position, so the selection keeps growing while the
/// mouse is stationary past the edge.
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let Some(state) = self.state.as_mut() else {
return;
};
if !state.pointer_drag_active || state.edge_scroll_dir.is_none() {
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
return;
}
let dir = state.edge_scroll_dir.unwrap_or(0);
let now = std::time::Instant::now();
let due = state
.edge_scroll_last
.is_none_or(|at| now.duration_since(at) >= EDGE_SCROLL_TICK);
let mut drag_resend: Option<(BufferId, u64)> = None;
if due {
state.edge_scroll_last = Some(now);
let vp = state.scroll_by_lines(dir);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: edge-scroll send_viewport failed: {e}");
}
let state = self.state.as_mut().expect("checked above");
if let Some((x, y)) = state.pointer_pos
&& let Some(byte) = state.hit_test_source_byte(x, y)
&& state.last_pointer_sent_byte != Some(byte)
{
state.last_pointer_sent_byte = Some(byte);
state.note_pointer_round_trip();
if let Some(buffer_id) = state.current_buffer_id {
drag_resend = Some((buffer_id, byte));
}
}
}
if let Some((buffer_id, byte)) = drag_resend {
let mods = translate_mods(self.modifiers);
self.send_pointer(buffer_id, byte, PointerKind::Drag, mods);
}
let last = self
.state
.as_ref()
.and_then(|s| s.edge_scroll_last)
.unwrap_or(now);
event_loop.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
last + EDGE_SCROLL_TICK,
));
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) {
let Some(state) = self.state.as_mut() else {
return;
@ -1129,6 +1203,8 @@ impl State {
last_pointer_sent_byte: None,
last_pointer_down: None,
minimap_scrub_active: false,
edge_scroll_dir: None,
edge_scroll_last: None,
hit_map_dirty: false,
line_chunk_cache: Vec::new(),
shaped_top: 0,
@ -2950,6 +3026,19 @@ fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32
&& y < MINIMAP_TOP + height
}
/// Q#M7 — which way (if any) a drag at pixel `y` should auto-scroll:
/// `-1` in the band hugging the text area's top, `+1` in the band at
/// the surface bottom, `None` in the interior.
fn edge_scroll_direction(y: f32, surface_height: u32) -> Option<i64> {
if y < TEXT_TOP + EDGE_SCROLL_BAND {
Some(-1)
} else if y > surface_height as f32 - EDGE_SCROLL_BAND {
Some(1)
} else {
None
}
}
/// Map a minimap pixel `y` to a whole-file source line — the inverse
/// of the painter's `y = MINIMAP_TOP + line * height / total`
/// interpolation, clamped into the file. `None` for an empty file or
@ -5020,6 +5109,21 @@ mod tests {
assert_eq!(minimap_y_to_line(100.0, 600, 0), None, "empty file");
}
#[test]
fn edge_scroll_direction_bands() {
// 600px surface: up-band y < 16 + 24 = 40; down-band y > 576.
assert_eq!(edge_scroll_direction(10.0, 600), Some(-1));
assert_eq!(edge_scroll_direction(39.9, 600), Some(-1));
assert_eq!(edge_scroll_direction(40.0, 600), None, "interior");
assert_eq!(edge_scroll_direction(300.0, 600), None);
assert_eq!(
edge_scroll_direction(576.0, 600),
None,
"band edge exclusive"
);
assert_eq!(edge_scroll_direction(577.0, 600), Some(1));
}
#[test]
fn minimap_rects_project_line_styles_as_right_side_bands() {
let red = style_with_fg(CellColor::Rgb(255, 0, 0));