diff --git a/README.md b/README.md index 8884f2bd3..8d6a8b414 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle. This is a contact-sheet workflow, not a printer-driver or custom picture-package layout editor. +- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image, + Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells, + stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper + sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi, + and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command + (`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`, + `print.template.save`…), so the CLI and MCP drive it too: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \ + print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf + ``` + + Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet. - **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a side door. - The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts. diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index fe29704b1..137caca7f 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -76,8 +76,11 @@ fn print_commands_chain_into_a_picture_package_pdf() { let export = lines.last().unwrap(); assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); let bytes = std::fs::read(&out).unwrap(); - assert!(bytes.starts_with(b"%PDF-1.4")); - assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 432.00 288.00]"), "6 x 4 in"); + assert!(bytes.starts_with(b"%PDF-")); + let text = String::from_utf8_lossy(&bytes); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in"); // a bad setting fails the run and writes nothing new let before = std::fs::read(&out).unwrap(); let (ok, _, _) = diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs index f058ccf0b..937884a8e 100644 --- a/crates/engine/src/print/output.rs +++ b/crates/engine/src/print/output.rs @@ -88,9 +88,11 @@ mod tests { let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("%PDF-1.4")); + assert!(text.starts_with("%PDF-")); assert!(text.contains("/Count 2")); - assert!(text.contains("/MediaBox [0 0 306.00 396.00]"), "4.25 x 5.5 in is 306 x 396 pt"); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); assert!(text.contains("/Width 425 /Height 550")); assert!(write(&[], &PrintJob::default(), "t").is_err()); let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 261d47d52..eb7ab032c 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -171,6 +171,14 @@ fn auto_layout_fills_pages_without_overlap() { } } +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + fn temp_pdf(name: &str) -> String { std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() } @@ -190,9 +198,10 @@ fn export_pdf_prints_the_current_settings() { let bytes = std::fs::read(&path).unwrap(); assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("%PDF-1.4") && text.contains("/Count 2")); + assert!(text.starts_with("%PDF-") && text.contains("/Count 2")); // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) - assert!(text.contains("/MediaBox [0 0 419.76 595.44]"), "{}", &text[..text.len().min(400)]); + let (w, h) = media_box(&bytes); + assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt"); // the row in the activity stack is gone when the print is done assert!(s.activity.list().is_empty()); fails(&mut s, "print.exportPdf", json!({})); diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 051ad22bd..470e2a377 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -132,6 +132,59 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result Result { + use lightcraft_engine::print::{compose, output}; + if app.export.is_some() { + return Err("an export is already running".into()); + } + let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string(); + app.session.check_write_target(&path)?; + let guard = app.session.original_guard(); + let settings = app.session.print.clone(); + let ids = app.session.targets(params); + let prepared = compose::prepare(&mut app.session, &settings, &ids)?; + let total = prepared.len(); + let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let write = app.services.write_shared.clone(); + let progress = Arc::new(Mutex::new((0, String::new()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (p, c) = (progress.clone(), cancel.clone()); + let (tx, rx) = channel(); + let photos = ids.len(); + let work = move || { + let result = prepared + .run(&mut |done, name| { + if let Ok(mut g) = p.lock() { + *g = (done, name.to_string()); + } + !c.load(Ordering::Relaxed) + }) + .and_then(|pages| { + if c.load(Ordering::Relaxed) { + return Err("Print cancelled".into()); + } + let bytes = output::write(&pages, &settings.job, &title)?; + guard.check(std::path::Path::new(&path))?; + match write { + Some(write) => write(&path, &bytes)?, + None => lightcraft_engine::export::write_file(&path, &bytes)?, + } + Ok(vec![json!({"path": path, "pages": pages.len(), "photos": photos, "bytes": bytes.len(), "print": true})]) + }); + let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed)))); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("print".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone())); + row.progress(0, total as u64); + app.export = Some(ExportTask { total, progress, cancel, rx, guard: row }); + Ok(json!({"background": true, "total": total})) +} + /// The toast's account of [`AfterExport::run`]'s Apple Photos outcome. pub fn apple_photos_note(outcome: &Value) -> String { if let Some(e) = outcome.get("error").and_then(Value::as_str) { @@ -200,6 +253,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sheet) = sheet { m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } if skipped > 0 { m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..96ef1d91e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; +pub mod print_view; pub mod region; pub mod render; pub mod shortcuts; @@ -104,6 +105,8 @@ mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..ff7f1519d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,6 +162,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), @@ -1169,6 +1171,28 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, ¶ms) + } "app.contactSheet" => { if app.export.is_some() { return Some(Err("an export is already running".into())); @@ -1798,7 +1822,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option bool { match id { - "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "dialog.contactSheet" | "app.contactSheet" | "dialog.print" | "app.print" => { + !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none() + } s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { app.session.active().is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..b574acbe3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,7 @@ pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); fn window_id(dialog: &Dialog) -> egui::Id { egui::Id::new(match dialog { Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Print => "lightcraft-print-dialog", Dialog::Settings { .. } => "lightcraft-settings-dialog", _ => "lightcraft-dialog", }) @@ -251,6 +252,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", + Dialog::Print => "Print", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", @@ -270,6 +272,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); let window_id = window_id(&dlg); // Every other dialog fits the window (#781): its options scroll when they don't, and the title // bar and the button row stay outside the scroll area. The room they take is measured on the @@ -289,13 +293,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, _ => 380.0, }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); ui.add( @@ -1167,6 +1172,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); ui.horizontal(|ui| { @@ -1203,6 +1209,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label.as_str() } Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::Print => "Print to PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", @@ -1276,6 +1283,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } | Dialog::Export { .. } | Dialog::ContactSheet { .. } + | Dialog::Print | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1665,6 +1673,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("app.contactSheet", json!(options)), + Dialog::Print => app.run("app.print", json!({})), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs new file mode 100644 index 000000000..7c1ab85fb --- /dev/null +++ b/crates/ui-egui/src/print_view.rs @@ -0,0 +1,823 @@ +//! The Print dialog (File ▸ Print…): settings on the left, the pages drawn to scale on the right. +//! +//! Every change goes through a `print.*` command ([`lightcraft_engine::cmd`]); this file only draws +//! the session's print ([`lightcraft_engine::Session::print`]) and turns pointer input into +//! commands: dragging a cell is `print.cell.move`, its corner handle `print.cell.resize`, with +//! the guides' snapping done by the command. Widgets register under `print:*`, `button:print*` +//! and `drag:print*` ids for the control channel and the headless tests. +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::export::ExportOptions; +use lightcraft_engine::print::compose::{self, place}; +use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; +use lightcraft_engine::print::template::PrintSettings; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::{register, text_button}; + +/// Width of the settings column. +const SETTINGS_W: f32 = 290.0; +/// Handle for resizing the selected cell, screen points. +const HANDLE: f32 = 12.0; +/// Snap distance while dragging, screen points. +const SNAP_PX: f32 = 8.0; +/// Cell sizes offered as buttons, inches (width, height). +const CELL_SIZES: &[(f64, f64)] = &[(2.0, 2.0), (2.5, 3.5), (3.5, 5.0), (4.0, 5.0), (4.0, 6.0), (5.0, 7.0), (8.0, 10.0), (3.0, 3.0)]; + +/// A drag of a cell in progress: the cell's rectangle when it began and how far the pointer went. +#[derive(Clone, Debug)] +pub struct Drag { + cell: u32, + origin: layout::Rect, + resize: bool, + /// Where the pointer was pressed; the distance dragged is measured from it, so the first few + /// points egui spends deciding between a click and a drag are not lost. + press: Pos2, + total: Vec2, +} + +/// What the Print dialog remembers between frames (not saved). +#[derive(Clone, Debug, Default)] +pub struct PrintUi { + /// The page being shown, zero-based. + pub page: usize, + /// The selected cell. + pub selected: Option, + drag: Option, + /// The name typed for Save Template. + pub template_name: String, + /// Size of the next cell added, inches. + pub new_cell: Option<(f64, f64)>, +} + +fn run(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Option { + match app.run(id, params) { + Ok(v) => Some(v), + Err(e) => { + app.toast(ctx, e); + None + } + } +} + +fn patch(app: &mut LightcraftApp, ctx: &egui::Context, params: Value) { + run(app, ctx, "print.set", params); +} + +fn heading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.semibold(12.0)).color(t.text)); + ui.add_space(2.0); +} + +fn row(ui: &mut Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(86.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn decimals(unit: Unit) -> usize { + match unit { + Unit::Inches | Unit::Centimeters => 2, + Unit::Millimeters => 1, + Unit::Points => 0, + } +} + +fn fmt_len(inches: f64, unit: Unit) -> String { + format!("{:.*} {}", decimals(unit), unit.from_inches(inches), unit.label()) +} + +/// A length field in the ruler's unit; the new value in inches when it changed. +fn length(ui: &mut Ui, id: &str, inches: f64, unit: Unit, min_in: f64, max_in: f64) -> Option { + let mut v = unit.from_inches(inches); + let r = ui.add( + egui::DragValue::new(&mut v) + .range(unit.from_inches(min_in)..=unit.from_inches(max_in)) + .speed(unit.per_inch() * 0.01) + .max_decimals(decimals(unit)) + .suffix(format!(" {}", unit.label())), + ); + register(ui.ctx(), format!("drag:{id}"), r.rect); + r.changed().then(|| unit.to_inches(v)) +} + +fn check(ui: &mut Ui, id: &str, label: &str, value: bool) -> Option { + let mut v = value; + let r = ui.checkbox(&mut v, crate::i18n::tr(label)); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed().then_some(v) +} + +fn choice(ui: &mut Ui, id: &str, options: &[(V, &str)], current: V) -> Option { + let mut out = None; + ui.spacing_mut().item_spacing.x = 3.0; + for (i, (v, l)) in options.iter().enumerate() { + if text_button(ui, &format!("{id}-{i}"), l, current == *v).clicked() && current != *v { + out = Some(*v); + } + } + out +} + +fn colour(ui: &mut Ui, id: &str, rgb: [u8; 3]) -> Option<[u8; 3]> { + let mut c = rgb; + let r = ui.color_edit_button_srgb(&mut c); + register(ui.ctx(), format!("color:{id}"), r.rect); + (c != rgb).then_some(c) +} + +fn paper_label(name: &str) -> String { + match name { + "a3" | "a4" | "a5" => name.to_uppercase(), + "letter" => "Letter".into(), + "legal" => "Legal".into(), + "tabloid" => "Tabloid".into(), + "custom" => "Custom".into(), + other => other.replace('x', " × ") + " in", + } +} + +/// The dialog's body: the settings column and the page preview side by side. +pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + let screen = ctx.content_rect(); + let width = (screen.width() - 80.0).clamp(640.0, 1180.0); + let height = (screen.height() - 190.0).clamp(280.0, 780.0); + let mut pu = std::mem::take(&mut app.ui.print); + let st = app.session.print.clone(); + let ids = app.session.targets(&json!({})); + // the page shown stays inside the print + let pages = st.layout.page_count(ids.len()); + pu.page = pu.page.min(pages.saturating_sub(1)); + if pu.selected.is_some_and(|c| st.layout.cell(c).is_none_or(|c| c.page as usize != pu.page)) && pu.drag.is_none() { + pu.selected = pu.selected.filter(|c| st.layout.cell(*c).is_some()); + } + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(SETTINGS_W, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(SETTINGS_W, height)); + egui::ScrollArea::vertical().id_salt("print-settings").max_height(height).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(SETTINGS_W - 14.0); + settings(app, ui, t, &mut pu, &st, ids.len()); + }); + }); + // (a `separator` would take the dialog's whole free height) + let (line, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover()); + ui.painter().line_segment([line.center_top(), line.center_bottom()], Stroke::new(1.0, t.divider)); + let w = (width - SETTINGS_W - 24.0).max(300.0); + ui.allocate_ui_with_layout(vec2(w, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(w, height)); + preview(app, ui, t, &mut pu, &st, &ids, pages); + }); + }); + app.ui.print = pu; +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + heading(ui, t, "Templates"); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) + .hint(crate::i18n::tr("Template name")) + .width(140.0) + .show(ui); + if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { + run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); + } + }); + let names: Vec = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { + run(app, &ctx, "print.template.delete", json!({"name": name})); + } + if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { + run(app, &ctx, "print.template.apply", json!({"name": name})); + pu.page = 0; + pu.selected = None; + } + }); + }); + } + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + heading(ui, t, "Image Settings"); + let im = &st.image; + if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { + patch(app, &ctx, json!({"image": {"zoomToFill": v}})); + } + if let Some(v) = check(ui, "printRotateFit", "Rotate to Fit", im.rotate_to_fit) { + patch(app, &ctx, json!({"image": {"rotateToFit": v}})); + } + if l.style.is_package() { + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printBorder", "Photo Border", im.border_enabled) { + patch(app, &ctx, json!({"image": {"borderEnabled": v}})); + } + if let Some(w) = length(ui, "printBorderWidth", im.border_width, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"image": {"borderWidth": w}})); + } + if let Some(c) = colour(ui, "printBorderColor", im.border_color) { + patch(app, &ctx, json!({"image": {"borderColor": c}})); + } + }); + } + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printStroke", "Inner Stroke", im.stroke_enabled) { + patch(app, &ctx, json!({"image": {"strokeEnabled": v}})); + } + let mut pt = im.stroke_width_pt; + let r = ui.add(egui::DragValue::new(&mut pt).range(0.0..=36.0).speed(0.05).max_decimals(2).suffix(" pt")); + register(ui.ctx(), "drag:printStrokeWidth", r.rect); + if r.changed() { + patch(app, &ctx, json!({"image": {"strokeWidthPt": pt}})); + } + if let Some(c) = colour(ui, "printStrokeColor", im.stroke_color) { + patch(app, &ctx, json!({"image": {"strokeColor": c}})); + } + }); + + heading(ui, t, "Layout"); + match l.style { + Style::SingleImage => { + ui.label(egui::RichText::new(crate::i18n::tr("One photo fills each page inside the margins.")).color(t.text_dim)); + } + Style::ContactSheet => { + let g = &l.grid; + row(ui, t, "Grid", |ui| { + let (mut c, mut r) = (g.columns, g.rows); + let a = ui.add(egui::DragValue::new(&mut c).range(1..=layout::MAX_GRID).suffix(" cols")); + register(ui.ctx(), "drag:printColumns", a.rect); + let b = ui.add(egui::DragValue::new(&mut r).range(1..=layout::MAX_GRID).suffix(" rows")); + register(ui.ctx(), "drag:printRows", b.rect); + if a.changed() || b.changed() { + patch(app, &ctx, json!({"layout": {"grid": {"columns": c, "rows": r}}})); + } + }); + row(ui, t, "Spacing", |ui| { + if let Some(v) = length(ui, "printSpacingX", g.spacing_x, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingX": v}}})); + } + if let Some(v) = length(ui, "printSpacingY", g.spacing_y, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingY": v}}})); + } + }); + if let Some(v) = check(ui, "printKeepSquare", "Keep Square", g.keep_square) { + patch(app, &ctx, json!({"layout": {"grid": {"keepSquare": v}}})); + } + } + Style::PicturePackage | Style::CustomPackage => { + ui.label(egui::RichText::new(crate::i18n::tr("Add a cell")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, (w, h)) in CELL_SIZES.iter().enumerate() { + if text_button(ui, &format!("printAddCell-{i}"), &format!("{} × {}", trim(*w), trim(*h)), false).clicked() { + add_cell(app, &ctx, pu, *w, *h); + } + } + }); + let (mut w, mut h) = pu.new_cell.unwrap_or((3.0, 2.0)); + row(ui, t, "Custom size", |ui| { + if let Some(v) = length(ui, "printNewCellW", w, unit, layout::MIN_CELL_IN, 30.0) { + w = v; + } + if let Some(v) = length(ui, "printNewCellH", h, unit, layout::MIN_CELL_IN, 30.0) { + h = v; + } + }); + pu.new_cell = Some((w, h)); + ui.horizontal(|ui| { + if text_button(ui, "printAddCustom", "Add Cell", false).clicked() { + add_cell(app, &ctx, pu, w, h); + } + if l.style == Style::PicturePackage && text_button(ui, "printAutoLayout", "Auto Layout", false).clicked() { + run(app, &ctx, "print.layout.autoLayout", json!({})); + pu.page = 0; + } + }); + ui.horizontal(|ui| { + if text_button(ui, "printNewPage", "New Page", false).clicked() + && let Some(r) = run(app, &ctx, "print.layout.newPage", json!({})) + { + pu.page = r["page"].as_u64().unwrap_or(0) as usize; + } + if text_button(ui, "printClear", "Clear Layout", false).clicked() { + run(app, &ctx, "print.layout.clear", json!({})); + pu.page = 0; + pu.selected = None; + } + }); + row(ui, t, "Gap", |ui| { + if let Some(v) = length(ui, "printGap", l.spacing, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"spacing": v}})); + } + }); + } + } + + heading(ui, t, "Rulers, Grid & Guides"); + let g = &st.guides; + if let Some(v) = check(ui, "printRulers", "Show Rulers", g.show_rulers) { + patch(app, &ctx, json!({"guides": {"showRulers": v}})); + } + row(ui, t, "Units", |ui| { + let units = [(Unit::Inches, "in"), (Unit::Centimeters, "cm"), (Unit::Millimeters, "mm"), (Unit::Points, "pt")]; + if let Some(u) = choice(ui, "printUnits", &units, g.units) { + patch(app, &ctx, json!({"guides": {"units": u}})); + } + }); + row(ui, t, "Snap", |ui| { + if let Some(s) = choice(ui, "printSnap", &[(Snap::Off, "Off"), (Snap::Cells, "Cells"), (Snap::Grid, "Grid")], g.snap) { + patch(app, &ctx, json!({"guides": {"snap": s}})); + } + }); + row(ui, t, "Grid size", |ui| { + if let Some(v) = length(ui, "printGridSize", g.grid_size, unit, 0.05, 10.0) { + patch(app, &ctx, json!({"guides": {"gridSize": v}})); + } + }); + for (id, label, on, key) in [ + ("printShowMargins", "Page Margins", g.show_margins, "showMargins"), + ("printShowBleed", "Page Bleed", g.show_bleed, "showBleed"), + ("printShowGrid", "Page Grid", g.show_page_grid, "showPageGrid"), + ("printShowCells", "Image Cells", g.show_cells, "showCells"), + ("printShowDims", "Dimensions", g.show_dimensions, "showDimensions"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"guides": {(key): v}})); + } + } + + heading(ui, t, "Page Setup"); + row(ui, t, "Paper", |ui| { + let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(¤t)).show_ui(ui, |ui| { + for name in layout::paper_names().chain(std::iter::once("custom")) { + if ui.selectable_label(current == name, paper_label(name)).clicked() { + patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); + } + } + }); + register(ui.ctx(), "combo:printPaper", c.response.rect); + }); + if l.page.paper.eq_ignore_ascii_case("custom") { + row(ui, t, "Size", |ui| { + if let Some(v) = length(ui, "printCustomW", l.page.custom_width, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customWidth": v}}})); + } + if let Some(v) = length(ui, "printCustomH", l.page.custom_height, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customHeight": v}}})); + } + }); + } + row(ui, t, "Orientation", |ui| { + let o = [(layout::Orientation::Portrait, "Portrait"), (layout::Orientation::Landscape, "Landscape")]; + if let Some(v) = choice(ui, "printOrientation", &o, l.page.orientation) { + patch(app, &ctx, json!({"layout": {"page": {"orientation": v}}})); + } + }); + let m = &l.page.margins; + for (label, id, key, v) in [ + ("Left", "printMarginLeft", "left", m.left), + ("Right", "printMarginRight", "right", m.right), + ("Top", "printMarginTop", "top", m.top), + ("Bottom", "printMarginBottom", "bottom", m.bottom), + ] { + row(ui, t, label, |ui| { + if let Some(v) = length(ui, id, v, unit, 0.0, layout::MAX_MARGIN_IN) { + patch(app, &ctx, json!({"layout": {"page": {"margins": {(key): v}}}})); + } + }); + } + + heading(ui, t, "Page"); + row(ui, t, "Background", |ui| { + if let Some(c) = colour(ui, "printBackground", st.page.background) { + patch(app, &ctx, json!({"page": {"background": c}})); + } + }); + + heading(ui, t, "Print Job"); + row(ui, t, "Resolution", |ui| { + let mut ppi = st.job.ppi; + let r = ui.add(egui::DragValue::new(&mut ppi).range(72.0..=720.0).speed(1.0).max_decimals(0).suffix(" ppi")); + register(ui.ctx(), "drag:printPpi", r.rect); + if r.changed() { + patch(app, &ctx, json!({"job": {"ppi": ppi}})); + } + }); + ui.label(egui::RichText::new(crate::i18n::tr("PDF · sRGB · current edits")).color(t.text_dim)); +} + +fn trim(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn add_cell(app: &mut LightcraftApp, ctx: &egui::Context, pu: &mut PrintUi, w: f64, h: f64) { + if let Some(r) = run(app, ctx, "print.cell.add", json!({"width": w, "height": h})) { + pu.selected = r["id"].as_u64().and_then(|v| u32::try_from(v).ok()); + pu.page = r["cell"]["page"].as_u64().unwrap_or(0) as usize; + } +} + +// ---- preview ------------------------------------------------------------------------------- + +/// Screen geometry of the page on show. +struct Sheet { + page: Rect, + /// Screen points per inch. + scale: f32, +} + +impl Sheet { + fn rect(&self, r: &layout::Rect) -> Rect { + Rect::from_min_size(self.page.min + vec2(r.x as f32, r.y as f32) * self.scale, vec2(r.w as f32, r.h as f32) * self.scale) + } +} + +fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, ids: &[PhotoId], pages: usize) { + let ctx = ui.ctx().clone(); + // room for the page buttons and, for a package, the selected cell's row (which may wrap) + let footer = if st.layout.style.is_package() { 104.0 } else { 44.0 }; + let size = vec2(ui.available_width(), (ui.available_height() - footer).max(120.0)); + let (area, _) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(area); + painter.rect_filled(area, 4.0, t.canvas); + let Ok((pw, ph)) = st.layout.page_size() else { + painter.text(area.center(), Align2::CENTER_CENTER, crate::i18n::tr("This page setup is not valid"), t.font(13.0), t.caution); + return; + }; + let g = &st.guides; + let ruler = if g.show_rulers { 22.0 } else { 0.0 }; + let inner = Rect::from_min_max(area.min + vec2(ruler, ruler), area.max).shrink(14.0); + let scale = ((inner.width() as f64 / pw).min(inner.height() as f64 / ph) as f32).max(1.0); + let sheet = Sheet { page: Rect::from_center_size(inner.center(), vec2(pw as f32, ph as f32) * scale), scale }; + register(&ctx, "print:page", sheet.page); + + painter.rect_filled(sheet.page.translate(vec2(2.0, 3.0)), 0.0, Color32::from_black_alpha(70)); + let bg = st.page.background; + painter.rect_filled(sheet.page, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + let guide = Stroke::new(1.0, Color32::from_rgb(90, 160, 240)); + if g.show_rulers { + rulers(&painter, t, &sheet, area, pw, ph, g.units); + } + let area_in = st.layout.printable().ok(); + if g.show_bleed { + let b = 0.125; + dashed(&painter, sheet.page.expand(b as f32 * scale), Stroke::new(1.0, t.caution)); + } + if g.show_page_grid + && let Some(a) = area_in + { + let faint = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 70)); + let step = g.grid_size.max(0.05); + let mut x = 0.0; + while a.x + x <= a.right() + 1e-9 && x < 200.0 { + let sx = sheet.page.left() + (a.x + x) as f32 * scale; + painter.line_segment([pos2(sx, sheet.page.top()), pos2(sx, sheet.page.bottom())], faint); + x += step; + } + let mut y = 0.0; + while a.y + y <= a.bottom() + 1e-9 && y < 200.0 { + let sy = sheet.page.top() + (a.y + y) as f32 * scale; + painter.line_segment([pos2(sheet.page.left(), sy), pos2(sheet.page.right(), sy)], faint); + y += step; + } + } + if g.show_margins + && let Some(a) = area_in + { + dashed(&painter, sheet.rect(&layout::Rect::new(a.x, a.y, a.w, a.h)), guide); + } + + let package = st.layout.style.is_package(); + if package { + package_cells(app, ui, &painter, t, pu, st, ids, &sheet); + } else { + let plan = compose::plan_cells(&st.layout, ids).ok(); + let cells: Vec<(layout::Rect, Option)> = match plan.as_ref().and_then(|p| p.get(pu.page)) { + Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), + None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), + }; + for (i, (r, photo)) in cells.iter().enumerate() { + let rect = sheet.rect(r); + register(&ctx, format!("print:gridcell:{i}"), rect); + paint_cell(app, &painter, t, st, rect, *photo, &sheet, r); + } + } + + // footer: page navigation and the selected cell + ui.horizontal(|ui| { + if text_button(ui, "printPagePrev", "◀", false).clicked() && pu.page > 0 { + pu.page -= 1; + pu.selected = None; + } + let label = ui.label(egui::RichText::new(format!("{} {} / {}", crate::i18n::tr("Page"), pu.page + 1, pages)).color(t.text)); + register(ui.ctx(), "print:pageLabel", label.rect); + if text_button(ui, "printPageNext", "▶", false).clicked() && pu.page + 1 < pages { + pu.page += 1; + pu.selected = None; + } + ui.label(egui::RichText::new(fmt_len(pw, g.units) + " × " + &fmt_len(ph, g.units)).color(t.text_dim)); + if package { + ui.label(egui::RichText::new(format!("{} {}", st.layout.cells.len(), crate::i18n::tr("cells"))).color(t.text_dim)); + if st.layout.has_overlaps() { + ui.label(egui::RichText::new(crate::i18n::tr("cells overlap")).color(t.caution)); + } + } + }); + if package { + inspector(app, ui, t, pu, st); + } +} + +fn press_origin(ui: &Ui, resp: &egui::Response) -> Pos2 { + ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).unwrap_or_default() +} + +/// Draws and handles every cell of a package page, back to front. +#[allow(clippy::too_many_arguments)] +fn package_cells( + app: &mut LightcraftApp, + ui: &mut Ui, + painter: &egui::Painter, + t: &Tokens, + pu: &mut PrintUi, + st: &PrintSettings, + ids: &[PhotoId], + sheet: &Sheet, +) { + let ctx = ui.ctx().clone(); + let first = ids.first().copied(); + let cells: Vec = st.layout.cells_on_page(pu.page as u32).cloned().collect(); + for c in &cells { + let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first }; + let rect = sheet.rect(&c.rect); + paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect); + let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag()); + register(&ctx, format!("print:cell:{}", c.id), rect); + if resp.clicked() || resp.drag_started() { + pu.selected = Some(c.id); + } + if resp.drag_started() { + pu.drag = Some(Drag { cell: c.id, origin: c.rect, resize: false, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == c.id && !d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (x, y) = (d.origin.x + f64::from(d.total.x / sheet.scale), d.origin.y + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run(app, &ctx, "print.cell.move", json!({"id": c.id, "x": x, "y": y, "snap": true, "threshold": threshold})); + } + if resp.drag_stopped() { + pu.drag = None; + } + let id = c.id; + resp.context_menu(|ui| { + for (how, label) in + [("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")] + { + if ui.button(crate::i18n::tr(label)).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete Cell")).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + ui.close(); + } + }); + } + // the selected cell's outline and resize handle go over everything + let Some(sel) = pu.selected.and_then(|s| cells.iter().find(|c| c.id == s)) else { return }; + let rect = sheet.rect(&sel.rect); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE)); + painter.rect_filled(handle, 2.0, t.accent); + let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag()); + register(&ctx, format!("print:resize:{}", sel.id), handle); + if resp.drag_started() { + pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == sel.id && d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (w, h) = (d.origin.w + f64::from(d.total.x / sheet.scale), d.origin.h + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run( + app, + &ctx, + "print.cell.resize", + json!({"id": sel.id, "width": w.max(layout::MIN_CELL_IN), "height": h.max(layout::MIN_CELL_IN), "snap": true, "threshold": threshold}), + ); + } + if resp.drag_stopped() { + pu.drag = None; + } +} + +/// The selected cell's numbers, its place in the stack and its photo. +fn inspector(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let Some(cell) = pu.selected.and_then(|id| st.layout.cell(id)) else { + ui.label(egui::RichText::new(crate::i18n::tr("Click a cell to move, resize or arrange it")).color(t.text_dim)); + return; + }; + let (id, r, unit) = (cell.id, cell.rect, st.guides.units); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if let Some(v) = length(ui, "printCellX", r.x, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": v, "y": r.y})); + } + if let Some(v) = length(ui, "printCellY", r.y, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": r.x, "y": v})); + } + if let Some(v) = length(ui, "printCellW", r.w, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": v, "height": r.h})); + } + if let Some(v) = length(ui, "printCellH", r.h, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": r.w, "height": v})); + } + for (i, (how, label)) in [("toBack", "Back"), ("backward", "Backward"), ("forward", "Forward"), ("toFront", "Front")].into_iter().enumerate() + { + if text_button(ui, &format!("printStack-{i}"), label, false).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + } + } + if st.layout.style == Style::CustomPackage { + if text_button(ui, "printCellPhoto", "Use Active Photo", false).clicked() { + let active = app.session.active().map(|p| p.0); + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": active})); + } + if cell.photo.is_some() && text_button(ui, "printCellPhotoClear", "Clear Photo", false).clicked() { + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": null})); + } + } + if text_button(ui, "printCellDelete", "Delete", false).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + pu.selected = None; + } + }); +} + +/// One cell on screen: the photo as it will print (fit or fill, turned, border, stroke). +#[allow(clippy::too_many_arguments)] +fn paint_cell( + app: &mut LightcraftApp, + painter: &egui::Painter, + t: &Tokens, + st: &PrintSettings, + cell: Rect, + photo: Option, + sheet: &Sheet, + inches: &layout::Rect, +) { + let im = &st.image; + if st.guides.show_cells { + painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); + } + let border = if st.layout.style.is_package() && im.border_enabled { + (im.border_width as f32 * sheet.scale).min(cell.width().min(cell.height()).max(2.0) / 2.0 - 1.0).max(0.0) + } else { + 0.0 + }; + let avail = cell.shrink(border); + if avail.width() < 1.0 || avail.height() < 1.0 { + return; + } + // the photo's shape: its cropped size, or 3:2 for a placeholder + let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { + let (w, h) = lightcraft_engine::export::output_size(p, &ExportOptions::default()); + (w as f64, h as f64) + }); + let scale = 8.0; // sub-point resolution for the rounding in `place` + let aw = (f64::from(avail.width()) * scale).round() as usize; + let ah = (f64::from(avail.height()) * scale).round() as usize; + let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return }; + let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); + let visible = shown.intersect(avail); + if border > 0.0 { + let frame = visible.expand(border); + let c = im.border_color; + painter.rect_filled(frame.intersect(cell), 0.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); + let tex = photo.and_then(|id| { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + app.renderer.thumb(id).map(|tex| tex.tex.id()) + }); + match tex { + Some(id) => { + let mut mesh = egui::Mesh::with_texture(id); + // an unturned quad maps the texture straight; a turned one (a quarter clockwise) takes + // the left edge to the top + let uv: [Pos2; 4] = if p.rotated { + [pos2(0.0, 1.0), pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)] + } else { + [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)] + }; + let corners = [shown.left_top(), shown.right_top(), shown.right_bottom(), shown.left_bottom()]; + for (pos, uv) in corners.into_iter().zip(uv) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + clip.add(egui::Shape::mesh(mesh)); + } + None => { + clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); + } + } + if im.stroke_enabled && im.stroke_width_pt > 0.0 { + let w = (im.stroke_width_pt as f32 / 72.0 * sheet.scale).max(1.0).min(visible.width().min(visible.height()) / 2.0); + let c = im.stroke_color; + painter.rect_stroke(visible, 0.0, Stroke::new(w, Color32::from_rgb(c[0], c[1], c[2])), StrokeKind::Inside); + } + if st.guides.show_dimensions { + let text = format!("{} × {}", fmt_len(inches.w, st.guides.units), fmt_len(inches.h, st.guides.units)); + let galley = painter.layout_no_wrap(text, t.font(10.0), Color32::WHITE); + let at = pos2(cell.center().x - galley.size().x / 2.0, cell.bottom() - galley.size().y - 3.0); + painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); + painter.galley(at, galley, Color32::WHITE); + } +} + +fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, stroke, 5.0, 4.0)); +} + +/// Rulers along the top and left of the preview, in `unit`, zero at the page's corner. +fn rulers(painter: &egui::Painter, t: &Tokens, sheet: &Sheet, area: Rect, pw: f64, ph: f64, unit: Unit) { + let (major, minor) = match unit { + Unit::Inches => (1.0, 0.25), + Unit::Centimeters => (1.0, 0.5), + Unit::Millimeters => (10.0, 5.0), + Unit::Points => (72.0, 18.0), + }; + let tick = Stroke::new(1.0, t.text_dim); + let ink = t.text_dim; + let top = Rect::from_min_max(area.min, pos2(area.max.x, area.min.y + 20.0)); + let left = Rect::from_min_max(area.min, pos2(area.min.x + 20.0, area.max.y)); + painter.rect_filled(top, 0.0, t.chrome); + painter.rect_filled(left, 0.0, t.chrome); + let ticks = |len_in: f64| (0..).map(move |k| f64::from(k) * minor).take_while(move |v| *v <= unit.from_inches(len_in) + 1e-9).take(2000); + for v in ticks(pw) { + let x = sheet.page.left() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(x, top.bottom()), pos2(x, top.bottom() - if is_major { 10.0 } else { 5.0 })], tick); + if is_major { + painter.text(pos2(x + 2.0, top.top() + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } + for v in ticks(ph) { + let y = sheet.page.top() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(left.right(), y), pos2(left.right() - if is_major { 10.0 } else { 5.0 }, y)], tick); + if is_major { + painter.text(pos2(left.left() + 1.0, y + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lengths_read_in_the_ruler_unit() { + assert_eq!(fmt_len(1.0, Unit::Inches), "1.00 in"); + assert_eq!(fmt_len(1.0, Unit::Millimeters), "25.4 mm"); + assert_eq!(fmt_len(1.0, Unit::Points), "72 pt"); + assert_eq!(trim(4.0), "4"); + assert_eq!(trim(2.5), "2.5"); + assert_eq!(paper_label("a4"), "A4"); + assert_eq!(paper_label("4x6"), "4 × 6 in"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..5e43cbe93 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -735,6 +735,9 @@ pub struct UiState { pub status: String, #[serde(skip)] pub dialog: Option, + /// The Print dialog's page, selection and drag. + #[serde(skip)] + pub print: crate::print_view::PrintUi, } /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything @@ -763,6 +766,9 @@ pub struct NameEdit { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, @@ -1133,6 +1139,7 @@ impl Default for UiState { last_find_missing: None, status: String::new(), dialog: None, + print: Default::default(), } } } diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs new file mode 100644 index 000000000..6dbea7a40 --- /dev/null +++ b/crates/ui-egui/src/tests_print.rs @@ -0,0 +1,300 @@ +//! The Print dialog, driven through the control channel: styles, cells dragged and resized with +//! real pointer input, Auto Layout, pages, templates and the PDF. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); + +fn demo(services: Services) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn drag(h: &mut Headless, id: &str, dx: f32, dy: f32) { + let r = h.request("ui.dragWidget", json!({"id": id, "dx": dx, "dy": dy, "steps": 8}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn cmd(h: &mut Headless, id: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": id, "params": params}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); + r["result"].clone() +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn open_print(h: &mut Headless) { + cmd(h, "dialog.print", json!({})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Print))); + assert!(has(h, "print:page"), "the page is on screen"); +} + +/// Screen points per inch of the page shown. +fn scale(h: &Headless) -> f32 { + rect(h, "print:page").width() / h.app.session.print.layout.page_size().unwrap().0 as f32 +} + +fn cell_rect(h: &Headless, id: u32) -> lightcraft_engine::print::layout::Rect { + h.app.session.print.layout.cell(id).unwrap().rect +} + +#[test] +fn the_print_dialog_opens_from_the_menu_command_and_closes() { + let mut h = demo(Services::default()); + open_print(&mut h); + assert!(has(&h, "button:printStyle-0") && has(&h, "check:printZoomFill") && has(&h, "button:dialogOk")); + assert!(rect(&h, "print:pageLabel").width() > 0.0); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn style_buttons_and_checkboxes_run_print_set() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + assert_eq!(h.app.session.print.layout.style, lightcraft_engine::print::layout::Style::ContactSheet); + assert!(has(&h, "drag:printColumns") && has(&h, "print:gridcell:0")); + click(&mut h, "check:printZoomFill"); + click(&mut h, "check:printStroke"); + assert!(h.app.session.print.image.zoom_to_fill && h.app.session.print.image.stroke_enabled); + click(&mut h, "button:printOrientation-1"); + assert_eq!(h.app.session.print.layout.page.orientation, lightcraft_engine::print::layout::Orientation::Landscape); + let p = rect(&h, "print:page"); + assert!(p.width() > p.height(), "the page is drawn landscape"); + click(&mut h, "button:printUnits-2"); + assert_eq!(h.app.session.print.guides.units, lightcraft_engine::print::layout::Unit::Millimeters); + click(&mut h, "check:printShowGrid"); + assert!(h.app.session.print.guides.show_page_grid); +} + +#[test] +fn cells_are_added_dragged_resized_and_arranged() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + // snapping off, so the pointer maths is exact + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + click(&mut h, "button:printAddCell-4"); + click(&mut h, "button:printAddCell-1"); + let cells: Vec = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(cells.len(), 2); + assert_eq!(h.app.session.print.layout.cells[0].rect.w, 4.0); + assert!(has(&h, &format!("print:cell:{}", cells[0]))); + + // drag the first cell with the pointer: the move is in inches, through print.cell.move + let before = cell_rect(&h, cells[0]); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", cells[0]), 0.0, 100.0); + let after = cell_rect(&h, cells[0]); + assert!((after.y - before.y - f64::from(100.0 / s)).abs() < 0.05, "moved {} in, expected {}", after.y - before.y, 100.0 / s); + assert!((after.x - before.x).abs() < 0.05); + assert_eq!(h.app.ui.print.selected, Some(cells[0])); + + // resize with its corner handle + let r0 = cell_rect(&h, cells[0]); + drag(&mut h, &format!("print:resize:{}", cells[0]), 30.0, 20.0); + let r1 = cell_rect(&h, cells[0]); + assert!((r1.w - r0.w - f64::from(30.0 / s)).abs() < 0.05 && (r1.h - r0.h - f64::from(20.0 / s)).abs() < 0.05, "{r0:?} → {r1:?}"); + assert_eq!((r1.x, r1.y), (r0.x, r0.y), "the top-left corner stays"); + + // arrange: Front moves it last + click(&mut h, "button:printStack-3"); + assert_eq!(h.app.session.print.layout.cells.last().unwrap().id, cells[0]); + click(&mut h, "button:printStack-0"); + assert_eq!(h.app.session.print.layout.cells.first().unwrap().id, cells[0]); + + // the inspector's delete + click(&mut h, "button:printCellDelete"); + assert_eq!(h.app.session.print.layout.cells.len(), 1); + assert_eq!(h.app.ui.print.selected, None); +} + +#[test] +fn dragging_a_cell_snaps_to_its_neighbour_when_snap_is_on() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + cmd(&mut h, "print.set", json!({"layout": {"spacing": 0.0}})); + click(&mut h, "button:printAddCell-0"); + click(&mut h, "button:printAddCell-0"); + let ids: Vec = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(h.app.session.print.guides.snap, lightcraft_engine::print::layout::Snap::Cells); + let a = cell_rect(&h, ids[0]); + // park the second cell away from the first, then drag it back to 0.06 in from the first's right edge + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - a.right()).abs() < 1e-6, "snapped to the neighbour's edge: {} vs {}", r.x, a.right()); + // with snapping off the same drag lands where the pointer put it + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - (a.right() + 0.06)).abs() < 0.05 && r.x > a.right() + 0.01, "unsnapped: {}", r.x - a.right()); +} + +#[test] +fn auto_layout_new_page_and_clear_buttons() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-2"); + for _ in 0..3 { + click(&mut h, "button:printAddCell-4"); + } + for _ in 0..3 { + click(&mut h, "button:printAddCell-5"); + } + let before_pages = h.app.session.print.layout.pages; + // scramble everything onto one spot, then pack + cmd(&mut h, "print.layout.autoLayout", json!({})); + click(&mut h, "button:printAutoLayout"); + assert!(!h.app.session.print.layout.has_overlaps()); + assert!(h.app.session.print.layout.pages >= before_pages.min(2)); + let pages = h.app.session.print.layout.pages; + click(&mut h, "button:printNewPage"); + assert_eq!(h.app.session.print.layout.pages, pages + 1); + assert_eq!(h.app.ui.print.page as u32, pages, "the new page is shown"); + // the next-page button stays on the last page + click(&mut h, "button:printPageNext"); + assert_eq!(h.app.ui.print.page as u32, pages); + click(&mut h, "button:printPagePrev"); + assert_eq!(h.app.ui.print.page as u32, pages - 1); + click(&mut h, "button:printClear"); + assert_eq!((h.app.session.print.layout.cells.len(), h.app.session.print.layout.pages), (0, 1)); + assert_eq!(h.app.ui.print.page, 0); +} + +#[test] +fn templates_save_apply_and_delete_from_the_dialog() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + assert_eq!(h.app.session.print_templates.len(), 1); + cmd(&mut h, "print.reset", json!({})); + assert!(has(&h, "button:printTemplateApply-0")); + click(&mut h, "button:printTemplateApply-0"); + assert_eq!(h.app.session.print.layout.grid.columns, 5); + click(&mut h, "button:printTemplateDelete-0"); + assert!(h.app.session.print_templates.is_empty()); +} + +#[test] +fn print_to_pdf_picks_a_file_and_runs_in_the_background() { + let asked = Arc::new(Mutex::new(Vec::::new())); + let requests = asked.clone(); + let bytes = Arc::new(Mutex::new(Vec::new())); + let written = bytes.clone(); + let services = Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec!["Print.pdf".into()]).unwrap(); + Ok(rx) + })), + write_shared: Some(Arc::new(move |path, data| { + assert_eq!(path, "Print.pdf"); + *written.lock().unwrap() = data.to_vec(); + Ok(()) + })), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + cmd(&mut h, "print.set", json!({"layout": {"page": {"paper": "5x7", "orientation": "landscape"}}, "job": {"ppi": 100.0}})); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the dialog closes once the print has started"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.last_export_result.is_some()), "the print did not finish"); + let req = asked.lock().unwrap(); + assert_eq!(req[0].file_name.as_deref(), Some("Print.pdf")); + let b = bytes.lock().unwrap(); + assert!(b.starts_with(b"%PDF-")); + let (pw, ph) = media_box(&b); + assert!((pw - 504.0).abs() < 1.0 && (ph - 360.0).abs() < 1.0, "7 x 5 in, got {pw} x {ph} pt"); + assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["print"], true); +} + +#[test] +fn printing_shows_a_row_in_the_activity_stack_and_stops() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { write_shared: Some(Arc::new(|_, _| panic!("a stopped print wrote a file"))), ..Default::default() }, + ); + app.session.execute("print.set", &json!({"layout": {"style": "contactSheet"}, "job": {"ppi": 300.0}})).unwrap(); + let ids: Vec<_> = app.session.catalog.photos().take(12).map(|p| p.id.0).collect(); + crate::export_task::start_print(&mut app, &json!({"path": "Stop.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("export", "Printing to PDF")); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + let task = app.export.take().unwrap(); + assert!(task.cancel.load(std::sync::atomic::Ordering::Relaxed)); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the print"); +} + +#[test] +fn the_print_menu_item_needs_a_photo_and_is_listed() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + assert!(crate::menus::ui_enabled(&app, "dialog.print")); + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(!crate::menus::ui_enabled(&empty, "dialog.print")); +} + +#[test] +fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { + for size in [[1400.0, 900.0], [1100.0, 760.0]] { + for style in ["singleImage", "customPackage"] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, size, 1.0); + h.settle(T); + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "customPackage" { + cmd(&mut h, "print.cell.add", json!({"width": 4, "height": 6})); + } + open_print(&mut h); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(&h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{style} at {size:?}: dialog {dialog:?} runs off the window"); + for b in ["button:dialogOk", "button:dialogCancel"] { + assert!(dialog.contains_rect(rect(&h, b)), "{style} at {size:?}: {b} is outside the dialog"); + } + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 72649a5bc..f24cbf5bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: no Print view (layouts, cells, image settings and templates exist as `print.*` commands only), printer colour management, identity plate / watermark / page numbers / crop marks / photo info, Custom Package Auto Layout | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: printer colour management (profile, rendering intent, black point compensation, print adjustment and sharpening), identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing (the Print dialog writes a PDF) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 75dc988ae..4edb4eff5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 34 | 19 | 32 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 54 | 69 | 35 | 193/201 (96%) | 145/157 (92%) | +| Lightroom Classic extras | 38 | 16 | 31 | 8 | — | 24/26 (92%) | +| **Total** | 412 | 51 | 68 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.9% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.4%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 52.3% of 172. ## Top gaps and shortcut notes @@ -747,14 +747,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | Single Image, Contact Sheet, Picture Package and Custom Package are print layout styles reachable from commands, the CLI, control channel and MCP; no Print module UI yet (File → Contact Sheet PDF still the only dialog) | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | 🟡 | `cmd:print.set`, `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke (width in points, colour) through `print.set {image}`; pixel-tested; no panel yet | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi; commands only, no panel yet | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | `cmd:print.set` | guide options (ruler units in/cm/mm/pt, snap off/cells/grid, grid size, show margins/bleed/page grid/cells/dimensions) are stored and used by snapping in `print.cell.move`/`resize`; nothing is drawn until the Print UI lands | -| LRC-PRINT-CELLS | Picture-package cells | P2 | 🟡 | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout` | add/move/resize/delete cells of any size, stacking order, per-cell photos (Custom Package), deterministic Auto Layout packer, snapping to cells or grid; no UI (drag placement) yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | page background colour and margins through `print.set`; optional filename captions in the contact-sheet PDF; no identity plates, watermarks, page numbers or crop marks | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | +| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete` | named templates of layout, image settings, page and job saved with the library (prefs.json); no built-in templates, no UI, no template import/export | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow |