M3: preserve coordinate clicks across slow frames (#2100)

Pair coordinate press and release after hover warmup so frame delays do not turn clicks into holds. Preserve text multi-click selection for coalesced pointer events.

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
sanyamsingh001
2026-10-15 02:44:14 -04:00
committed by GitHub
co-authored by sanyamsingh001 Codex Brandon Thomas
parent b0a692c139
commit 3a4de17707
5 changed files with 194 additions and 2 deletions
+2
View File
@@ -3,6 +3,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -172,6 +173,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned.
- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions.
- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening.
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+16 -1
View File
@@ -2950,7 +2950,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
} else if over_text && cursor_free {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
let press_here = ui.input(|inp| inp.pointer.press_origin()).is_some_and(|o| r.contains(o));
let press_here = ui
.input(|inp| {
inp.pointer.press_origin().or_else(|| {
// A press and release can arrive in one pass; egui has already
// cleared press_origin, but the press still starts multi-click selection.
inp.events.iter().find_map(|event| match event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. }
if pos.is_finite() =>
{
Some(*pos)
}
_ => None,
})
})
})
.is_some_and(|o| r.contains(o));
let pressed = ui.input(|inp| inp.pointer.primary_pressed());
if pressed && press_here && !over_link {
let now = ui.input(|inp| inp.time);
+128 -1
View File
@@ -428,7 +428,8 @@ impl Control {
let pos = egui::pos2(point("x", x)?, point("y", y)?);
let which = pointer_button(p, "ui.click")?;
let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE };
let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true)], vec![button(false)]]);
// Keep hover/layout warmup, then make an instantaneous click even on slow frames.
let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]);
return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] })));
}
let s = self.shared.lock().map_err(|_| "control state poisoned")?;
@@ -1211,3 +1212,129 @@ mod transport_tests {
assert_eq!(reply(&mut r)["error"]["code"], -32700, "and the connection is still open");
}
}
#[cfg(test)]
mod pointer_tests {
use super::*;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
#[test]
fn coordinate_clicks_activate_once_with_slow_frames() {
struct Probe {
control: Option<Control>,
client: Option<ControlClient>,
host: crate::PdfCraftApp,
primary: usize,
secondary: usize,
disabled: usize,
discard: bool,
discarded: bool,
events: Vec<(f64, egui::PointerButton, bool, egui::Pos2)>,
}
fn request(h: &mut Harness<'_, Probe>, c: &ControlClient, params: Value) -> Reply {
let rx = c.send("ui.click", params);
for _ in 0..8 {
let before = h.ctx.input(|i| i.time);
h.step();
assert!(h.ctx.input(|i| i.time) > before, "simulated input time advances");
if let Ok(reply) = rx.try_recv() {
return reply;
}
}
panic!("coordinate click did not reply within eight steps");
}
let max_duration = egui::Context::default().options(|o| o.input_options.max_click_duration);
let slow_dt = (max_duration + 0.25) as f32;
assert!(f64::from(slow_dt) > max_duration);
for discard in [false, true] {
let state = Probe {
control: None,
client: None,
host: crate::PdfCraftApp::new(),
primary: 0,
secondary: 0,
disabled: 0,
discard,
discarded: false,
events: Vec::new(),
};
let mut h = Harness::builder().with_size(egui::vec2(320.0, 200.0)).with_step_dt(slow_dt).build_ui_state(
|ui, state: &mut Probe| {
if state.control.is_none() {
let (control, client) = attach(ui.ctx());
state.control = Some(control);
state.client = Some(client);
ui.ctx().options_mut(|o| o.max_passes = if state.discard { 2 } else { 1 }.try_into().unwrap());
}
state.control.as_mut().unwrap().tick(ui.ctx(), &mut state.host);
ui.input(|i| {
for event in &i.events {
if let egui::Event::PointerButton { pos, button, pressed, .. } = event {
assert!(state.events.len() < 32, "bounded pointer trace");
state.events.push((i.time, *button, *pressed, *pos));
}
}
});
let response = ui.button("Click probe");
if response.clicked() {
state.primary += 1;
if state.discard && !state.discarded {
state.discarded = true;
ui.ctx().request_discard("control click must not replay across passes");
}
}
if response.secondary_clicked() {
state.secondary += 1;
}
let disabled = ui.add_enabled(false, egui::Button::new("Disabled probe"));
if disabled.clicked() || disabled.secondary_clicked() {
state.disabled += 1;
}
},
state,
);
h.run_steps(3);
let c = h.state().client.as_ref().unwrap().clone();
assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration);
let pos = h.get_by_label("Click probe").rect().center();
assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (0, 0, 0));
let reply = request(&mut h, &c, json!({"x":pos.x,"y":pos.y})).unwrap();
assert_eq!(reply["clicked"], json!([pos.x, pos.y]));
println!("slow control click discard={discard} duration_limit={max_duration} step_dt={slow_dt} pointer_events={:?}", h.state().events);
let events = &h.state().events;
assert_eq!(
events.iter().map(|(_, button, pressed, point)| (*button, *pressed, *point)).collect::<Vec<_>>(),
[(egui::PointerButton::Primary, true, pos), (egui::PointerButton::Primary, false, pos)]
);
let elapsed = events[1].0 - events[0].0;
assert!(elapsed == 0.0 || elapsed >= f64::from(slow_dt) - 1e-6, "the pair is atomic or crosses a simulated slow frame");
assert_eq!(h.state().primary, 1, "a coordinate click activates the real button once on slow frames");
h.run_steps(3);
assert_eq!(h.state().primary, 1, "later passes must not replay the click");
assert_eq!(h.state().discarded, discard);
request(&mut h, &c, json!({"x":pos.x,"y":pos.y,"button":"secondary"})).unwrap();
assert_eq!((h.state().primary, h.state().secondary), (1, 1));
let disabled = h.get_by_label("Disabled probe").rect().center();
request(&mut h, &c, json!({"x":disabled.x,"y":disabled.y})).unwrap();
assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (1, 1, 0));
let disabled_id = h
.state()
.control
.as_ref()
.unwrap()
.shared
.lock()
.unwrap()
.nodes
.iter()
.find(|(_, node)| node.label() == Some("Disabled probe"))
.unwrap()
.0
.0;
assert!(request(&mut h, &c, json!({"id":disabled_id})).unwrap_err().contains("disabled"));
assert!(h.state().host.views.is_empty(), "pointer probes create no document edits");
assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration);
}
}
}
@@ -349,3 +349,49 @@ fn a_triple_click_drag_extends_by_whole_lines() {
double_click_drag(&mut h, at, below);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox\njumps over the dog"));
}
/// Coalesced down/up keeps the same word/line selection as separate press and release passes.
#[test]
fn coalesced_primary_clicks_select_line_then_spaced_click_clears() {
for coalesced in [true, false] {
let (mut h, at) = harness(|_| {});
h.ctx.options_mut(|o| o.max_passes = 1.try_into().unwrap());
let id = h.state().views[0].id;
let saved = h.state().session.get(id).unwrap().saved_bytes();
assert!(h.state().views[0].selected_text().is_none());
assert!(!h.state().session.get(id).unwrap().dirty);
for want in [None, Some("quick"), Some("The quick brown fox")] {
if coalesced {
// Harness::event steps each queued event separately; RawInput keeps this pair together.
h.input_mut().events.extend([true, false].map(|pressed| Event::PointerButton {
pos: at,
button: PointerButton::Primary,
pressed,
modifiers: Modifiers::NONE,
}));
h.step();
} else {
click(&mut h, at);
}
assert_eq!(h.state().views[0].selected_text().as_deref(), want, "coalesced={coalesced}");
}
h.run_steps(60);
if coalesced {
h.input_mut().events.extend([true, false].map(|pressed| Event::PointerButton {
pos: at,
button: PointerButton::Primary,
pressed,
modifiers: Modifiers::NONE,
}));
h.step();
} else {
click(&mut h, at);
}
assert_eq!(h.state().views[0].selected_text(), None, "a spaced click clears either gesture path");
assert_eq!(h.state().views[0].id, id);
let doc = h.state().session.get(id).unwrap();
assert!(!doc.dirty);
assert_eq!((doc.can_undo(), doc.can_redo()), (None, None));
assert_eq!(doc.saved_bytes(), saved);
}
}
+2
View File
@@ -1,6 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -99,6 +100,7 @@ total matches the parity estimate.
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.<br>Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |