Print: adopt craft-print with native settings and job feedback

This commit is contained in:
skylar taylor-barrick
2026-10-12 03:25:47 +02:00
parent 3926d21aa9
commit 587d879f13
15 changed files with 449 additions and 88 deletions
Generated
+18
View File
@@ -742,6 +742,22 @@ dependencies = [
"libc", "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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.2"
@@ -1483,6 +1499,7 @@ name = "gridcraft"
version = "0.5.0" version = "0.5.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"craft-print",
"eframe", "eframe",
"egui", "egui",
"fmv-macos-events", "fmv-macos-events",
@@ -1616,6 +1633,7 @@ version = "0.5.0"
name = "gridcraft-ui-egui" name = "gridcraft-ui-egui"
version = "0.5.0" version = "0.5.0"
dependencies = [ dependencies = [
"craft-ui",
"egui", "egui",
"egui_kittest", "egui_kittest",
"epaint_default_fonts", "epaint_default_fonts",
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed | | Date | What landed |
|---|---| |---|---|
| 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 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
@@ -177,6 +178,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
+1
View File
@@ -24,6 +24,7 @@ image = { workspace = true }
winresource = "0.1" winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
rfd = { workspace = true } rfd = { workspace = true }
arboard = { workspace = true } arboard = { workspace = true }
+8 -62
View File
@@ -16,6 +16,7 @@ mod graphics;
mod logging; mod logging;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod native_menu; mod native_menu;
mod print;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -39,6 +40,10 @@ impl eframe::App for App {
apple_events::poll(&self.2, &mut self.0, ctx); apple_events::poll(&self.2, &mut self.0, ctx);
} }
self.0.logic(ctx); self.0.logic(ctx);
self.0.poll_printing(ctx);
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
} }
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw); self.0.raw_input_hook(raw);
@@ -53,6 +58,7 @@ impl eframe::App for App {
self.0.ui(ui); self.0.ui(ui);
} }
fn on_exit(&mut self) { fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0); save_prefs(&self.0);
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's // macOS (#117): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown; // macOS adapter subclasses the window's content view and restores its class on teardown;
@@ -180,12 +186,8 @@ fn services() -> Services {
dialog.save_file().and_then(|p| p.to_str().map(str::to_string)) dialog.save_file().and_then(|p| p.to_str().map(str::to_string))
})), })),
open_url: Some(Box::new(|url: &str| open_with_system(url))), open_url: Some(Box::new(|url: &str| open_with_system(url))),
// File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print printers: Some(print::discover()),
// command reaches the printer dialog (GridCraft has no printer driver of its own). print_native: Some(print::submit()),
print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) {
Ok(path) => open_with_system(&path.to_string_lossy()),
Err(e) => log::warn!("print: {e}"),
})),
..Default::default() ..Default::default()
} }
} }
@@ -204,32 +206,6 @@ fn open_with_system(target: &str) {
} }
} }
/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the
/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively
/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable
/// only by the user.
fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
use std::io::Write;
static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
for _ in 0..64 {
let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1);
let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
match options.open(&path) {
Ok(mut file) => {
file.write_all(bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name"))
}
fn icon() -> Option<egui::IconData> { fn icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
@@ -393,33 +369,3 @@ fn main() -> eframe::Result<()> {
} }
result result
} }
#[cfg(test)]
mod print_tests {
#[test]
fn print_pdfs_get_fresh_names_in_the_given_folder() {
let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap();
let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap();
assert_ne!(a, b);
assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf"));
assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b");
assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err());
// A file (or symlink) already at the next name is skipped, never overwritten.
let pid = std::process::id();
let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect();
for p in &taken {
std::fs::write(p, b"keep").unwrap();
}
let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap();
assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep"));
assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
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
})
}
// 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<Vec<std::thread::JoinHandle<()>>> = 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| {
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),
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: "GridCraft 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
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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");
}
}
+28 -1
View File
@@ -175,7 +175,18 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
/// transferred to other file formats*): number formats applied, never column-width `#####`. /// transferred to other file formats*): number formats applied, never column-width `#####`.
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> { fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
gridcraft_xlsx::write_csv(sh, delimiter, |c| crate::display::value_text(wb, sh, c)) gridcraft_xlsx::write_csv(sh, delimiter, |c| {
// General's grid-width formatting rounds large identifiers to scientific notation.
// Delimited files have no column width, so preserve the whole numeric value instead.
if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General")
&& let gridcraft_core::Value::Number(number) = sh.value(c)
&& number.is_finite()
{
number.to_string()
} else {
crate::display::value_text(wb, sh, c)
}
})
} }
fn esc(s: &str) -> String { fn esc(s: &str) -> String {
@@ -568,6 +579,22 @@ mod tests {
assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}");
} }
#[test]
fn delimited_exports_preserve_general_precision_and_explicit_number_formats() {
use gridcraft_core::Value;
use gridcraft_model::{Cell, NumFmt, StyleId};
let mut workbook = Workbook::new();
let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00"));
let sheet = workbook.sheet_mut(0).unwrap();
sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() });
sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() });
sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() });
for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] {
let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap();
assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n"));
}
}
#[test] #[test]
fn template_extension_saves_template_content_type() { fn template_extension_saves_template_content_type() {
let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap();
+1
View File
@@ -9,6 +9,7 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
workspace = true workspace = true
[dependencies] [dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
gridcraft-engine = { workspace = true } gridcraft-engine = { workspace = true }
gridcraft-chart = { workspace = true } gridcraft-chart = { workspace = true }
egui = { workspace = true } egui = { workspace = true }
+9 -3
View File
@@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.set_width(250.0); ui.set_width(250.0);
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) { if let Some(discover) = &app.services.printers {
app.run_or_alert("file.print", params.clone()); crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned());
out.close = true; }
let can_print = app.services.print_native.is_none() || app.printing.ready();
if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked()
&& app.commit_edit(0, 0, false, false)
{
app.print_now(params.clone());
out.close = app.services.print_native.is_none();
} }
ui.add_space(16.0); ui.add_space(16.0);
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
+2
View File
@@ -2,6 +2,8 @@
pub(super) fn translate(text: &str) -> Option<&'static str> { pub(super) fn translate(text: &str) -> Option<&'static str> {
Some(match text { Some(match text {
"Different First Page" => "Première page différente",
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
"Paste" => "Coller", "Paste" => "Coller",
"Paste Values" => "Coller les valeurs", "Paste Values" => "Coller les valeurs",
"Paste Formulas" => "Coller les formules", "Paste Formulas" => "Coller les formules",
+61 -17
View File
@@ -26,6 +26,7 @@ pub mod outline;
pub mod panes; pub mod panes;
pub mod paper_view; pub mod paper_view;
pub mod pivot_pane; pub mod pivot_pane;
pub mod printing;
pub mod ribbon; pub mod ribbon;
pub mod selection_glide; pub mod selection_glide;
pub mod tabs; pub mod tabs;
@@ -159,6 +160,10 @@ pub struct Notice {
/// Host services the platform shell provides (file pickers, opening URLs). /// Host services the platform shell provides (file pickers, opening URLs).
#[derive(Default)] #[derive(Default)]
pub struct Services { pub struct Services {
/// Desktop: discover configured printers on a worker.
pub printers: Option<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>, pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>, pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Native picture picker (PNG/JPEG), separate from workbook opening. /// Native picture picker (PNG/JPEG), separate from workbook opening.
@@ -210,6 +215,8 @@ pub struct SheetApp {
/// Dismissible info bar under the ribbon (open-time notices). /// Dismissible info bar under the ribbon (open-time notices).
pub notice: Option<Notice>, pub notice: Option<Notice>,
pub services: Services, pub services: Services,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
/// Synthetic input from agents, one batch per frame (drags need several frames). /// Synthetic input from agents, one batch per frame (drags need several frames).
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>, pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>, pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
@@ -256,6 +263,8 @@ impl SheetApp {
toast: None, toast: None,
notice: None, notice: None,
services, services,
printing: Default::default(),
print_feedback_seen: None,
synthetic: Default::default(), synthetic: Default::default(),
control_rx: None, control_rx: None,
grid: grid::GridState { grid: grid::GridState {
@@ -404,8 +413,12 @@ impl SheetApp {
} }
pub fn run_or_alert(&mut self, id: &str, params: Json) { pub fn run_or_alert(&mut self, id: &str, params: Json) {
// Printing from the UI also hands the PDF to the host's print flow (web). // A menu/shortcut opens the settings first; the Print page's button submits explicitly.
if id == "file.print" { if id == "file.print" {
if self.services.print_native.is_some() {
self.open_backstage(backstage::Page::Print);
return;
}
return self.print_now(params); return self.print_now(params);
} }
if let Err(e) = self.run(id, params) { if let Err(e) = self.run(id, params) {
@@ -422,29 +435,39 @@ impl SheetApp {
} }
} }
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the
/// build) hands the bytes straight to the browser's print flow; errors show the usual /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert` /// on the Print page. The engine command still returns PDF bytes without submitting.
/// (which still produces the PDF — just without a UI to act on the result yet).
pub fn print_now(&mut self, params: Json) { pub fn print_now(&mut self, params: Json) {
match self.run("file.print", params) { if let Err(error) = self.submit_print(params) {
Ok(r) => { self.alert(error);
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
return;
};
if let Some(print) = &self.services.print {
print(&bytes);
}
}
Err(e) => self.alert(e),
} }
} }
fn submit_print(&mut self, params: Json) -> Result<Json, String> {
if self.services.print_native.is_some() && !self.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
let pdf = self.run("file.print", params)?;
let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?;
let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?;
let len = bytes.len();
if let Some(submit) = &self.services.print_native {
self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?;
self.print_feedback_seen = None;
} else if let Some(print) = &self.services.print {
print(&bytes);
} else {
return Err("Printing is unavailable here; export a PDF instead.".into());
}
Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()}))
}
/// UI-only commands (dialogs, view toggles that aren't saved in the file). /// UI-only commands (dialogs, view toggles that aren't saved in the file).
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> { fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
let r = match id { let r = match id {
"ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
"ui.printSubmit" => self.submit_print(p.clone()),
"ui.ribbonTab" => { "ui.ribbonTab" => {
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
Ok(Json::Null) Ok(Json::Null)
@@ -834,6 +857,26 @@ impl SheetApp {
self.grid.ensure_visible = true; self.grid.ensure_visible = true;
} }
/// 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| self.ui.language.tr(label).to_owned());
if matches!(message, printing::Feedback::Submitted(_)) {
self.toast = Some((text, now_ms()));
} else {
self.alert(text);
}
}
/// Per-frame logic (control channel, screenshots). Call before `ui`. /// Per-frame logic (control channel, screenshots). Call before `ui`.
pub fn logic(&mut self, ctx: &egui::Context) { pub fn logic(&mut self, ctx: &egui::Context) {
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and // A keytip control request lives one frame: the ribbon consumes it during `ui`, and
@@ -939,6 +982,7 @@ impl SheetApp {
/// Lays out the whole window. /// Lays out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) { pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
self.poll_printing(&ctx);
if !self.fonts_ready || self.fonts_rebind { if !self.fonts_ready || self.fonts_rebind {
ctx.request_repaint(); ctx.request_repaint();
return; return;
+98
View File
@@ -0,0 +1,98 @@
//! 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, Submit};
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("gridcraft_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<serde_json::Value, String> {
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::<Vec<Value>>(),
"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."),
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Native print onboarding preserves engine PDF generation and submits the selected settings.
use gridcraft_engine::Session;
use gridcraft_ui_egui::printing::{Duplex, Feedback, Printer};
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
use std::{cell::RefCell, rc::Rc};
#[test]
fn native_print_uses_selected_settings_and_reports_spool_errors() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.run("cell.set", json!({"cell":"A1", "input":"A sheet 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| {
observed.borrow_mut().push((bytes, settings));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Err("printer is offline".into())).unwrap();
receiver
}));
app.run_or_alert("file.print", json!({}));
assert!(app.backstage.is_some(), "a shortcut opens settings without submitting");
let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap();
assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting");
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.printSubmit", json!({"sheets":"active"})).unwrap();
assert!(app.printing.busy());
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);
app.poll_printing(&egui::Context::default());
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
assert!(app.message.as_ref().unwrap().1.contains("printer is offline"));
}
#[test]
fn print_settings_are_visible_in_a_narrow_window() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Services::default());
app.run("cell.set", json!({"cell":"A1", "input":"A sheet 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.run("ui.backstage", json!({"page":"print"})).unwrap();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state(
|ui, app: &mut SheetApp| {
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 session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, 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 session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, 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.toast = 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.toast.as_ref().unwrap().0.contains("same receipt"));
app.toast = None;
app.poll_printing(&egui::Context::default());
assert!(app.toast.is_none());
}
}
+12
View File
@@ -65,3 +65,15 @@ the password, and never replaces the encrypted original.
For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`;
see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). see [`mcp.md`](mcp.md) and [`cli.md`](cli.md).
### 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.
GridCraft accepts the existing `file.print` sheet/pagination params on `ui.printSubmit`; the engine `file.print` command returns the PDF without spooling.
+3 -2
View File
@@ -1,6 +1,6 @@
# Gaps # Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§6: iterative calculation landed) · **Target:** Microsoft Excel (Microsoft 365) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -111,7 +111,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
### 14. Page Layout view and print polish (U, F) ### 14. Page Layout view and print polish (U, F)
- Page Layout view landed (#329): paper view with rulers and in-place header/footer editing. - Page Layout view landed (#329): paper view with rulers and in-place header/footer editing.
Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging
margins on the ruler; web print exports only (#141, PR #143). **Estimate:** 5–10 h. margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h.
### 15. Remaining functions and an oracle suite (F) ### 15. Remaining functions and an oracle suite (F)
- 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h.
@@ -160,6 +160,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) |
| 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate |
| 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn | | 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity # Hardware parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Windows DirectX 12 shaders with FXC, #339) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
The hardware Excel uses, per platform, against GridCraft. Part of The hardware Excel uses, per platform, against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated).
@@ -14,12 +14,13 @@ The hardware Excel uses, per platform, against GridCraft. Part of
| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h |
| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — |
| Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) |
| Printers | System print with driver options | Desktop prints through the system dialog via PDF; web exports only | Counted under print | | Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print |
## Revision history ## Revision history
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) |
| 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) |
| 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window | | 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window |