LRC-PRINT-GUIDES: the Print dialog (File → Print…) with page preview, rulers, guides and cell editing

File → Print… (⌘P) opens the Print module as a dialog: settings on the left,
the page drawn to scale on the right with the photos as they will print (fit
or Zoom to Fill, turned by Rotate to Fit, Photo Border, Inner Stroke, page
background).

Settings column: templates (save, apply, delete), layout style, image
settings, grid / package cell controls (standard and custom cell sizes, Auto
Layout, New Page, Clear Layout), rulers and guides (units in/cm/mm/pt, snap
off/cells/grid, grid size, margins, bleed, page grid, image cells,
dimensions), page setup (paper, orientation, margins), page background and
the print job.

Preview: rulers in the chosen unit, dashed margins and bleed, page grid,
dimension labels, page navigation. In the package styles cells are selected,
dragged (print.cell.move) and resized from a corner handle
(print.cell.resize) with the guides' snapping, arranged from the inspector
row or a context menu, and given a photo (Custom Package). Every change is a
print.* command; the drag is measured from where the pointer went down so
the click-versus-drag slop is not lost.

Print to PDF… picks a file and renders on a background thread with an
activity-stack row, cancellation and original protection, like the contact
sheet, which keeps working unchanged.

Headless tests drive the dialog with real pointer input through the control
channel (print:cell:N, print:resize:N, button:print*): styles and checkboxes,
add / drag / resize / arrange / delete, snapping to a neighbour, Auto Layout
and pages, templates, the PDF, the activity row, and that the dialog and its
buttons fit the window.
This commit is contained in:
Atmik Shetty
2026-10-12 15:15:33 +05:30
parent 3b32f82e9e
commit 32b5aefc5b
13 changed files with 1274 additions and 20 deletions
+14
View File
@@ -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.
+5 -2
View File
@@ -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<f64> = 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, _, _) =
+4 -2
View File
@@ -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<f64> = 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])];
+11 -2
View File
@@ -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<f64> = 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!({}));
+56
View File
@@ -132,6 +132,59 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
Ok(json!({"background": true, "total": total}))
}
/// Print the session's current print (`print.get`) to a PDF in the background, with the same
/// activity row and cancellation as the other exports.
pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> {
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);
}
+3
View File
@@ -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;
+27 -1
View File
@@ -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<Re
app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() });
Ok(Value::Null)
}
"dialog.print" => {
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, &params)
}
"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<Re
pub fn ui_enabled(app: &LightcraftApp, id: &str) -> 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()
+10 -1
View File
@@ -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<serde_jso
}),
),
Dialog::ContactSheet { options } => 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, .. } => {
+823
View File
@@ -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<u32>,
drag: Option<Drag>,
/// 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<Value> {
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<R>(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<f64> {
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<bool> {
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<V: PartialEq + Copy>(ui: &mut Ui, id: &str, options: &[(V, &str)], current: V) -> Option<V> {
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<String> = 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(&current)).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<PhotoId>)> = 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<layout::Cell> = 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<PhotoId>,
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");
}
}
+7
View File
@@ -735,6 +735,9 @@ pub struct UiState {
pub status: String,
#[serde(skip)]
pub dialog: Option<Dialog>,
/// 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(),
}
}
}
+300
View File
@@ -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<f64> = 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<u32> = 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<u32> = 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::<crate::pick::PickRequest>::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");
}
}
}
}
+2 -1
View File
@@ -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 |
+12 -11
View File
@@ -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.
<!-- /parity:summary -->
## 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 |