mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
M3: retain native open replies behind dialogs and pickers (#1953)
Check admission before each prepared result so a modal or queued picker keeps its document recipient. Wake retained replies once after picker consumption or worker abandonment.
Preserve merged #1914 continuation/startup behavior and the three worker regressions from upstream 3f044b62 (jcl-bit and its recorded coauthors). Keep the inherited printing/dependency changes intact; add only the remaining ownership barrier, wakes and focused regressions.
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
f52c92882d
commit
8bd200f643
@@ -161,6 +161,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. 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.
|
||||
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
|
||||
- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged.
|
||||
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
|
||||
|
||||
@@ -1152,12 +1152,19 @@ impl PdfCraftApp {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn poll_pending_opens(&mut self) {
|
||||
let mut ready = Vec::new();
|
||||
let mut waiting = Vec::new();
|
||||
let pending_opens = std::mem::take(&mut self.pending_opens);
|
||||
for pending in pending_opens {
|
||||
let mut remaining = pending_opens.into_iter();
|
||||
let mut waiting = Vec::new();
|
||||
while let Some(pending) = remaining.next() {
|
||||
// A tool, password prompt or file picker belongs to the document just admitted.
|
||||
// Keep later replies queued so another open cannot change its recipient.
|
||||
if self.modal_open() || self.pickers.pending() {
|
||||
waiting.push(pending);
|
||||
waiting.extend(remaining);
|
||||
break;
|
||||
}
|
||||
match pending.receiver.try_recv() {
|
||||
Ok(result) if result.token == pending.token => ready.push((pending, result.kind)),
|
||||
Ok(result) if result.token == pending.token => self.finish_pending_open(pending, result.kind),
|
||||
Ok(_) => waiting.push(pending),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
@@ -1165,10 +1172,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A completion may schedule another open; preserve those requests as well as the
|
||||
// earlier waiting replies, without consuming a blocked receiver's result.
|
||||
waiting.append(&mut self.pending_opens);
|
||||
self.pending_opens = waiting;
|
||||
for (pending, kind) in ready {
|
||||
self.finish_pending_open(pending, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{PdfCraftApp, control::ControlClient, open_worker};
|
||||
@@ -406,3 +407,204 @@ fn a_startup_document_waits_for_an_active_document_to_prepare() {
|
||||
assert_eq!(harness.state().active, Some(0), "the chosen document keeps focus");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn tool_modal_keeps_picked_recipient_when_other_open_is_ready() {
|
||||
for same_batch in [true, false] {
|
||||
let timing = if same_batch { "batch" } else { "later" };
|
||||
let original_a = fixture(2);
|
||||
let original_b = fixture(1);
|
||||
let picked = tool_open_file(&format!("recipient-{timing}-a"), "pdf", &original_a);
|
||||
let other = tool_open_file(&format!("recipient-{timing}-b"), "pdf", &original_b);
|
||||
let mut harness = tool_open_harness();
|
||||
harness.state_mut().pick_override = Some(vec![picked.0.to_string_lossy().into_owned()]);
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
harness.state_mut().run_command("export.text");
|
||||
harness.step();
|
||||
held.started.recv_timeout(Duration::from_secs(5)).expect("picked A reached prepare barrier");
|
||||
harness.state_mut().open_path(other.0.to_string_lossy().as_ref());
|
||||
held.release.send(()).expect("release picked A");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("picked A is ready");
|
||||
held.started.recv_timeout(Duration::from_secs(5)).expect("ordinary B reached prepare barrier");
|
||||
if same_batch {
|
||||
held.release.send(()).expect("release ordinary B before A admission");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B is also ready");
|
||||
}
|
||||
harness.step();
|
||||
assert!(matches!(harness.state().dialog, Some(crate::Dialog::Export(_))));
|
||||
let active = harness.state().active.expect("active Export recipient");
|
||||
let doc = harness.state().session.get(harness.state().views[active].id).expect("Export recipient document");
|
||||
assert_eq!(doc.path.as_deref(), Some(picked.0.to_string_lossy().as_ref()), "Export must retain picked A in {timing} completion timing");
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none());
|
||||
assert_eq!(doc.saved_bytes().as_slice(), original_a.as_slice());
|
||||
assert_eq!(harness.state().views.len(), 1);
|
||||
assert_eq!(harness.state().pending_opens.len(), 1);
|
||||
if !same_batch {
|
||||
held.release.send(()).expect("release ordinary B after A Export opens");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B is now ready");
|
||||
}
|
||||
harness.run_steps(2);
|
||||
assert!(matches!(harness.state().dialog, Some(crate::Dialog::Export(_))));
|
||||
assert_eq!(harness.state().active, Some(active));
|
||||
assert_eq!(harness.state().views.len(), 1, "ready B stays unadmitted while A's modal is open");
|
||||
assert_eq!(harness.state().pending_opens.len(), 1);
|
||||
assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(other.0.to_string_lossy().as_ref()));
|
||||
// Use the actual modal's Cancel button, rather than clearing its state directly.
|
||||
harness.get_by_label("Cancel").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none() && harness.state().pending_opens.is_empty());
|
||||
assert_eq!(harness.state().views.len(), 2, "cancel releases B's retained result exactly once");
|
||||
let active = harness.state().active.expect("ordinary B activates after cancel");
|
||||
let doc = harness.state().session.get(harness.state().views[active].id).expect("ordinary B document");
|
||||
assert_eq!(doc.path.as_deref(), Some(other.0.to_string_lossy().as_ref()));
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none());
|
||||
assert_eq!(doc.saved_bytes().as_slice(), original_b.as_slice());
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().views.len(), 2);
|
||||
assert!(harness.state().dialog.is_none(), "A's tool does not run again after B admission");
|
||||
for (file, original) in [(&picked, &original_a), (&other, &original_b)] {
|
||||
assert_eq!(std::fs::read(&file.0).expect("read unchanged modal fixture"), *original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn tool_password_prompt_retains_other_ready_open_through_retry() {
|
||||
let protected = encrypted_tool_fixture();
|
||||
let picked = tool_open_file("password-recipient-a", "pdf", &protected);
|
||||
let other = tool_open_file("password-recipient-b", "pdf", &fixture(2));
|
||||
let mut harness = tool_open_harness();
|
||||
harness.state_mut().pick_override = Some(vec![picked.0.to_string_lossy().into_owned()]);
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
harness.state_mut().run_command("export.text");
|
||||
harness.step();
|
||||
held.started.recv_timeout(Duration::from_secs(5)).expect("encrypted A reached prepare barrier");
|
||||
harness.state_mut().open_path(other.0.to_string_lossy().as_ref());
|
||||
held.release.send(()).expect("release encrypted A");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("encrypted A is ready");
|
||||
held.started.recv_timeout(Duration::from_secs(5)).expect("ordinary B reached prepare barrier");
|
||||
held.release.send(()).expect("release ordinary B");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B is ready before prompt admission");
|
||||
harness.step();
|
||||
let prompt = harness.state().password_prompt.as_ref().expect("picked A password prompt");
|
||||
assert_eq!(prompt.path.as_deref(), Some(picked.0.to_string_lossy().as_ref()));
|
||||
assert_eq!(prompt.then, Some("export.text"));
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().views.is_empty());
|
||||
assert_eq!(harness.state().pending_opens.len(), 1, "B cannot replace A's visible password prompt");
|
||||
harness.state_mut().submit_password(Some("wrong-password".into()));
|
||||
held.wait_started();
|
||||
held.complete(&mut harness);
|
||||
// During the retry gap no modal exists, so ordinary B may admit. A still owns its
|
||||
// continuation and its wrong-password prompt, and success must reactivate A.
|
||||
assert_eq!(harness.state().views.len(), 1);
|
||||
let prompt = harness.state().password_prompt.as_ref().expect("picked A wrong-password prompt");
|
||||
assert_eq!(prompt.path.as_deref(), Some(picked.0.to_string_lossy().as_ref()));
|
||||
assert!(prompt.error.is_some());
|
||||
assert_eq!(prompt.then, Some("export.text"));
|
||||
assert!(harness.state().dialog.is_none());
|
||||
harness.state_mut().submit_password(Some("tool-password".into()));
|
||||
held.wait_started();
|
||||
held.complete(&mut harness);
|
||||
assert!(harness.state().password_prompt.is_none() && harness.state().pending_opens.is_empty());
|
||||
assert_eq!(harness.state().views.len(), 2);
|
||||
assert!(matches!(harness.state().dialog, Some(crate::Dialog::Export(_))));
|
||||
let active = harness.state().active.expect("A reactivates for Export");
|
||||
let doc = harness.state().session.get(harness.state().views[active].id).expect("A Export recipient");
|
||||
assert_eq!(doc.path.as_deref(), Some(picked.0.to_string_lossy().as_ref()));
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none());
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().active, Some(active));
|
||||
harness.get_by_label("Cancel").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none() && harness.state().pending_opens.is_empty());
|
||||
assert_eq!(harness.state().views.len(), 2);
|
||||
assert_eq!(std::fs::read(&picked.0).expect("read original encrypted A"), protected.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() {
|
||||
for cancel in [false, true] {
|
||||
let case = if cancel { "picker-cancel" } else { "picker-insert" };
|
||||
let original_a = fixture(2);
|
||||
let original_b = fixture(1);
|
||||
let original_c = fixture(1);
|
||||
let picked = tool_open_file(&format!("{case}-a"), "pdf", &original_a);
|
||||
let other = tool_open_file(&format!("{case}-b"), "pdf", &original_b);
|
||||
let insert = tool_open_file(&format!("{case}-c"), "pdf", &original_c);
|
||||
let mut harness = tool_open_harness();
|
||||
harness.state_mut().pick_override = Some(vec![picked.0.to_string_lossy().into_owned()]);
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
harness.state_mut().run_command("page.insert");
|
||||
harness.step();
|
||||
held.started.recv_timeout(Duration::from_secs(5)).expect("picked A reached prepare barrier");
|
||||
assert!(harness.state().views.is_empty());
|
||||
assert_eq!(harness.state().pending_opens.len(), 1);
|
||||
harness.state_mut().open_path(other.0.to_string_lossy().as_ref());
|
||||
// A's first picker was consumed above. Its real Insert pages continuation must
|
||||
// now queue this second picker, bound to A, when the prepared document commits.
|
||||
harness.state_mut().pick_override = Some(if cancel { Vec::new() } else { vec![insert.0.to_string_lossy().into_owned()] });
|
||||
held.release.send(()).expect("release picked A");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("picked A reply is ready");
|
||||
held.started.recv_timeout(Duration::from_secs(5)).expect("ordinary B reached prepare barrier");
|
||||
held.release.send(()).expect("release ordinary B");
|
||||
held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B reply is also ready");
|
||||
assert_eq!(harness.state().pending_opens.len(), 2);
|
||||
|
||||
// Poll admission separately from consuming the picker callback: this reproduces
|
||||
// the same ordering as a UI frame, with both worker replies already available.
|
||||
harness.state_mut().poll_pending_opens();
|
||||
assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker");
|
||||
assert_eq!(harness.state().active, Some(0));
|
||||
assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none());
|
||||
let a_id = harness.state().views[0].id;
|
||||
let doc = harness.state().session.get(a_id).expect("picked A before picker completion");
|
||||
assert_eq!(doc.path.as_deref(), Some(picked.0.to_string_lossy().as_ref()));
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none());
|
||||
assert_eq!(harness.state().pending_opens.len(), 1);
|
||||
assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(other.0.to_string_lossy().as_ref()));
|
||||
harness.state_mut().poll_pending_opens();
|
||||
assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed");
|
||||
assert_eq!(harness.state().active, Some(0));
|
||||
|
||||
// Consume the actual native-picker callback (or its empty-path cancellation).
|
||||
harness.state_mut().process_picked();
|
||||
let doc = harness.state().session.get(a_id).expect("A after picker completion");
|
||||
assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 });
|
||||
assert_eq!(doc.dirty, !cancel);
|
||||
assert_eq!(doc.can_undo().is_some(), !cancel);
|
||||
assert!(doc.can_redo().is_none());
|
||||
assert_eq!(harness.state().views.len(), 1);
|
||||
assert_eq!(harness.state().pending_opens.len(), 1);
|
||||
assert_eq!(harness.state().active, Some(0));
|
||||
|
||||
harness.step();
|
||||
assert!(harness.state().pending_opens.is_empty());
|
||||
assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once");
|
||||
let active = harness.state().active.expect("B activates after picker completion");
|
||||
let doc = harness.state().session.get(harness.state().views[active].id).expect("ordinary B document");
|
||||
assert_eq!(doc.path.as_deref(), Some(other.0.to_string_lossy().as_ref()));
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none());
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none());
|
||||
assert_eq!(harness.state().views.len(), 2);
|
||||
assert_eq!(harness.state().active, Some(active));
|
||||
let doc = harness.state().session.get(a_id).expect("A retains exactly one insertion or cancellation");
|
||||
assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 });
|
||||
assert_eq!(doc.dirty, !cancel);
|
||||
assert_eq!(doc.can_undo().is_some(), !cancel);
|
||||
assert!(doc.can_redo().is_none());
|
||||
assert_eq!(doc.saved_bytes().as_slice(), original_a.as_slice(), "insertion preserves A's saved source");
|
||||
for (file, original) in [(&picked, &original_a), (&other, &original_b), (&insert, &original_c)] {
|
||||
assert_eq!(std::fs::read(&file.0).expect("read unchanged original picker fixture"), *original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,24 +81,36 @@ pub struct Pickers {
|
||||
}
|
||||
|
||||
/// Clears `showing` if the worker ends without queueing a pick (it panicked).
|
||||
struct Showing(Option<Arc<AtomicBool>>);
|
||||
struct Showing {
|
||||
flag: Option<Arc<AtomicBool>>,
|
||||
ctx: Option<egui::Context>,
|
||||
}
|
||||
|
||||
impl Showing {
|
||||
/// The pick is queued: the frame that uses it clears the flag.
|
||||
fn hand_over(mut self) {
|
||||
self.0 = None;
|
||||
self.flag = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Showing {
|
||||
fn drop(&mut self) {
|
||||
if let Some(flag) = &self.0 {
|
||||
if let Some(flag) = &self.flag {
|
||||
flag.store(false, Ordering::SeqCst);
|
||||
// No picked result will wake the frame to resume opens behind this picker.
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pickers {
|
||||
/// A picker is showing or its result is waiting for the frame.
|
||||
pub(crate) fn pending(&self) -> bool {
|
||||
self.showing.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Wait for `pick` on a worker thread and queue its result for the next frame. On macOS
|
||||
/// `pick` must already have shown the picker (rfd starts the panel when the future is
|
||||
/// created, on the main thread). Returns false when the worker couldn't start.
|
||||
@@ -114,7 +126,7 @@ impl Pickers {
|
||||
where
|
||||
F: Future<Output = Vec<PathBuf>> + Send + 'static,
|
||||
{
|
||||
let showing = Showing(Some(self.showing.clone()));
|
||||
let showing = Showing { flag: Some(self.showing.clone()), ctx: ctx.clone() };
|
||||
let done = self.done.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-picker".into())
|
||||
@@ -225,6 +237,13 @@ impl PdfCraftApp {
|
||||
for Picked { target, paths, then } in self.pickers.take() {
|
||||
// The pick is in hand: the next picker may show, including one `then` asks for.
|
||||
self.pickers.showing.store(false, Ordering::SeqCst);
|
||||
// Opens were polled before this pick was consumed. Wake their retained replies
|
||||
// once, including after cancellation, without polling while a picker waits.
|
||||
if !self.pending_opens.is_empty()
|
||||
&& let Some(ctx) = &self.ctx
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -264,18 +283,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_worker_clears_showing_and_queues_nothing() {
|
||||
fn a_panicking_worker_releases_picker_and_requests_repaint() {
|
||||
let pickers = Pickers::default();
|
||||
pickers.showing.store(true, Ordering::SeqCst);
|
||||
let ctx = egui::Context::default();
|
||||
// Consume the context's initial repaint demand so only the failed worker can wake it.
|
||||
for _ in 0..2 {
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |_| {});
|
||||
}
|
||||
assert!(!ctx.has_requested_repaint());
|
||||
let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let requested = wakes.clone();
|
||||
ctx.set_request_repaint_callback(move |_| {
|
||||
requested.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
fn fail() -> Vec<PathBuf> {
|
||||
panic!("picker failed")
|
||||
}
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
let worker = pickers.spawn_worker(None, None, async { fail() }, record(&got)).expect("worker starts");
|
||||
let worker = pickers.spawn_worker(None, Some(ctx.clone()), async { fail() }, record(&got)).expect("worker starts");
|
||||
// join returns once the panic has unwound and the drop guard has run.
|
||||
assert!(worker.join().is_err());
|
||||
assert!(!pickers.showing.load(Ordering::SeqCst));
|
||||
assert!(pickers.take().is_empty());
|
||||
assert!(ctx.has_requested_repaint(), "retained opens must get another frame after a picker worker fails");
|
||||
assert_eq!(wakes.load(Ordering::SeqCst), 1, "an abandoned picker requests one wake");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -289,6 +321,43 @@ mod tests {
|
||||
assert_eq!(*got.lock().unwrap(), vec![PathBuf::from("b.pdf")], "a cancelled pick runs nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_requests_a_frame_for_a_retained_open_reply() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let ctx = egui::Context::default();
|
||||
app.ctx = Some(ctx.clone());
|
||||
for _ in 0..2 {
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |_| {});
|
||||
}
|
||||
assert!(!ctx.has_requested_repaint());
|
||||
let (reply, receiver) = std::sync::mpsc::channel();
|
||||
reply
|
||||
.send(crate::open_worker::ResultMessage { token: 1, kind: crate::open_worker::ResultKind::ReadFailed("controlled failure".into()) })
|
||||
.unwrap();
|
||||
app.pending_opens.push(crate::PendingOpen {
|
||||
token: 1,
|
||||
receiver,
|
||||
name: "waiting.pdf".into(),
|
||||
path: None,
|
||||
stamp: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
});
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
app.pickers.showing.store(true, Ordering::SeqCst);
|
||||
app.pickers.deliver(None, vec![], record(&got));
|
||||
app.poll_pending_opens();
|
||||
assert_eq!(app.pending_opens.len(), 1, "the picker keeps the ready reply queued");
|
||||
app.process_picked();
|
||||
assert!(!app.pickers.showing.load(Ordering::SeqCst));
|
||||
assert!(got.lock().unwrap().is_empty(), "cancellation runs no callback");
|
||||
assert!(ctx.has_requested_repaint(), "cancellation must wake the earlier blocked open poll");
|
||||
app.poll_pending_opens();
|
||||
assert!(app.pending_opens.is_empty(), "the retained ready reply is consumed on the next poll");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_picker_is_refused_while_one_is_showing() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
|
||||
@@ -95,6 +95,7 @@ total matches the parity estimate.
|
||||
| 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. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
|
||||
| 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. 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). #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. |
|
||||
| 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). |
|
||||
@@ -171,6 +172,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M2/M6: images with at most 16 bits of samples a pixel (8-bit Separation/DeviceN, 1-4-bit gray or indexed) convert once per pixel value (hayro-interpret patch 28), so spot-colour form backgrounds draw in milliseconds instead of seconds; widgets without `/P` are placed in one pass; the Fields panel no longer searches the form per row and draws only the rows in view. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M3: native-open admission preserves modal/picker ownership; regressions cover queued replies and one-shot picker wakes. Estimates unchanged |
|
||||
| 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged |
|
||||
| 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged |
|
||||
| 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user