pdfcraft/crates/ui-egui/src/dialogs.rs

1494 lines
73 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.

//! Modal dialogs: Document Properties, Keyboard Shortcuts, About (with the Contributors and Models credits).
use egui::{Align, Layout};
use crate::theme::{self, Tokens};
use pdfcraft_engine::Edit;
use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, widgets};
const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
password(app, ctx);
save_prompt(app, ctx);
link_prompt(app, ctx);
crate::updates::dialog(app, ctx);
let Some(dialog) = app.dialog else {
app.props_draft = None;
app.view_draft = None;
return;
};
// Seed the editable Description fields from the document when the dialog opens.
if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
&& app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
&& let Some(doc) = app.session.get(id)
{
app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default())));
app.view_draft = Some((id, doc.initial_view()));
}
let mut apply = false;
let mut split_now: Option<crate::SplitPlan> = None;
let mut split_ready: Option<crate::SplitPlan> = None;
let mut extract_now = false;
let mut rotate_now = false;
let mut link_now: Option<Edit> = None;
let mut recover: Option<bool> = None;
let mut number_now: Option<Edit> = None;
let mut apply_number = false;
let mut link_command: Option<&'static str> = None;
let mut protect_now = false;
let mut boxes_now = false;
let mut marks_now = false;
let mut export_now = false;
let mut props_now = false;
let mut field_props_now = false;
let mut redact_now: Option<Dialog> = None;
let mut print_go = false;
let mut revert_now = false;
let mut summarize_now = false;
let mut optimize_now = false;
let mut duplicate_now: Option<Edit> = None;
let mut replace_now = false;
let mut open_revision: Option<usize> = None;
let mut a11y_now = false;
let mut ocr_now = false;
let mut compare_now = false;
let mut combine_now = false;
let mut images_now = false;
let mut stamp_now = false;
let mut alt_now = false;
let t = Tokens::get(ctx);
let mut close = false;
let mut next = dialog;
let modal = egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
ui.set_width(match dialog {
Dialog::Properties(_) => 640.0,
Dialog::Print => 820.0,
Dialog::FieldProps => 600.0,
Dialog::About => 780.0,
_ => 520.0,
});
// Dialog controls are outlined (radio buttons, check boxes, combo boxes and number fields
// would otherwise blend into the dialog, whose fill matches the theme's field colour).
let w = &mut ui.visuals_mut().widgets;
w.inactive.bg_stroke = egui::Stroke::new(1.0, t.border);
w.inactive.weak_bg_fill = t.field;
// Slider rails and check-box interiors use the plain fill.
w.inactive.bg_fill = t.hover;
w.hovered.bg_stroke = egui::Stroke::new(1.0, t.text_muted);
match dialog {
Dialog::CreateImages => {
let (go, cancel) = crate::create_ui::image_import_body(ui, app);
images_now = go;
close = go || cancel;
return;
}
Dialog::Properties(tab) => {
ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.horizontal(|ui| {
for (tb, label) in [
(PropsTab::Description, tl!("Description")),
(PropsTab::InitialView, tl!("Initial View")),
(PropsTab::Security, tl!("Security")),
(PropsTab::Fonts, tl!("Fonts")),
(PropsTab::Advanced, tl!("Advanced")),
] {
if widgets::mode_tab(ui, label, tab == tb).clicked() {
next = Dialog::Properties(tb);
}
}
});
ui.separator();
let Some((_, id)) = app.active_ids() else { return };
let Some(doc) = app.session.get(id) else { return };
let i = &doc.info;
let row = |ui: &mut egui::Ui, k: &str, v: String| {
// Row labels are UI chrome; values are document data and stay as they are.
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.label(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) });
ui.end_row();
};
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
PropsTab::Description => {
row(ui, "File", doc.name.clone());
match app.props_draft.as_mut() {
Some((_, draft)) if doc.allows_modification() => {
for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.add(
egui::TextEdit::singleline(v)
.desired_width(420.0)
.background_color(t.field)
.margin(egui::Margin::symmetric(6, 4))
.id_salt(("info", *k)),
)
.labelled_by(l.id);
ui.end_row();
}
}
_ => {
row(ui, "Title", i.title.clone().unwrap_or_default());
row(ui, "Author", i.author.clone().unwrap_or_default());
row(ui, "Subject", i.subject.clone().unwrap_or_default());
row(ui, "Keywords", i.keywords.clone().unwrap_or_default());
}
}
row(ui, "Application", i.creator.clone().unwrap_or_default());
row(ui, "PDF producer", i.producer.clone().unwrap_or_default());
}
PropsTab::InitialView => {
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
let editable = doc.allows_modification();
let pages = i.pages.len();
let Some((_, v)) = app.view_draft.as_mut() else { return };
ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5)));
ui.end_row();
ui.label(tl!("Navigation tab"));
ui.add_enabled_ui(editable, |ui| {
egui::ComboBox::from_id_salt("iv-nav")
.selected_text(tl!(format!("{:?}", v.navigation)
.replace("PageOnly", "Page Only")
.replace("Pages", "Pages Panel and Page")
.as_str()))
.show_ui(ui, |ui| {
for (n, l) in [
(N::PageOnly, tl!("Page Only")),
(N::Bookmarks, tl!("Bookmarks Panel and Page")),
(N::Pages, tl!("Pages Panel and Page")),
(N::Attachments, tl!("Attachments Panel and Page")),
(N::Layers, tl!("Layers Panel and Page")),
] {
ui.selectable_value(&mut v.navigation, n, l);
}
});
});
ui.end_row();
ui.label(tl!("Page layout"));
ui.add_enabled_ui(editable, |ui| {
let names = [
(L::Default, tl!("Default")),
(L::SinglePage, tl!("Single Page")),
(L::SinglePageContinuous, tl!("Single Page Continuous")),
(L::TwoUp, tl!("Two-Up (Facing)")),
(L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")),
(L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")),
(L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")),
];
let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n);
egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| {
for (l, n) in names {
ui.selectable_value(&mut v.layout, l, n);
}
});
});
ui.end_row();
ui.label(tl!("Magnification"));
ui.add_enabled_ui(editable, |ui| {
ui.horizontal(|ui| {
let names = [
(M::Default, tl!("Default")),
(M::ActualSize, tl!("Actual Size")),
(M::FitPage, tl!("Fit Page")),
(M::FitWidth, tl!("Fit Width")),
(M::FitHeight, tl!("Fit Height")),
(M::FitVisible, tl!("Fit Visible")),
];
let shown = match v.magnification {
M::Percent(p) => format!("{p:.0}%"),
m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()),
};
egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| {
for (m, n) in names {
ui.selectable_value(&mut v.magnification, m, n);
}
for p in [50.0, 75.0, 125.0, 150.0, 200.0] {
ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%"));
}
});
});
});
ui.end_row();
ui.label(tl!("Open to page"));
ui.add_enabled_ui(editable, |ui| {
let mut p = v.page + 1;
if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() {
v.page = p - 1;
}
ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())]));
});
ui.end_row();
ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5)));
ui.end_row();
ui.label("");
ui.add_enabled_ui(editable, |ui| {
ui.vertical(|ui| {
ui.checkbox(&mut v.fit_window, tl!("Resize window to initial page"));
ui.checkbox(&mut v.center_window, tl!("Center window on screen"));
ui.checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"));
ui.horizontal(|ui| {
ui.label(tl!("Show:"));
ui.radio_value(&mut v.display_title, false, tl!("File Name"));
ui.radio_value(&mut v.display_title, true, tl!("Document Title"));
});
});
});
ui.end_row();
ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5)));
ui.end_row();
ui.label("");
ui.add_enabled_ui(editable, |ui| {
ui.vertical(|ui| {
ui.checkbox(&mut v.hide_menubar, tl!("Hide menu bar"));
ui.checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"));
ui.checkbox(&mut v.hide_window_ui, tl!("Hide window controls"));
});
});
ui.end_row();
}
PropsTab::Security => match doc.security_summary() {
None => {
row(ui, "Security method", tl!("No security").to_string());
row(ui, "Restrictions", tl!("None — everything is allowed").to_string());
if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() {
link_command = Some("protect.password");
}
}
Some(sec) => {
row(ui, "Security method", tl!("Password security").to_string());
row(ui, "Encryption", sec.method.clone());
row(
ui,
"Opened with",
if sec.pending {
tl!("— (protection is applied when you save)").to_string()
} else if sec.owner {
tl!("Owner password (no restrictions apply)").to_string()
} else {
tl!("User password").to_string()
},
);
if doc.allows_security_change() {
ui.horizontal(|ui| {
if ui.button(tl!("Change settings…")).clicked() {
link_command = Some("protect.password");
}
if ui.button(tl!("Remove security")).clicked() {
link_command = Some("protect.remove");
}
});
}
let p = sec.permissions;
let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string();
row(
ui,
"Printing",
if !p.print() {
tl!("Not allowed").to_string()
} else if p.print_high_quality() {
tl!("High resolution").to_string()
} else {
tl!("Low resolution").to_string()
},
);
row(ui, "Changing the document", yes(p.modify()));
row(ui, "Document assembly", yes(p.assemble()));
row(ui, "Content copying", yes(p.copy()));
row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility()));
row(ui, "Commenting", yes(p.annotate()));
row(ui, "Filling of form fields", yes(p.fill_forms()));
}
},
PropsTab::Fonts => {
if i.fonts.is_empty() {
row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string());
}
for f in &i.fonts {
let mut detail = f.kind.clone();
if let Some(e) = &f.encoding {
detail.push_str(&format!(" · {e}"));
}
detail.push_str(if f.subset {
" · Embedded subset"
} else if f.embedded {
" · Embedded"
} else {
" · Not embedded (substituted)"
});
row(ui, &f.name, detail);
}
}
PropsTab::Advanced => {
row(ui, "PDF version", i.pdf_version.clone());
row(ui, "Location", doc.path.clone().unwrap_or_default());
row(ui, "File size", format!("{} ({} bytes)", human_size(i.file_size), i.file_size));
let p = &i.pages[0];
row(ui, "Page size", format!("{:.2} × {:.2} in", p.width / 72.0, p.height / 72.0));
row(ui, "Number of pages", i.pages.len().to_string());
row(ui, "Tagged PDF", yes(i.tagged));
row(ui, "Form fields", i.fields.len().to_string());
row(ui, "Comments", i.annotations.len().to_string());
row(ui, "Layers", i.layers.len().to_string());
row(ui, "Attachments", i.attachments.len().to_string());
row(ui, "JavaScript", yes(i.has_javascript));
// Each incremental update is a revision; earlier ones open as their own document.
let ends = doc.revision_ends();
if ends.len() > 1 {
ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted));
ui.horizontal_wrapped(|ui| {
ui.label(ends.len().to_string());
for n in (1..ends.len()).rev().take(12) {
if ui
.small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())]))
.on_hover_text(tl!("Open the file as it was saved then"))
.clicked()
{
open_revision = Some(n);
}
}
});
ui.end_row();
}
// Reading Options: binding and language.
if let Some((_, v)) = app.view_draft.as_mut() {
let editable = doc.allows_modification();
ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted));
ui.add_enabled_ui(editable, |ui| {
ui.horizontal(|ui| {
ui.radio_value(&mut v.right_to_left, false, tl!("Left Edge"));
ui.radio_value(&mut v.right_to_left, true, tl!("Right Edge"));
});
});
ui.end_row();
let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted));
let mut lang = v.language.clone().unwrap_or_default();
if ui
.add_enabled(editable, egui::TextEdit::singleline(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0))
.labelled_by(l.id)
.changed()
{
v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string());
}
ui.end_row();
}
// What was repaired while reading a damaged file (fidelity: never silent).
let repairs = doc.repair_log();
row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() });
if !repairs.is_empty() {
ui.label("");
egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| {
for r in &repairs {
ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap());
}
});
ui.end_row();
}
}
})
});
}
Dialog::Split => {
ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0)));
ui.add_space(8.0);
let Some((vi, id)) = app.active_ids() else { return };
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let selected: Vec<usize> = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect();
let marks = app.session.bookmark_splits(id);
let draft = &mut app.split_draft;
use crate::SplitMode as M;
if selected.is_empty() && draft.mode == M::Selection {
draft.mode = M::Pages;
}
ui.radio_value(&mut draft.mode, M::Pages, tl!("Number of pages"));
ui.add_enabled_ui(draft.mode == M::Pages, |ui| {
ui.horizontal(|ui| {
ui.add_space(24.0);
ui.label(tl!("Pages per file"));
ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1)));
});
});
ui.radio_value(&mut draft.mode, M::Size, tl!("File size"));
ui.add_enabled_ui(draft.mode == M::Size, |ui| {
ui.horizontal(|ui| {
ui.add_space(24.0);
ui.label(tl!("At most"));
ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB"));
});
});
ui.add_enabled_ui(!marks.is_empty(), |ui| {
ui.radio_value(
&mut draft.mode,
M::Bookmarks,
crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]),
)
});
ui.add_enabled_ui(!selected.is_empty(), |ui| {
ui.radio_value(&mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)"))
});
let plan = match draft.mode {
M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)),
M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)),
M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize),
M::Bookmarks => crate::SplitPlan::Bookmarks,
};
let files = match &plan {
crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()),
crate::SplitPlan::Bookmarks => {
Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len())
}
crate::SplitPlan::Size(_) => None,
};
ui.add_space(8.0);
let text = match files {
Some(f) => {
if f == 1 {
crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())])
} else {
crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())])
}
}
None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]),
};
ui.label(egui::RichText::new(text).color(t.text_muted));
if files.is_none_or(|f| f > 1) {
split_ready = Some(plan);
}
}
Dialog::ReplacePages => {
let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
let Some(d) = app.replace_draft.as_mut() else {
close = true;
return;
};
ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
d.to = d.to.clamp(1, count);
d.from = d.from.clamp(1, d.to);
let n = d.to - d.from + 1;
ui.horizontal(|ui| {
ui.label(tl!("Original: replace pages"));
ui.add(egui::DragValue::new(&mut d.from).range(1..=count));
ui.label(tl!("to"));
ui.add(egui::DragValue::new(&mut d.to).range(1..=count));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted));
});
let max_start = d.src_pages.saturating_sub(n) + 1;
d.src_from = d.src_from.clamp(1, max_start.max(1));
ui.horizontal(|ui| {
ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)]));
ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1)));
ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())]));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted));
});
let fits = n <= d.src_pages;
ui.add_space(6.0);
ui.label(
egui::RichText::new(if fits {
tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.")
} else {
tl!("The replacement file doesn't have that many pages.")
})
.small()
.color(t.text_faint),
);
ui.add_space(10.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() {
replace_now = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
return;
}
Dialog::RedactPages => {
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t);
if ok {
redact_now = Some(dialog);
}
close = ok || cancel;
return;
}
Dialog::RedactSearch => {
let (go, cancel) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t);
if go {
redact_now = Some(dialog);
}
close = cancel;
return;
}
Dialog::RedactProps => {
let mut prefs = app.redact_prefs.clone();
let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t);
app.redact_prefs = prefs;
close = ok || cancel;
return;
}
Dialog::RedactApply => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t);
if ok {
redact_now = Some(dialog);
}
close = ok || cancel;
return;
}
Dialog::LinkProps => {
let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let Some(d) = app.link_draft.as_mut() else {
close = true;
return;
};
let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t);
if ok {
link_now = Some(crate::link_ui::edit_for(d));
}
close = ok || cancel;
return;
}
Dialog::Extract => {
let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len());
ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
let text = if count == 1 {
tl!("1 page selected.").to_string()
} else {
crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &count.to_string())])
};
ui.label(text);
ui.checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"));
ui.checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"));
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Extract"), true).clicked() {
extract_now = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
return;
}
Dialog::RotatePages => {
use pdfcraft_engine::{PageOrientation as O, PageParity as P};
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let d = &mut app.rotate_draft;
ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Direction:"));
egui::ComboBox::from_id_salt("rotate-dir")
.selected_text(match d.degrees {
270 => tl!("Counterclockwise 90 degrees"),
180 => tl!("180 degrees"),
_ => tl!("Clockwise 90 degrees"),
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees"));
ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees"));
ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees"));
});
ui.end_row();
ui.label(tl!("Pages:"));
ui.vertical(|ui| {
ui.radio_value(&mut d.which, 0, tl!("All"));
ui.radio_value(&mut d.which, 1, tl!("Selection"));
ui.horizontal(|ui| {
ui.radio_value(&mut d.which, 2, tl!("From"));
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
});
});
ui.end_row();
ui.label(tl!("Rotate:"));
egui::ComboBox::from_id_salt("rotate-parity")
.selected_text(match d.parity {
P::Both => tl!("Even and Odd Pages"),
P::Even => tl!("Even Pages Only"),
P::Odd => tl!("Odd Pages Only"),
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages"));
ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only"));
ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only"));
});
ui.end_row();
ui.label("");
egui::ComboBox::from_id_salt("rotate-orient")
.selected_text(match d.orientation {
O::Both => tl!("Landscape and Portrait Pages"),
O::Landscape => tl!("Landscape Pages"),
O::Portrait => tl!("Portrait Pages"),
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages"));
ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages"));
ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages"));
});
ui.end_row();
});
d.to = d.to.max(d.from);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
rotate_now = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
return;
}
Dialog::DuplicateField => {
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let Some(d) = app.duplicate_draft.as_mut() else {
close = true;
return;
};
ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)]));
ui.radio_value(&mut d.all, true, tl!("All pages"));
ui.horizontal(|ui| {
ui.radio_value(&mut d.all, false, tl!("From"));
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
});
d.to = d.to.max(d.from);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
let pages: Vec<usize> = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
return;
}
Dialog::Optimize => {
let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t);
optimize_now = ok;
close = ok || cancel;
if std::mem::take(&mut app.optimize_draft.audit) {
app.space_audit = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.audit_space()).unwrap_or_default();
next = Dialog::AuditSpace;
}
return;
}
Dialog::CertificateViewer => {
ui.set_width(700.0);
let trusted = app.session.trusted_certificates().to_vec();
let Some(v) = app.cert_viewer.as_mut() else {
close = true;
return;
};
let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t);
close = done;
match action {
Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c),
Some(crate::sign_ui::CertAction::Export(c)) => {
let pem = crate::sign_ui::certificate_pem(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
}
None => {}
}
return;
}
Dialog::AuditSpace => {
if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) {
next = Dialog::Optimize;
}
return;
}
Dialog::Sign => {
if crate::sign_ui::dialog(ui, app, &t) {
app.sign_draft = None;
close = true;
}
return;
}
Dialog::SummarizeComments => {
ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(tl!("Choose a layout:"));
let _ = ui.radio(true, tl!("Comments only"));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(tl!("Sort comments by:"));
egui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| {
for s in pdfcraft_engine::SummarySort::ALL {
ui.selectable_value(&mut app.summary_sort, s, tl!(s.name()));
}
});
});
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() {
summarize_now = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
return;
}
Dialog::Revert => {
let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default();
ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(crate::i18n::fmt(
tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."),
&[("name", &name)],
));
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Revert"), true).clicked() {
revert_now = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
return;
}
Dialog::Print => {
let Some((i, id)) = app.active_ids() else {
close = true;
return;
};
let Some(doc) = app.session.get(id) else { return };
let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
let thumbs: std::collections::HashMap<usize, egui::TextureId> =
(0..sizes.len()).filter_map(|p| app.views[i].thumb_id(p).map(|t| (p, t))).collect();
let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &|p| thumbs.get(&p).copied());
print_go = go;
close = go || cancel;
return;
}
Dialog::RemoveHidden => {
let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t);
if ok {
redact_now = Some(dialog);
}
close = ok || cancel;
return;
}
Dialog::Sanitize => {
let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t);
if ok {
redact_now = Some(dialog);
}
close = ok || cancel;
return;
}
Dialog::FieldProps => {
let Some(d) = app.field_props.as_mut() else {
close = true;
return;
};
let (apply, cancel) = crate::prepare::body(ui, d, &t);
field_props_now = apply;
close = apply || cancel;
return;
}
Dialog::CommentProps => {
let (apply, cancel) = crate::comment_props::body(ui, app, &t);
props_now = apply;
close = apply || cancel;
return;
}
Dialog::Signature => {
let (apply, cancel) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview);
if apply {
let d = std::mem::take(&mut app.signature_draft);
let tool = if d.initials {
app.initials = Some(d.saved());
crate::fill_sign::FillTool::Initials
} else {
app.signature = Some(d.saved());
crate::fill_sign::FillTool::Signature
};
app.quick_tool = crate::QuickTool::Fill(tool);
app.toast = None;
}
close = apply || cancel;
return;
}
Dialog::AltText => {
let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t);
alt_now = save;
close = save || cancel;
return;
}
Dialog::CreateStamp => {
let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t);
stamp_now = save;
close = save || cancel;
return;
}
Dialog::Combine => {
ui.set_width(620.0);
let (go, cancel) = crate::combine_ui::body(ui, app, &t);
combine_now = go;
if cancel {
app.combine_draft.clear();
}
close = go || cancel;
return;
}
Dialog::PdfA => {
close = crate::standards_ui::body(ui, app, &t);
return;
}
Dialog::ActionWizard => {
ui.set_width(620.0);
close = crate::actions_ui::body(ui, app, &t);
return;
}
Dialog::CompareFiles => {
let (go, cancel) = crate::compare_ui::body(ui, app, &t);
compare_now = go;
close = go || cancel;
return;
}
Dialog::JsConsole => {
ui.set_width(640.0);
close = crate::js_ui::console_body(ui, app, &t);
return;
}
Dialog::DocumentJs => {
ui.set_width(640.0);
close = crate::js_ui::document_js_body(ui, app, &t);
return;
}
Dialog::Preferences => {
close = crate::js_ui::preferences_body(ui, app, &t);
return;
}
Dialog::RecognizeText => {
let (go, cancel) = crate::ocr_ui::body(ui, app, &t);
ocr_now = go;
close = go || cancel;
return;
}
Dialog::AccessibilityOptions => {
let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t);
a11y_now = start;
close = start || cancel;
return;
}
Dialog::Export(kind) => {
let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind);
export_now = apply;
close = apply || cancel;
return;
}
Dialog::Marks(kind) => {
ui.set_width(720.0);
let (apply, cancel) = crate::marks_ui::body(ui, app, &t, kind);
marks_now = apply;
close = apply || cancel;
return;
}
Dialog::PageBoxes => {
let (apply, cancel) = crate::pageboxes::body(ui, app, &t);
boxes_now = apply;
close = apply || cancel;
return;
}
Dialog::Protect => {
let (apply, cancel) = crate::protect::body(ui, app, &t);
protect_now = apply;
close = apply || cancel;
return;
}
Dialog::NumberPages => {
use pdfcraft_engine::LabelStyle as L;
ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
let Some((_, id)) = app.active_ids() else { return };
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
let d = &mut app.number_draft;
d.to = d.to.clamp(1, n);
d.from = d.from.clamp(1, d.to);
// Editable values get a visible border (the dialog and field fills are alike).
let boxed = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui) -> egui::Response| {
egui::Frame::new()
.stroke(egui::Stroke::new(1.0, t.border))
.corner_radius(egui::CornerRadius::same(5))
.inner_margin(egui::Margin::symmetric(4, 1))
.show(ui, |ui| add(ui))
.inner
};
egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Pages"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
ui.add(egui::DragValue::new(&mut d.to).range(1..=n));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted));
});
ui.end_row();
ui.label(tl!("Style"));
let styles = [
(L::Decimal, "1, 2, 3"),
(L::LowerRoman, "i, ii, iii"),
(L::UpperRoman, "I, II, III"),
(L::LowerAlpha, "a, b, c"),
(L::UpperAlpha, "A, B, C"),
(L::None, tl!("None (prefix only)")),
];
let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l);
egui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| {
for (s, l) in styles {
ui.selectable_value(&mut d.style, s, l);
}
});
ui.end_row();
let l = ui.label(tl!("Prefix"));
boxed(ui, &mut |ui| ui.add(egui::TextEdit::singleline(&mut d.prefix).desired_width(160.0).frame(egui::Frame::NONE)))
.labelled_by(l.id);
ui.end_row();
ui.label(tl!("Start"));
ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999));
ui.end_row();
});
d.to = d.to.max(d.from);
let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k));
ui.add_space(8.0);
let preview = if d.from == d.to {
label(d.start)
} else {
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32))
};
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)]))
.color(t.text_muted),
);
number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
}
Dialog::Recovery => {
ui.horizontal(|ui| {
ui.add(crate::icons::image("clock-3", 22.0, t.accent));
ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0)));
});
ui.add_space(6.0);
ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:"));
ui.add_space(8.0);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| {
for m in &app.recoverable {
ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0)));
let mins = now.saturating_sub(m.saved_at) / 60;
let when = match mins {
0 => tl!("just now").to_string(),
1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]),
_ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]),
};
let lock = if m.encrypted { tl!(" · password-protected") } else { "" };
ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted));
ui.end_row();
}
});
}
Dialog::Shortcuts => {
ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0)));
ui.add_space(8.0);
let mac = cfg!(target_os = "macos") || cfg!(target_arch = "wasm32");
// Registered commands first (always in sync with the real bindings), then the
// keys the document view handles itself.
let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS
.iter()
.filter_map(|c| c.shortcut.map(|k| (k.label(mac), tl!(c.label).trim_end_matches('…').to_string())))
.collect();
for (k, v) in [
("⌘G / ⇧⌘G", tl!("Next / previous match")),
("⌘C", tl!("Copy selected text")),
("Double-click", tl!("Select a word")),
("Esc", tl!("Clear selection / close find")),
("⌘1", tl!("Actual size")),
("⌘0", tl!("Zoom to page level")),
("⌘2", tl!("Fit to width")),
("⌘3", tl!("Fit visible")),
("⌘+ / ⌘−", tl!("Zoom in / out (also pinch or ⌘-scroll)")),
("⇧⌘+ / ⇧⌘−", tl!("Rotate view")),
("Home / End", tl!("First / last page")),
("⌘← / ⌘→", tl!("Previous / next page")),
("Delete", tl!("Delete selected pages (Organize)")),
("⌘A", tl!("Select all pages (Organize)")),
] {
rows.push((tl!(k).to_string(), tl!(v).to_string()));
}
egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| {
egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| {
for (k, v) in &rows {
ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5)));
ui.label(v);
ui.end_row();
}
});
});
}
Dialog::About => {
// Tabs About · Contributors · Models (craftrules standards/contributors.md).
let tab_id = egui::Id::new("about_tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() {
let i = i as u8;
if widgets::mode_tab(ui, tl!(label), tab == i).clicked() {
tab = i;
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
match tab {
1 => crate::credits::contributors_ui(ui),
2 => crate::credits::models_ui(ui),
_ => {
ui.horizontal(|ui| {
widgets::artcraft_mark(ui, 40.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0)));
ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", env!("CARGO_PKG_VERSION"))]));
});
});
ui.add_space(6.0);
ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0."));
ui.label(
egui::RichText::new(
"Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)",
)
.color(t.text_muted)
.small(),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted));
widgets::artcraft_logo(ui, 16.0);
});
ui.add_space(6.0);
if let Some(cmd) = widgets::community_links(ui) {
link_command = Some(cmd);
}
}
}
}
}
ui.add_space(12.0);
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if dialog == Dialog::Recovery {
if widgets::pill_button(ui, tl!("Recover"), true).clicked() {
recover = Some(true);
close = true;
}
if widgets::pill_button(ui, tl!("Discard"), false).clicked() {
recover = Some(false);
close = true;
}
} else if dialog == Dialog::NumberPages {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
apply_number = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
} else if dialog == Dialog::Split {
if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() {
split_now = split_ready.clone();
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
} else if changed {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
apply = true;
close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
} else if widgets::pill_button(ui, tl!("Close"), true).clicked() {
close = true;
}
});
});
if let Some(yes) = recover {
let keys: Vec<String> = app.recoverable.iter().map(|m| m.key.clone()).collect();
if yes {
app.recover(&keys);
} else {
app.discard_recovered(&keys);
}
}
if replace_now && let Some(d) = app.replace_draft.take() {
let n = d.to - d.from + 1;
app.apply_edit(Edit::ReplacePages {
pages: (d.from - 1..d.to).collect(),
name: d.name.clone(),
bytes: d.bytes.clone(),
src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(),
});
}
if print_go {
app.print_now();
}
if revert_now {
app.revert_active();
}
if summarize_now {
app.summarize_comments();
}
if optimize_now {
app.optimize_with_draft();
}
if let Some(e) = duplicate_now {
app.duplicate_draft = None;
app.apply_edit(e);
}
if extract_now {
app.extract_selection();
}
if let Some(e) = link_now {
app.apply_edit(e);
app.link_draft = None;
}
if rotate_now {
app.rotate_with_draft();
}
match redact_now {
Some(Dialog::RedactPages) => app.redact_pages(),
Some(Dialog::RedactSearch) => {
let n = app.redact_search();
app.redact_search.found = Some(n);
}
Some(Dialog::RemoveHidden) => {
let which: Vec<pdfcraft_engine::Hidden> = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect();
let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum();
if app.apply_edit(Edit::RemoveHidden { which }) {
if n == 1 {
app.notify_tr("Removed 1 hidden item. Save to remove them from the file.");
} else {
app.notify_fmt("Removed {n} hidden items. Save to remove them from the file.", &[("n", &n.to_string())]);
}
}
}
Some(Dialog::Sanitize) => {
if app.apply_edit(Edit::Sanitize) {
app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file.");
}
}
Some(Dialog::RedactApply) => {
let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks());
if app.apply_edit(Edit::ApplyRedactions { pages: None }) {
if marks == 1 {
app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file.");
} else {
app.notify_fmt("Applied {n} redaction marks. Save to remove the content from the file.", &[("n", &marks.to_string())]);
}
}
}
_ => {}
}
if field_props_now
&& let Some(d) = app.field_props.take()
&& let Some(props) = d.props()
&& app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
&& let Some(i) = app.active
{
// Keep the (possibly renamed) field selected.
let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
app.views[i].prepare.selected = Some((format!("{prefix}{}", d.name.trim()), d.widget));
}
if props_now && let Some(d) = app.comment_props.take() {
let mut edits = crate::comment_props::edits(&d);
match edits.len() {
0 => {}
1 => {
app.apply_edit(edits.remove(0));
}
_ => {
app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits });
}
}
}
if export_now && let Dialog::Export(kind) = dialog {
app.start_export(kind);
}
if marks_now && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) {
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let edit = crate::marks_ui::edit(&app.marks_draft, kind, count);
app.apply_edit(edit);
}
if boxes_now && let Some((i, id)) = app.active_ids() {
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
let edit = app.boxes_draft.edit(app.views[i].current, count);
app.apply_edit(edit);
app.boxes_draft.seeded = None;
}
if protect_now && app.apply_edit(app.protect_draft.edit()) {
app.protect_draft = Default::default();
app.notify_tr("Password protection will be applied when you save");
}
if apply_number && let Some(edit) = number_now {
app.apply_edit(edit);
}
if let Some(by) = split_now {
app.split_active(&by);
}
if apply && let Some(edits) = draft_changes(app) {
app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits });
}
// Protect / Remove security replace the Properties dialog.
let replaces = link_command.is_some_and(|c| c.starts_with("protect.")) || open_revision.is_some();
if modal.should_close() || close || replaces {
app.dialog = None;
app.props_draft = None;
app.view_draft = None;
} else {
app.dialog = Some(next);
}
if let Some(cmd) = link_command {
app.execute(cmd);
}
if let Some(n) = open_revision {
app.open_revision(n);
}
if alt_now {
app.save_alt_text();
}
if stamp_now {
app.save_custom_stamp();
}
if combine_now {
app.combine_staged();
}
if images_now {
app.finish_image_import();
} else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) {
app.image_import = None;
}
if ocr_now {
app.start_ocr();
}
if compare_now {
app.run_compare();
}
if a11y_now {
app.a11y_skipped.clear();
app.run_accessibility_check();
}
}
/// Info edits needed to make the document match the Description draft.
fn draft_changes(app: &PdfCraftApp) -> Option<Vec<Edit>> {
let (id, draft) = app.props_draft.as_ref()?;
let doc = app.session.get(*id)?;
let mut edits: Vec<Edit> = INFO_KEYS
.iter()
.zip(draft.iter())
.filter(|(k, v)| doc.info_value(k).unwrap_or_default().trim() != v.trim())
.map(|(k, v)| Edit::SetInfo { key: (*k).to_string(), value: v.clone() })
.collect();
if let Some((vid, v)) = &app.view_draft
&& vid == id
&& *v != doc.initial_view()
{
edits.push(Edit::SetInitialView(Box::new(v.clone())));
}
Some(edits)
}
/// The "Save changes?" prompt's keys (issue #8), read before anything else can take them: Enter
/// saves (the default button) and Escape cancels; Don't save takes ⌘D / Ctrl+D, the macOS
/// convention, or Alt+D / Alt+N, the mnemonics Windows and Linux desktops use for it.
pub(crate) fn save_prompt_key(ctx: &egui::Context) -> Option<Option<bool>> {
use egui::{Key, Modifiers};
ctx.input_mut(|i| {
if i.consume_key(Modifiers::NONE, Key::Enter) {
Some(Some(true))
} else if i.consume_key(Modifiers::NONE, Key::Escape) {
Some(None)
} else if [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)].into_iter().any(|(m, k)| i.consume_key(m, k)) {
Some(Some(false))
} else {
None
}
})
}
/// "Save changes?" when closing a tab or quitting with unsaved edits.
fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(req) = app.close_request else { return };
let index = match req {
CloseRequest::Tab(i) => Some(i),
CloseRequest::Quit | CloseRequest::All => app.first_dirty(),
};
let Some(name) = index.and_then(|i| app.views.get(i)).and_then(|v| app.session.get(v.id)).map(|d| d.name.clone()) else {
// Nothing left to ask about (tab already gone or no dirty documents).
app.resolve_close(ctx, Some(false));
return;
};
let t = Tokens::get(ctx);
let mut choice: Option<Option<bool>> = None;
let modal = egui::Modal::new(egui::Id::new("save_prompt")).show(ctx, |ui| {
ui.set_width(420.0);
ui.horizontal(|ui| {
ui.add(crate::icons::image("save", 22.0, t.accent));
ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::fmt(tl!("Save changes to “{name}” before closing?"), &[("name", &name)]))
.font(theme::semibold(16.0)),
)
.wrap(),
);
});
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Your changes will be lost if you don't save them.")).color(t.text_muted));
ui.add_space(14.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Save"), true).clicked() {
choice = Some(Some(true));
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
choice = Some(None);
}
ui.add_space(24.0);
if widgets::pill_button(ui, tl!("Don't save"), false).clicked() {
choice = Some(Some(false));
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(None);
}
if let Some(c) = choice {
app.resolve_close(ctx, c);
}
}
/// "Open this web page?" when a document's link, button or script asks to open an address
/// (#90, #91). Shows where the address really goes and the whole address; Cancel is the default,
/// and Escape or clicking outside cancels.
fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(pending) = app.pending_link.clone() else { return };
let t = Tokens::get(ctx);
let email = pending.url.get(..7).is_some_and(|s| s.eq_ignore_ascii_case("mailto:"));
let (title, open) = if email { (tl!("Write this email?"), tl!("Open email app")) } else { (tl!("Open this web page?"), tl!("Open link")) };
let mut choice: Option<bool> = None;
let modal = egui::Modal::new(egui::Id::new("link_prompt")).show(ctx, |ui| {
ui.set_width(460.0);
ui.horizontal(|ui| {
ui.add(crate::icons::image("external-link", 22.0, t.accent));
ui.label(egui::RichText::new(title).font(theme::semibold(16.0)));
});
ui.add_space(6.0);
let template = if email {
tl!("{who} in this document wants to open your email app with this address:")
} else {
tl!("{who} in this document wants to open this address in your browser:")
};
ui.label(crate::i18n::fmt(template, &[("who", tl!(pending.origin.noun()))]));
ui.add_space(6.0);
if let Some(host) = pdfcraft_engine::links::host(&pending.url) {
ui.label(egui::RichText::new(host).font(theme::semibold(14.0)));
}
egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.add(egui::Label::new(egui::RichText::new(&pending.url).monospace().small()).wrap().selectable(true));
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(tl!(
"Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form."
))
.color(t.text_muted)
.small(),
);
ui.add_space(14.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Cancel"), true).clicked() {
choice = Some(false);
}
if widgets::pill_button(ui, open, false).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(open) = choice {
app.resolve_pending_link(open);
}
}
fn yes(b: bool) -> String {
tl!(if b { "Yes" } else { "No" }).to_string()
}
/// Password prompt for encrypted documents (Acrobat: "Password" dialog on open).
fn password(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(prompt) = app.password_prompt.as_mut() else { return };
let t = Tokens::get(ctx);
let mut submit = false;
let mut cancel = false;
let modal = egui::Modal::new(egui::Id::new("password")).show(ctx, |ui| {
ui.set_width(400.0);
ui.horizontal(|ui| {
ui.add(crate::icons::image("lock", 22.0, t.accent));
ui.label(egui::RichText::new(tl!("Password required")).font(theme::semibold(17.0)));
});
ui.add_space(6.0);
ui.add(egui::Label::new(crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)])).wrap());
ui.add_space(8.0);
let r = ui.add(egui::TextEdit::singleline(&mut prompt.input).password(true).hint_text(tl!("Password")).desired_width(f32::INFINITY));
// Enter submits. The field keeps focus (we request it every frame), so check the key
// while it is focused as well as on the frame focus is lost.
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
submit = true;
}
r.request_focus();
if let Some(e) = &prompt.error {
ui.add_space(4.0);
// Known error text is translated; anything else passes through.
ui.label(egui::RichText::new(tl!(e)).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B)));
}
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Open"), true).clicked() {
submit = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
cancel = true;
}
});
});
if submit {
let pw = prompt.input.clone();
app.submit_password(Some(pw));
} else if cancel || modal.should_close() {
app.submit_password(None);
}
}