diff --git a/.github/workflows/printing.yml b/.github/workflows/printing.yml new file mode 100644 index 0000000..722cd5d --- /dev/null +++ b/.github/workflows/printing.yml @@ -0,0 +1,73 @@ +# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates +# stay on their existing nightly/release schedule. +name: Printing + +on: + pull_request: + paths: + - 'Cargo*' + - 'apps/wordcraft/**' + - 'apps/wordcraft-web/**' + - 'crates/ui-egui/**' + - '.github/workflows/printing.yml' + push: + branches: [main] + paths: + - 'Cargo*' + - 'apps/wordcraft/**' + - 'apps/wordcraft-web/**' + - 'crates/ui-egui/**' + - '.github/workflows/printing.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: printing-${{ github.ref }} + cancel-in-progress: true + +jobs: + native: + name: Print (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Linux build and software-rendering dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ + libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Formatting + run: cargo fmt --all -- --check + + - name: Native app and UI Clippy + run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings + + - name: Native app and UI tests + run: cargo test -p wordcraft -p wordcraft-ui-egui --locked + + - name: Browser compatibility + if: runner.os == 'Linux' + run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked diff --git a/Cargo.lock b/Cargo.lock index 7e8d5b5..ae82a38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,6 +843,22 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "tempfile", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +dependencies = [ + "egui", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -5086,6 +5102,7 @@ version = "0.6.1" dependencies = [ "arboard", "clipboard-win", + "craft-print", "dispatch2", "eframe", "egui", @@ -5321,6 +5338,7 @@ dependencies = [ name = "wordcraft-ui-egui" version = "0.6.1" dependencies = [ + "craft-ui", "egui", "egui_kittest", "image", diff --git a/ROADMAP.md b/ROADMAP.md index 4504f02..1dce7f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. + WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is [`docs/target-app-parity.md`](docs/target-app-parity.md), the work list is @@ -200,6 +202,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) | | 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) | | 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | +| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | | 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | | 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back | @@ -300,6 +303,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: large-table scrolling (#811) | | 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) | | 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) | +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) | | 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) | | 2026-10-11 | trivial | Progress log: Outline view (#493) | diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index bf059dd..7e79f24 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -54,6 +54,8 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block wl-clipboard-rs = "0.9" [target.'cfg(target_os = "windows")'.dependencies] +# The upstream default-apps button opens Windows Settings; printing uses craft-print instead. +open = "5" # The system's Animation effects setting (Settings › Accessibility › Visual effects), read through # the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our # workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds @@ -65,6 +67,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. @@ -74,6 +77,3 @@ pollster = "0.4" arboard = { workspace = true, features = ["image-data"] } # The control server's random key. getrandom = "0.3" -# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the -# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest. -open = "5.4" diff --git a/apps/wordcraft/src/default_apps.rs b/apps/wordcraft/src/default_apps.rs index 8f07b21..263fc73 100644 --- a/apps/wordcraft/src/default_apps.rs +++ b/apps/wordcraft/src/default_apps.rs @@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec { /// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default /// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths /// are ignored, as the XDG Base Directory spec says. -#[cfg(any(all(unix, not(target_os = "macos")), test))] +#[cfg(all(unix, any(not(target_os = "macos"), test)))] fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec { use std::path::{Path, PathBuf}; let absolute = |s: &&str| Path::new(s).is_absolute(); @@ -130,6 +130,7 @@ mod xdg { #[cfg(test)] mod tests { + #[cfg(unix)] use std::path::PathBuf; use super::*; @@ -163,6 +164,7 @@ mod tests { assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line"); } + #[cfg(unix)] #[test] fn application_dirs_follow_the_xdg_spec() { let p = |s: &str| PathBuf::from(s); diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 18184f8..988b4f6 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -54,7 +54,7 @@ impl eframe::App for App { let closing = ctx.input(|i| i.viewport().close_requested()); if closing && !self.0.close_requested() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - } else if closing || self.0.quit_requested { + } else if (closing || self.0.quit_requested) && !self.0.printing.busy() { // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in // `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once // the close is allowed, take that path instead of dropping the window on this turn. @@ -78,6 +78,7 @@ impl eframe::App for App { self.3.show(frame); } fn on_exit(&mut self) { + print::finish_pending_jobs(); save_prefs(&self.0); if let Some(key_file) = &self.2 { key_file.remove(); @@ -95,11 +96,16 @@ impl eframe::App for App { #[cfg(target_os = "macos")] fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) { use std::sync::atomic::{AtomicBool, Ordering}; + if app.printing.busy() { + return; + } static QUIT: AtomicBool = AtomicBool::new(false); if QUIT.swap(true, Ordering::Relaxed) { return; } log::info!("window close: quitting"); + // This path can bypass on_exit. Join the owned workers before termination and cleanup. + print::finish_pending_jobs(); save_prefs(app); if let Some(key_file) = key_file { key_file.remove(); @@ -193,13 +199,14 @@ fn save_prefs(app: &WordApp) { } /// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print -/// through the system's PDF viewer (`print`, #15), the system clipboard's text, formatted copies, +/// through the system spooler (`print`), the system clipboard's text, formatted copies, /// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting /// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295). fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { Services { file_dialog: Some(file_dialog), - print: Some(print::hook()), + printers: Some(print::discover()), + print_native: Some(print::submit()), clipboard_picture: Some(Box::new(clipboard::picture)), make_default_app: default_apps::hook(), animations_enabled: Some(animations::hook(ctx)), diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index 826574f..978bdc4 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -1,229 +1,76 @@ -//! Desktop printing (#15): File › Print hands the document to the system's print flow. -//! -//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls -//! [`Services::print`] with the bytes. Here they go to a fresh temporary file -//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it: -//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and -//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user -//! prints from there through the system print dialog. -//! -//! The viewer reads the file after we return, so it can't be removed right away: files older than -//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print. -//! -//! [`Services::print`]: wordcraft_ui_egui::Services::print +//! Desktop spooler adapter. Discovery and submission never run on the UI thread. +use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit}; -use std::fs::OpenOptions; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -/// Temporary print files start with this and end with `.pdf`. -const PREFIX: &str = "wordcraft-print-"; -/// How long a print file is kept for its viewer before the clean-up removes it. -const KEEP: Duration = Duration::from_secs(60 * 60); - -/// The `Services::print` hook. -pub type Hook = Box Result<(), String>>; - -/// The desktop print hook; also clears print files left over from earlier runs. -pub fn hook() -> Hook { - let dir = std::env::temp_dir(); - spawn_cleanup(dir.clone()); - Box::new(move |bytes| { - let r = print_with(&dir, bytes, open_viewer); - spawn_cleanup(dir.clone()); - r +pub fn discover() -> Discover { + Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { + let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect(); + let _ = sender.send(printers); + }); + receiver }) } -/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file -/// is removed again. -fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> { - let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?; - open(&path).inspect_err(|_| { - let _ = std::fs::remove_file(&path); +// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would +// let process exit kill PowerShell/CUPS before it hands the job to the spooler. +static JOBS: std::sync::Mutex>> = std::sync::Mutex::new(Vec::new()); + +pub fn finish_pending_jobs() { + let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner())); + for worker in workers { + if worker.join().is_err() { + log::error!("the print worker stopped unexpectedly during shutdown"); + } + } +} + +pub fn submit() -> Submit { + Box::new(|bytes, settings: Settings, collate| { + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || { + let job = craft_print::Job { + printer: Some(settings.printer), + copies: settings.copies.clamp(1, 999), + collate, + duplex: settings.duplex.map(|value| match value { + Duplex::LongEdge => craft_print::Duplex::LongEdge, + Duplex::ShortEdge => craft_print::Duplex::ShortEdge, + Duplex::Off => craft_print::Duplex::Off, + }), + grayscale: settings.grayscale, + scaling: craft_print::Scaling::ShrinkToFit, + title: "WordCraft document".into(), + ..Default::default() + }; + let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string())); + }); + if let Ok(worker) = worker { + let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner()); + jobs.retain(|job| !job.is_finished()); + jobs.push(worker); + } + receiver }) } -/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or -/// follows a link at) an existing path; readable by the user only where the OS supports it. -fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result { - static COUNTER: AtomicU32 = AtomicU32::new(0); - let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0); - for _ in 0..16 { - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id())); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - match options.open(&path) { - Ok(mut file) => { - return match file.write_all(bytes) { - Ok(()) => Ok(path), - Err(e) => { - drop(file); - let _ = std::fs::remove_file(&path); - Err(e) - } - }; - } - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name")) -} - -/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many. -/// Other files, links and files it may not remove are left alone. -fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize { - let Ok(entries) = std::fs::read_dir(dir) else { - return 0; - }; - let mut removed = 0; - for entry in entries.flatten() { - if !is_print_file(&entry.file_name().to_string_lossy()) { - continue; - } - // `DirEntry::metadata` doesn't follow links. - let Ok(meta) = entry.metadata() else { continue }; - let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep); - if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() { - removed += 1; - } - } - removed -} - -fn is_print_file(name: &str) -> bool { - name.starts_with(PREFIX) && name.ends_with(".pdf") -} - -/// Runs [`remove_stale`] on a background thread (the temp folder can be large). -fn spawn_cleanup(dir: PathBuf) { - let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || { - let n = remove_stale(&dir, SystemTime::now(), KEEP); - if n > 0 { - log::info!("removed {n} old print file(s)"); - } - }); - if let Err(e) = spawned { - log::warn!("print clean-up: {e}"); - } -} - -/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app. -#[cfg(target_os = "macos")] -fn open_viewer(path: &Path) -> Result<(), String> { - let mut child = - std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?; - let path = path.to_path_buf(); - let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || { - if !child.wait().is_ok_and(|s| s.success()) - && let Err(e) = open::that(&path) - { - log::error!("print: couldn't open {}: {e}", path.display()); - } - }); - if let Err(e) = waiter { - log::warn!("print: {e}"); - } - Ok(()) -} - -/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD; -/// the file association on Windows). -#[cfg(not(target_os = "macos"))] -fn open_viewer(path: &Path) -> Result<(), String> { - open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}")) -} - #[cfg(test)] mod tests { use super::*; - use serde_json::json; - use std::cell::RefCell; - use std::rc::Rc; - use wordcraft_engine::Session; - use wordcraft_ui_egui::{Services, WordApp}; - - fn temp_dir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - /// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and - /// hands that file to the viewer (faked here: no process is started); a viewer that can't - /// start is reported in the status bar and leaves no file behind. #[test] - fn file_print_opens_a_pdf_file() { - let dir = temp_dir("open"); - let opened: Rc)>>> = Rc::default(); - let seen = opened.clone(); - let hook_dir = dir.clone(); - let services = Services { - print: Some(Box::new(move |bytes| { - print_with(&hook_dir, bytes, |path| { - seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?)); - Ok(()) - }) - })), - ..Default::default() - }; - let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); - app.run("text.insert", json!({"text": "Print me"})).unwrap(); - app.run("file.print", json!({})).unwrap(); - assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page"); - app.run("ui.print", json!({})).unwrap(); - app.run("ui.print", json!({})).unwrap(); - - let opened = opened.borrow(); - assert_eq!(opened.len(), 2, "each print opens the viewer once"); - for (path, bytes) in opened.iter() { - assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF"); - assert_eq!(path.parent(), Some(dir.as_path())); - assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display()); - } - assert_ne!(opened[0].0, opened[1].0, "every print gets its own file"); - - let mut app = WordApp::new( - Session::new(wordcraft_doc::Document::new()), - Services { - print: Some(Box::new({ - let dir = dir.clone(); - move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into())) - })), - ..Default::default() - }, - ); - let e = app.run("ui.print", json!({})).unwrap_err(); - assert!(e.contains("no PDF viewer"), "{e}"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The clean-up removes old print files only. - #[test] - fn stale_print_files_are_removed() { - let dir = temp_dir("stale"); - let fresh = write_temp(&dir, b"%PDF-1.7").unwrap(); - let old = write_temp(&dir, b"%PDF-1.7").unwrap(); - let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60); - OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap(); - let other = dir.join("notes.pdf"); - std::fs::write(&other, b"keep").unwrap(); - OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap(); - - assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1); - assert!(fresh.exists() && other.exists() && !old.exists()); - assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine"); - let _ = std::fs::remove_dir_all(&dir); + fn shutdown_waits_for_pending_submission_workers() { + let (release, wait) = std::sync::mpsc::channel(); + let (completed, completion) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + wait.recv().unwrap(); + completed.send(()).unwrap(); + }); + JOBS.lock().unwrap().push(worker); + let shutdown = std::thread::spawn(finish_pending_jobs); + assert!(completion.try_recv().is_err(), "the print worker is still pending"); + release.send(()).unwrap(); + shutdown.join().unwrap(); + completion.recv().unwrap(); + assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned"); } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 83c5cad..f4a0b66 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c011f68..dda9412 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -2,7 +2,7 @@ //! //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): //! - `engine.execute {command, params}`: run any command (engine or `ui.*`) -//! - `engine.commands`: every command with its enablement +//! - `engine.commands {search?}`: engine and app print commands with enablement and parameters //! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf //! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`: //! real pointer input in screen points (reaches every widget) @@ -119,12 +119,19 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } "engine.commands" => { let reg = app.session.registry.clone(); - ok(Value::Array( - reg.all() - .iter() - .map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()})) - .collect(), - )) + let mut commands: Vec = reg.all().iter() + .map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()})) + .collect(); + commands.extend(crate::printing::commands(app)); + let search = s("search").unwrap_or("").to_ascii_lowercase(); + if !search.is_empty() { + commands.retain(|command| { + ["id", "label", "location", "params"] + .iter() + .any(|field| command.get(field).and_then(Value::as_str).is_some_and(|text| text.to_ascii_lowercase().contains(&search))) + }); + } + ok(Value::Array(commands)) } "document.inspect" => match app.session.run("document.inspect", p) { Ok(v) => ok(v), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a10cee2..83659fc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,7 @@ pub mod panes; pub mod paste_picture; pub mod previews; pub mod print_page; +pub mod printing; pub mod read_aloud; pub mod ribbon; pub mod scroll; @@ -87,6 +88,10 @@ pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters}; /// Platform services injected by the host (file dialogs, file I/O). #[derive(Default)] pub struct Services { + /// Desktop: discover configured printers on a worker. + pub printers: Option, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, /// Pick a file to open; the argument says what for (`document`, `picture`). pub pick_open: Option Option>>, /// Pick a path to save to, given a suggested name. @@ -107,9 +112,8 @@ pub struct Services { /// Web: told whether the document has unsaved changes after each pass, for the browser's /// leave-page guard (`beforeunload` runs between frames and can't ask the app). pub on_dirty: Option>, - /// Hand PDF bytes to the system's print flow. Web: the browser's print dialog on that PDF (no - /// download, no intermediate file). Desktop: the PDF opens in the system's viewer (a temporary - /// file), where the user prints (#15). An error means nothing was opened. + /// Web: hand PDF bytes to the browser's print dialog (no download or intermediate file). + /// Desktop uses `print_native` instead. An error means nothing was opened. pub print: Option Result<(), String>>>, /// Desktop: the picture on the system clipboard, if any — egui's paste only carries text /// (#45). Read when Paste finds no text; see [`paste_picture`]. @@ -341,6 +345,8 @@ pub struct WordApp { pub session: Session, pub ui: UiState, pub services: Services, + pub printing: printing::State, + print_feedback_seen: Option, pub canvas: canvas::CanvasState, /// Content control frames, check boxes and lists on the page. pub controls: content_controls::ControlUi, @@ -460,6 +466,8 @@ impl WordApp { session, ui: UiState::default(), services, + printing: Default::default(), + print_feedback_seen: None, canvas: canvas::CanvasState::default(), controls: Default::default(), dialog: None, @@ -828,6 +836,10 @@ impl WordApp { /// The window's close button (or the system) asked to quit. Returns true to let the window /// close; with unsaved changes it asks first and returns false. pub fn close_requested(&mut self) -> bool { + if self.printing.busy() { + self.status(tl!("Wait for the print job to finish submitting before closing.")); + return false; + } if self.quit_requested || !self.session.dirty { return true; } @@ -1088,10 +1100,9 @@ impl WordApp { self.pick_recipient_list(); json!({"pending": self.recipient_list_pending}) } - // Send the document to the system print flow (web, desktop). `file.print` opens the Print - // page; this is the button on it, and the one programmatic call that opens system UI - // here, like `ui.openFileDialog` — only when the host can print. - "ui.print" => return Some(self.print_to_system(p)), + // Native printing submits the selected queue on a worker; the browser owns its dialog. + "ui.printSettings" => return Some(printing::settings(&mut self.printing, p, self.services.printers.as_ref())), + "ui.print" | "ui.printSubmit" => return Some(self.print_to_system(p)), // File › Options › General › Make WordCraft the default… changes the user's system // default apps (Linux) or opens system settings (Windows), so only a click on that // button does it ([`WordApp::make_default_app`]); scripts, the control channel and @@ -1139,38 +1150,9 @@ impl WordApp { })) } - /// `ui.print`: export the document to PDF in memory and hand it to the host's print hook. - /// `p` holds the print settings (`range`, `pages`, `parity`, `copies`, `collate`; see - /// `file.printPdf`); without any, every page prints once. - /// An error when the host can't print (File › Print then only saves a PDF). + /// Submit the selected print job; the implementation lives beside its localized controls. fn print_to_system(&mut self, p: &Value) -> Result { - if self.services.print.is_none() { - return Err("printing to the system print dialog isn't available here; export a PDF instead".into()); - } - // File › Options › Display: fields first, and the page colour only when asked for. - if self.ui.update_fields_before_print - && let Err(e) = self.session.run("references.updateFields", &json!({})) - { - log::warn!("updating fields before printing: {e}"); - } - let colour = if self.ui.print_backgrounds { None } else { self.session.doc.settings.page_color.take() }; - let pdf = self.session.print_pdf(p).map_err(|e| e.to_string()); - if colour.is_some() { - self.session.doc.settings.page_color = colour; - } - let result = pdf.and_then(|(bytes, _)| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len())); - match result { - Ok(len) => { - self.status(tl!("Opening print dialog…")); - Ok(json!({"printing": true, "bytes": len})) - } - Err(e) => { - log::error!("print failed: {e}"); - let msg = i18n::fmt(tl!("Print failed: {error}"), &[("error", &e)]); - self.status(msg.clone()); - Err(msg) - } - } + printing::submit(self, p) } /// File › Options › General › Make WordCraft the default…: hand over to the host's @@ -1307,6 +1289,22 @@ impl WordApp { } } + /// Terminal feedback also reaches the normal window when printing starts through control. + pub fn poll_printing(&mut self, ctx: &egui::Context) { + self.printing.poll(ctx); + if self.printing.message == self.print_feedback_seen { + return; + } + self.print_feedback_seen = self.printing.message.clone(); + let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = + self.printing.message.clone() + else { + return; + }; + let text = printing::feedback(&message, |label| i18n::t(label).to_owned()); + self.status(text); + } + /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. pub fn logic(&mut self, ctx: &egui::Context) { let lang = i18n::Lang::from_pref(&self.ui.language); @@ -1361,6 +1359,7 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } + self.poll_printing(ctx); self.drain_control(ctx); zotero::poll(self, ctx); read_aloud::poll(self, ctx); diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index 560dbe8..50e8b93 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -1,9 +1,7 @@ //! File › Print (#641): the print settings and a preview of the pages. //! -//! WordCraft prints through a PDF that the host opens in the system's print flow, so the settings -//! here are the ones that shape that PDF: which pages, odd/even, copies and collation. They go to -//! `ui.print` / `file.printPdf` as params (see `wordcraft_engine::print`). The printer, two-sided -//! printing and pages per sheet are chosen in the system print dialog that opens next. +//! Page range and parity shape the PDF. Native printer settings and copy collation go to +//! the shared spooler; browser printing and saved print PDFs retain copies in the PDF. use std::sync::Arc; @@ -109,8 +107,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.vertical(|ui| { ui.set_width(380.0); ui.spacing_mut().item_spacing.y = 8.0; - let hook = app.services.print.is_some(); - if hook { + let native = app.services.print_native.is_some(); + let hook = native || app.services.print.is_some(); + if native { + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned()); + } + app.ui.print.copies = app.printing.settings.copies; + } else if hook { ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below.")); ui.label( egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim), @@ -119,7 +123,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer.")); } ui.add_space(4.0); - if hook && ui.add(egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() { + if hook + && ui + .add_enabled( + !native || app.printing.ready(), + egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)), + ) + .clicked() + { let params = app.ui.print.params(); let _ = app.run("ui.print", params); } @@ -131,15 +142,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(6.0); - ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); - ui.horizontal(|ui| { - let mut n = app.ui.print.copies; - ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)); - app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES); + if native { if app.ui.print.copies > 1 { - ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")); + ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies"))); } - }); + } else { + ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); + ui.horizontal(|ui| { + let mut n = app.ui.print.copies; + ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)); + app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES); + if app.ui.print.copies > 1 { + ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")); + } + }); + } ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0))); if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) { diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs new file mode 100644 index 0000000..059f34f --- /dev/null +++ b/crates/ui-egui/src/printing.rs @@ -0,0 +1,174 @@ +//! Localized print-page wording around the shared, backend-independent controls. +use craft_ui::printing::Labels; +pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State}; + +/// Native submission adds WordCraft's PDF-page collation setting to the shared spooler settings. +pub type Submit = Box, Settings, bool) -> std::sync::mpsc::Receiver>>; + +/// Metadata for the app-owned commands. Inspecting it never discovers printers or submits. +pub fn commands(app: &crate::WordApp) -> Vec { + use serde_json::json; + let enabled = if app.services.print_native.is_some() { app.printing.ready() } else { app.services.print.is_some() }; + let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool. Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed."; + vec![ + json!({"id":"ui.printSettings", "label":"Print settings", "location":"File › Print", "shortcut":null, + "params":"printer?: discovered name; copies?: integer 1..999; duplex?: printerDefault|off|longEdge|shortEdge|null; grayscale?: bool; refresh?: bool. Empty params inspect printer/copies/duplex/grayscale, printers, busy/discovering/ready and status/detail. Updates are atomic; refresh discovers only on native hosts. Never submits or opens a dialog.", + "enabled":true}), + json!({"id":"ui.printSubmit", "label":"Submit print job", "location":"File › Print", "shortcut":null, "params":submit_params, "enabled":enabled}), + json!({"id":"ui.print", "label":"Print (alias of ui.printSubmit)", "location":"File › Print", "shortcut":null, "params":submit_params, "enabled":enabled}), + ] +} + +pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) { + let labels = Labels { + printer: &translate("Printer"), + select_printer: &translate("Select a printer"), + refresh: &translate("Refresh printers"), + copies: &translate("Copies"), + duplex: &translate("Two-sided printing"), + printer_default: &translate("Printer default"), + one_sided: &translate("One-sided"), + long_edge: &translate("Two-sided: long edge"), + short_edge: &translate("Two-sided: short edge"), + grayscale: &translate("Grayscale"), + }; + state.show(ui, egui::Id::new("wordcraft_print_settings"), discover, &labels); + if let Some(message) = &state.message { + ui.label(feedback(message, translate)); + } +} + +/// Read print settings/status, or validate an explicit update before changing anything. +/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog. +pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result { + use serde_json::{Value, json}; + let object = params.as_object().ok_or("print settings must be an object")?; + if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) { + return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into()); + } + if state.busy() && !object.is_empty() { + return Err("wait for the active print submission before changing settings".into()); + } + let mut next = state.settings.clone(); + if let Some(value) = object.get("printer") { + let printer = value.as_str().ok_or("printer must be a name from the discovered list")?; + if !state.printers.iter().any(|available| available.name == printer) { + return Err("printer must be a name from the discovered list".into()); + } + next.printer = printer.to_owned(); + } + if let Some(value) = object.get("copies") { + let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?; + next.copies = copies as u32; + } + if let Some(value) = object.get("duplex") { + next.duplex = match value.as_str() { + Some("printerDefault") => None, + Some("off") => Some(Duplex::Off), + Some("longEdge") => Some(Duplex::LongEdge), + Some("shortEdge") => Some(Duplex::ShortEdge), + None if value.is_null() => None, + _ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()), + }; + } + if let Some(value) = object.get("grayscale") { + next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?; + } + let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false); + if refresh { + let discover = discover.ok_or("native printer discovery is unavailable here")?; + if !state.refresh(discover) { + return Err("printer discovery or submission is already active".into()); + } + } + state.settings = next; + let (status, detail) = match state.message.as_ref() { + None => ("idle", ""), + Some(Feedback::FindingPrinters) => ("findingPrinters", ""), + Some(Feedback::NoPrinters) => ("noPrinters", ""), + Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""), + Some(Feedback::Submitting) => ("submitting", ""), + Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()), + Some(Feedback::Failed(error)) => ("failed", error.as_str()), + Some(Feedback::WorkerStopped) => ("workerStopped", ""), + }; + Ok(json!({ + "printer": state.settings.printer, "copies": state.settings.copies, + "duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" }, + "grayscale": state.settings.grayscale, + "printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::>(), + "busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail, + })) +} + +pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String { + match message { + Feedback::FindingPrinters => translate("Finding printers…"), + Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."), + Feedback::DiscoveryFailed => translate("Printer discovery failed."), + Feedback::Submitting => translate("Submitting print job…"), + Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."), + Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt), + Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error), + Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."), + } +} + +/// Build the upstream page-range/markup PDF and hand it to the chosen host print flow. +pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result { + use serde_json::json; + if app.services.print.is_none() && app.services.print_native.is_none() { + return Err("printing to the system print dialog isn't available here; export a PDF instead".into()); + } + let native = app.services.print_native.is_some(); + if native && !app.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".into()); + } + let mut pdf_params = params.clone(); + let mut settings = app.printing.settings.clone(); + let collate = params.get("collate").map(|value| value.as_bool().ok_or("collate must be a boolean")).transpose()?.unwrap_or(app.ui.print.collate); + if native { + let object = pdf_params.as_object_mut().ok_or("print settings must be an object")?; + if let Some(copies) = object.get("copies") { + settings.copies = copies.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")? as u32; + } + // Copies/collation belong to the spooler. Baking them into the PDF would multiply + // the copies twice, and duplex could put the end of one copy behind the next. + object.insert("copies".into(), json!(1)); + } + if app.ui.update_fields_before_print + && let Err(error) = app.session.run("references.updateFields", &json!({})) + { + log::warn!("updating fields before printing: {error}"); + } + let colour = if app.ui.print_backgrounds { None } else { app.session.doc.settings.page_color.take() }; + let pdf = app.session.print_pdf(&pdf_params).map_err(|error| error.to_string()); + if colour.is_some() { + app.session.doc.settings.page_color = colour; + } + let result = pdf.and_then(|(bytes, plan)| { + if native && plan.chosen.checked_mul(settings.copies as usize).is_none_or(|pages| pages > wordcraft_engine::print::MAX_PRINT_PAGES) { + return Err("the selected pages and copies exceed the print-job page limit".into()); + } + let len = bytes.len(); + if let Some(submit) = &app.services.print_native { + app.printing.begin(submit(bytes, settings, collate)).map_err(|_| "A print job is already being submitted.".to_owned())?; + app.print_feedback_seen = None; + } else if let Some(print) = &app.services.print { + print(&bytes)?; + } + Ok(len) + }); + match result { + Ok(len) => { + app.status(if native { tl!("Submitting print job…") } else { tl!("Opening print dialog…") }); + Ok(json!({"printing": true, "bytes": len, "pending": native})) + } + Err(error) => { + log::error!("print failed: {error}"); + let msg = crate::i18n::fmt(tl!("Print failed: {error}"), &[("error", &error)]); + app.status(msg.clone()); + Err(msg) + } + } +} diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs new file mode 100644 index 0000000..30a3220 --- /dev/null +++ b/crates/ui-egui/tests/native_print.rs @@ -0,0 +1,203 @@ +//! Native print onboarding preserves engine PDF generation and submits the selected settings. +use serde_json::json; +use std::{cell::RefCell, rc::Rc}; +use wordcraft_engine::Session; +use wordcraft_ui_egui::printing::{Duplex, Feedback, Printer}; +use wordcraft_ui_egui::{Services, WordApp}; + +#[test] +fn control_print_discovery_reports_metadata_without_host_side_effects() { + use std::cell::Cell; + use wordcraft_ui_egui::control::{ControlRequest, Outcome, handle}; + let discoveries = Rc::new(Cell::new(0)); + let submissions = Rc::new(Cell::new(0)); + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + let observed = discoveries.clone(); + app.services.printers = Some(Box::new(move || { + observed.set(observed.get() + 1); + let (_, receiver) = std::sync::mpsc::channel(); + receiver + })); + let observed = submissions.clone(); + app.services.print_native = Some(Box::new(move |_, _, _| { + observed.set(observed.get() + 1); + let (_, receiver) = std::sync::mpsc::channel(); + receiver + })); + let ctx = egui::Context::default(); + let (request, _) = ControlRequest::new("engine.commands", json!({"search":"print"})); + let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") }; + assert_eq!(reply["ok"], true); + let commands = reply["result"].as_array().unwrap(); + assert!(commands.iter().any(|command| command["id"] == "file.print"), "engine print commands remain visible"); + let settings = commands.iter().find(|command| command["id"] == "ui.printSettings").unwrap(); + assert_eq!(settings["enabled"], true, "settings/status inspection is available"); + for field in ["printer", "copies", "duplex", "grayscale", "refresh", "status", "detail"] { + assert!(settings["params"].as_str().unwrap().contains(field), "documented setting: {field}"); + } + for id in ["ui.printSubmit", "ui.print"] { + let command = commands.iter().find(|command| command["id"] == id).unwrap(); + assert_eq!(command["enabled"], false, "a printer must be selected before submission"); + for field in ["range", "pages", "parity", "copies", "collate"] { + assert!(command["params"].as_str().unwrap().contains(field), "{id}: {field}"); + } + } + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); + let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") }; + for id in ["ui.printSubmit", "ui.print"] { + let command = reply["result"].as_array().unwrap().iter().find(|command| command["id"] == id).unwrap(); + assert_eq!(command["enabled"], true, "the selected queue enables {id}"); + } + assert_eq!(discoveries.get(), 0, "metadata never starts printer discovery"); + assert_eq!(submissions.get(), 0, "metadata never submits"); + assert!(app.dialog.is_none() && !app.ui.backstage, "metadata never opens a dialog or Print page"); + assert!(!app.printing.busy() && !app.session.dirty); +} + +#[test] +fn native_print_uses_selected_settings_and_reports_spool_errors() { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + app.run("text.insert", json!({"text": "A document to print"})).unwrap(); + let jobs = Rc::new(RefCell::new(Vec::new())); + let observed = jobs.clone(); + app.services.print_native = Some(Box::new(move |bytes, settings, collate| { + observed.borrow_mut().push((bytes, settings, collate)); + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + assert!(app.run("ui.print", json!({})).is_err()); + assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool"); + app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false }); + let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap(); + assert_eq!(configured["ready"], true); + assert_eq!(configured["printers"][0]["name"], "Chosen queue"); + app.run("ui.print", json!({})).unwrap(); + assert!(app.printing.busy()); + assert!(!app.close_requested(), "a pending print job cannot be interrupted by normal close"); + let jobs = jobs.borrow(); + assert_eq!(jobs.len(), 1); + assert!(jobs[0].0.starts_with(b"%PDF")); + assert_eq!(jobs[0].1.printer, "Chosen queue"); + assert_eq!(jobs[0].1.copies, 3); + assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge)); + assert!(jobs[0].1.grayscale); + assert!(jobs[0].2, "copies are collated by default"); + app.poll_printing(&egui::Context::default()); + assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into()))); + assert!(app.status_msg.as_ref().unwrap().0.contains("printer is offline")); +} + +#[test] +fn print_settings_are_visible_in_a_narrow_window() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + app.run("text.insert", json!({"text": "A document to print"})).unwrap(); + app.services.printers = Some(Box::new(|| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap(); + receiver + })); + app.services.print_native = Some(Box::new(|_, _, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("queued".into())).unwrap(); + receiver + })); + app.ui.backstage = true; + app.ui.backstage_page = "print".into(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state( + |ui, app: &mut WordApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(5); + assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled()); + assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled()); + harness.get_by_label("Grayscale").click(); + harness.run_steps(2); + assert!(harness.state().printing.settings.grayscale); + assert!(harness.state().printing.ready()); +} + +#[test] +fn invalid_print_settings_are_rejected_without_partial_changes() { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + let before = app.run("ui.printSettings", json!({})).unwrap(); + for params in [ + json!({"printer":"Missing"}), + json!({"copies":0}), + json!({"copies":1000}), + json!({"copies":1.5}), + json!({"copies":-1}), + json!({"copies":"3"}), + json!({"duplex":"maybe"}), + json!({"grayscale":"yes"}), + json!({"refresh":"yes"}), + json!({"copies":3,"duplex":"invalid"}), + json!([]), + ] { + assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}"); + assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}"); + } +} + +#[test] +fn repeated_fast_jobs_each_surface_an_identical_receipt() { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("same receipt".into())).unwrap(); + receiver + })); + for _ in 0..2 { + app.status_msg = None; + app.run("ui.printSubmit", json!({})).unwrap(); + // Completion may be consumed by the shared widget before the app's logic pass. + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + assert!(app.status_msg.as_ref().unwrap().0.contains("same receipt")); + app.status_msg = None; + app.poll_printing(&egui::Context::default()); + assert!(app.status_msg.is_none()); + } +} + +#[test] +fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + for page in 1..=3 { + app.run("text.insert", json!({"text":format!("Distinct page {page}")})).unwrap(); + if page < 3 { + app.run("insert.pageBreak", json!({})).unwrap(); + } + } + assert_eq!(app.session.export_layout().pages.len(), 3); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap(); + let jobs = Rc::new(RefCell::new(Vec::new())); + let observed = jobs.clone(); + app.services.print_native = Some(Box::new(move |bytes, settings, collate| { + observed.borrow_mut().push((bytes, settings, collate)); + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("queued".into())).unwrap(); + receiver + })); + for bad in [json!({"pages":"8"}), json!({"parity":"bad"}), json!({"copies":0}), json!({"collate":"yes"})] { + assert!(app.run("ui.printSubmit", bad).is_err()); + } + assert!(jobs.borrow().is_empty(), "invalid settings do not send any job"); + let params = json!({"pages":"1-3","parity":"odd","collate":false}); + let (expected, plan) = app.session.print_pdf(¶ms).unwrap(); + assert_eq!(plan.pages, vec![0, 2]); + app.run("ui.printSubmit", params).unwrap(); + let jobs = jobs.borrow(); + assert_eq!(jobs[0].0, expected, "the PDF contains only the two selected pages, once each"); + assert_eq!(jobs[0].1.copies, 3, "copies go to the spooler, not the PDF"); + assert!(!jobs[0].2, "uncollated copies reach the spooler"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 02c0eb9..2faa1b4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -69,3 +69,27 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. Any other method name is treated as a command id: `{"method": "format.bold"}` works. Positions are `{"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}`. + +### Native printing + +`engine.execute` can call `ui.printSettings` with `{}` to inspect the printer list, selected +printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error. +`{"refresh":true}` starts background discovery; poll the same command until `discovering` is +false. Set `printer` to a discovered name, integer `copies` from 1 to 999, `duplex` to +`printerDefault`, `off`, `longEdge` or `shortEdge`, and boolean `grayscale`. Invalid updates are +rejected without changing earlier settings. `ui.printSubmit` explicitly submits the current +print-ready PDF without opening a dialog; inspect `ui.printSettings` until `busy` becomes false +and check `status`/`detail`. The browser keeps its own print hook. PDF export remains separate. + +`ui.printSubmit` / `ui.print` also accept the upstream PDF page settings (`range`, `pages`, +`parity`, `copies`, `collate`). Native printing renders the selected pages once and passes +copies/collation to the spooler; the browser and `file.printPdf` keep copies in the PDF. +Omitting native `copies` uses the configured `ui.printSettings` value. `collate` defaults to +File › Print's setting; invalid ranges submit no job. + +`engine.commands {"search":"print"}` lists the engine print commands and the app-owned +`ui.printSettings`, `ui.printSubmit`, and legacy `ui.print` alias, with current enablement +and their parameter documentation. Search is case-insensitive across IDs, labels, locations +and parameter descriptions. Listing commands never discovers printers, submits a job or opens +a dialog. Settings inspection is always available; submission becomes enabled when the native +printer is ready, or when the browser host provides its print flow. diff --git a/docs/gaps.md b/docs/gaps.md index c5cd40c..04a985c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. + The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs @@ -18,7 +20,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | -| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | +| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing covers English, Polish and Portuguese only**; no dictionaries, grammar or hyphenation for the other ~47 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish and Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish and Portuguese | 20–40 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | @@ -86,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | | 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet | | 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h | +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts | | 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list | | 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index e91b5f3..bd4dfa1 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** trivial (32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows +> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. + Word is not a hardware-heavy app, but a few devices matter to its users: printers above all, then pens, microphones (Dictate) and speakers (Read Aloud), and displays. @@ -10,7 +12,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours | |---|---|---|---|---|---|---|---| -| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 PDF opens in Preview to print (#286) | 🟡 PDF opens in the default viewer (#286) | 🟡 PDF opens in the default viewer (#286) | ✅ browser print dialog (#209) | 35% | 5–8 | +| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 | | GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) | | Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 | | Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) | @@ -24,6 +26,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| | 2026-10-12 | trivial | 32-bit Windows starts on OpenGL: DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. The upstream preview, page ranges, parity and Print Markup remain available. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. | | 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) | | 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) | | 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) |