mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 02:36:48 -06:00
Print: adopt craft-print with native settings and job feedback
This commit is contained in:
Generated
+18
@@ -742,6 +742,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"
|
||||
@@ -1483,6 +1499,7 @@ name = "gridcraft"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
@@ -1616,6 +1633,7 @@ version = "0.5.0"
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"epaint_default_fonts",
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**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
|
||||
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 |
|
||||
|---|---|
|
||||
| 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 | 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) |
|
||||
@@ -177,6 +178,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| 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 | Forecast Sheet (#322); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||
|
||||
@@ -24,6 +24,7 @@ image = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod print;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -39,6 +40,10 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.2, &mut self.0, 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) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -53,6 +58,7 @@ impl eframe::App for App {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(&self.0);
|
||||
// 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;
|
||||
@@ -180,12 +186,8 @@ fn services() -> Services {
|
||||
dialog.save_file().and_then(|p| p.to_str().map(str::to_string))
|
||||
})),
|
||||
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
|
||||
// command reaches the printer dialog (GridCraft has no printer driver of its own).
|
||||
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}"),
|
||||
})),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
..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> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png");
|
||||
@@ -393,33 +369,3 @@ fn main() -> eframe::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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
/// transferred to other file formats*): number formats applied, never column-width `#####`.
|
||||
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 {
|
||||
@@ -568,6 +579,22 @@ mod tests {
|
||||
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]
|
||||
fn template_extension_saves_template_content_type() {
|
||||
let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap();
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
|
||||
@@ -570,9 +570,15 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(250.0);
|
||||
if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) {
|
||||
app.run_or_alert("file.print", params.clone());
|
||||
out.close = true;
|
||||
if let Some(discover) = &app.services.printers {
|
||||
crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned());
|
||||
}
|
||||
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);
|
||||
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Different First Page" => "Première page différente",
|
||||
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
|
||||
"Paste" => "Coller",
|
||||
"Paste Values" => "Coller les valeurs",
|
||||
"Paste Formulas" => "Coller les formules",
|
||||
|
||||
+61
-17
@@ -26,6 +26,7 @@ pub mod outline;
|
||||
pub mod panes;
|
||||
pub mod paper_view;
|
||||
pub mod pivot_pane;
|
||||
pub mod printing;
|
||||
pub mod ribbon;
|
||||
pub mod selection_glide;
|
||||
pub mod tabs;
|
||||
@@ -159,6 +160,10 @@ pub struct Notice {
|
||||
/// Host services the platform shell provides (file pickers, opening URLs).
|
||||
#[derive(Default)]
|
||||
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_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// 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).
|
||||
pub notice: Option<Notice>,
|
||||
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).
|
||||
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
|
||||
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
|
||||
@@ -256,6 +263,8 @@ impl SheetApp {
|
||||
toast: None,
|
||||
notice: None,
|
||||
services,
|
||||
printing: Default::default(),
|
||||
print_feedback_seen: None,
|
||||
synthetic: Default::default(),
|
||||
control_rx: None,
|
||||
grid: grid::GridState {
|
||||
@@ -404,8 +413,12 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
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 self.services.print_native.is_some() {
|
||||
self.open_backstage(backstage::Page::Print);
|
||||
return;
|
||||
}
|
||||
return self.print_now(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
|
||||
/// build) hands the bytes straight to the browser's print flow; errors show the usual
|
||||
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
|
||||
/// (which still produces the PDF — just without a UI to act on the result yet).
|
||||
/// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the
|
||||
/// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible
|
||||
/// on the Print page. The engine command still returns PDF bytes without submitting.
|
||||
pub fn print_now(&mut self, params: Json) {
|
||||
match self.run("file.print", params) {
|
||||
Ok(r) => {
|
||||
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),
|
||||
if let Err(error) = self.submit_print(params) {
|
||||
self.alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
|
||||
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" => {
|
||||
self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string();
|
||||
Ok(Json::Null)
|
||||
@@ -834,6 +857,26 @@ impl SheetApp {
|
||||
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`.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
// 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.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.poll_printing(&ctx);
|
||||
if !self.fonts_ready || self.fonts_rebind {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
|
||||
@@ -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."),
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
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
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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)
|
||||
- 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
|
||||
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)
|
||||
- 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 |
|
||||
|---|---|---|
|
||||
| 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 | §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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
[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 |
|
||||
| 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)) |
|
||||
| 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
|
||||
|
||||
| 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: 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 |
|
||||
|
||||
Reference in New Issue
Block a user