Files
lightcraft/crates/ui-egui/src/print_view.rs
T

964 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 lightcraft_engine::print_color::{ManagedSpace, PrintColorSettings, PrintIntent, PrintMedia, PrintProfile, PrintSharpen};
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();
// (no `id`/`ids` given: the selection, which never fails to parse)
let ids = app.session.targets(&json!({})).unwrap_or_default();
// 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}}));
}
});
if compose::has_page_options(l.style) {
ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label));
let p = &st.page;
for (id, label, on, key) in [
("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"),
("printPageInfo", "Page Info", p.page_info, "pageInfo"),
("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"),
] {
if let Some(v) = check(ui, id, label, on) {
patch(app, &ctx, json!({"page": {(key): v}}));
}
}
}
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}}));
}
});
print_job_color(app, ui, t, &st.job.color);
}
/// Send one colour setting of the print job (the rest stay as they are).
fn patch_color(app: &mut LightcraftApp, ctx: &egui::Context, key: &str, value: Value) {
patch(app, ctx, json!({"job": {"color": {(key): value}}}));
}
fn to_value<V: serde::Serialize>(v: V) -> Value {
serde_json::to_value(v).unwrap_or(Value::Null)
}
/// The Print Job panel's colour management, print adjustment, print sharpening and depth.
fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &PrintColorSettings) {
let ctx = ui.ctx().clone();
let managed = |space| PrintProfile::Managed { space };
let shown = match &color.profile {
PrintProfile::Managed { space: ManagedSpace::Srgb } => crate::i18n::tr("Managed by Printer (sRGB)").to_string(),
PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => crate::i18n::tr("Managed by Printer (Adobe RGB)").to_string(),
PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
};
let mut pick = false;
row(ui, t, "Profile", |ui| {
let c = egui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| {
for (space, label) in [(ManagedSpace::Srgb, "Managed by Printer (sRGB)"), (ManagedSpace::AdobeRgb, "Managed by Printer (Adobe RGB)")] {
let r = ui.selectable_label(color.profile == managed(space), crate::i18n::tr(label));
register(ui.ctx(), format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect);
if r.clicked() && color.profile != managed(space) {
patch_color(app, &ctx, "profile", to_value(managed(space)));
}
}
if let PrintProfile::Icc { .. } = &color.profile {
let _ = ui.selectable_label(true, shown.as_str());
}
if app.services.pick_display_profile.is_some() {
let r = ui.selectable_label(false, crate::i18n::tr("Other…"));
register(ui.ctx(), "item:printProfile-other", r.rect);
pick |= r.clicked();
}
});
register(ui.ctx(), "combo:printProfile", c.response.rect);
});
if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) {
patch_color(app, &ctx, "profile", json!({"kind": "icc", "path": path}));
}
row(ui, t, "Intent", |ui| {
if let Some(v) = choice(ui, "printIntent", &[(PrintIntent::Perceptual, "Perceptual"), (PrintIntent::Relative, "Relative")], color.intent) {
patch_color(app, &ctx, "intent", to_value(v));
}
});
ui.add_enabled_ui(color.intent == PrintIntent::Relative, |ui| {
if let Some(v) = check(ui, "printBpc", "Black Point Compensation", color.black_point_compensation) {
patch_color(app, &ctx, "blackPointCompensation", json!(v));
}
});
for (key, label, value) in [("brightness", "Brightness", color.brightness), ("contrast", "Contrast", color.contrast)] {
row(ui, t, label, |ui| {
let mut v = value;
let r = ui.add(egui::DragValue::new(&mut v).range(-100.0..=100.0).speed(0.5).max_decimals(0));
register(ui.ctx(), format!("drag:print{label}"), r.rect);
if r.changed() {
patch_color(app, &ctx, key, json!(v));
}
});
}
row(ui, t, "Sharpening", |ui| {
let options = [(PrintSharpen::Off, "Off"), (PrintSharpen::Low, "Low"), (PrintSharpen::Standard, "Standard"), (PrintSharpen::High, "High")];
if let Some(v) = choice(ui, "printSharpen", &options, color.sharpen) {
patch_color(app, &ctx, "sharpen", to_value(v));
}
});
ui.add_enabled_ui(color.sharpen != PrintSharpen::Off, |ui| {
row(ui, t, "Media", |ui| {
if let Some(v) = choice(ui, "printMedia", &[(PrintMedia::Matte, "Matte"), (PrintMedia::Glossy, "Glossy")], color.media) {
patch_color(app, &ctx, "media", to_value(v));
}
});
});
if let Some(v) = check(ui, "print16bit", "16 bits per channel", color.sixteen_bit) {
patch_color(app, &ctx, "sixteenBit", json!(v));
}
let bits = if color.sixteen_bit { "16-bit" } else { "8-bit" };
let summary = format!("PDF · {shown} · {bits} · {}", crate::i18n::tr("current edits"));
ui.label(egui::RichText::new(summary).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(),
};
let mut shown = Vec::new();
for (i, (r, photo)) in cells.iter().enumerate() {
let rect = sheet.rect(r);
register(&ctx, format!("print:gridcell:{i}"), rect);
shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r));
}
page_options(&painter, st, &sheet, &shown, pu.page + 1);
}
// 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,
) -> Option<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 None;
}
// 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 p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?;
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| {
// Use the library's thumbnail when there is one. A thumbnail is kept at one size, so asking
// for another size here every frame made the preview and the grid behind the dialog
// re-render each other's thumbnail for as long as the dialog stayed open.
if app.renderer.thumb(id).is_none() {
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);
}
Some(visible)
}
/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each
/// photo, the page number at the bottom right and the print settings at the bottom left.
/// Packages draw none of them, as the print doesn't ([`compose::has_page_options`]).
fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) {
if !compose::has_page_options(st.layout.style) {
return;
}
let p = &st.page;
let ink = compose::mark_ink(p.background);
let ink = Color32::from_rgb(ink[0], ink[1], ink[2]);
if p.crop_marks {
let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale);
let stroke = Stroke::new(1.0, ink);
for r in photos {
for (corner, dx, dy) in
[(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)]
{
painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke);
painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke);
}
}
}
let Ok(area) = st.layout.printable() else { return };
let area = sheet.rect(&area);
let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0));
let y = (area.bottom() + sheet.page.bottom()) / 2.0;
if p.page_numbers {
painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink);
}
if p.page_info {
painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink);
}
}
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");
}
}