From 3a4de17707a0ac2c2100ab842e4d8f7753946ea9 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Thu, 15 Oct 2026 06:44:14 +0000 Subject: [PATCH] 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 17 ++- crates/ui-egui/src/control.rs | 129 ++++++++++++++++++- crates/ui-egui/tests/ui_group/text_select.rs | 46 +++++++ docs/roadmap.md | 2 + 5 files changed, 194 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3db771d635..cf2a338df2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 30b652b36c..e05815bfd5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7539716785..742641330c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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, + client: Option, + 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::>(), + [(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); + } + } +} diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index e9e4ecdbd0..eb380f261d 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -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); + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 10c4087b1f..be3c125370 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
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
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
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
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.
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
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). |