mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:36:13 -06:00
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:
co-authored by
sanyamsingh001
Codex
Brandon Thomas
parent
b0a692c139
commit
3a4de17707
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user