Files
pdfcraft/crates/ui-egui/src/dialogs.rs
T
mtlcl410andClaude Opus 5.5 f41abf921f M6: dotted field names make parent fields (#2696)
A field's own (partial) name can't contain a period (ISO 32000-2
§12.7.4.2), so Prepare a form refused MASTER.first. Acrobat reads such a
name as a full name: the field `first` under a parent field `MASTER`.

- add_field: a dotted name makes or joins the parent fields it names.
- set_props: a dotted name is the full name and moves the field there,
  copying what it inherited onto it first; a plain name still renames
  the field within its parent. Either way the new full name may not be
  taken by, or hold or sit inside, another field.
- A field joining an existing parent doesn't take its Ff or Q.
- Field Properties and the Prepare a form name tags show the full name
  instead of the last part.
- Names are capped at 32 parts; empty parts are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:45:13 +08:00

1883 lines
91 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) {
dialogs(app, ctx);
// Last, so it sits above Document Properties when Security's Unlock… asked for it.
unlock(app, ctx);
}
fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
password(app, ctx);
save_prompt(app, ctx);
link_prompt(app, ctx);
remote_link_prompt(app, ctx);
full_screen_prompt(app, ctx);
crate::updates::dialog(app, ctx);
if app.dialog != Some(Dialog::WordCount) {
app.word_count_draft.cancel();
}
if app.dialog != Some(Dialog::FieldTable) {
app.field_table_draft = None;
}
// A movable window, not this modal: the page under it shows the table as it changes.
if app.dialog == Some(Dialog::FieldTable) {
crate::field_table_ui::show(app, ctx);
return;
}
let Some(dialog) = app.dialog else {
app.props_draft = None;
app.view_draft = None;
return;
};
// Properties ▸ Fonts shows the fonts as they are now, after any content edits.
if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) {
app.session.refresh_fonts(id);
}
// 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 bulk_field_props_now = false;
let mut redact_now: Option<Dialog> = None;
let mut import_words = false;
let mut print_go = false;
let mut revert_now = false;
let mut overwrite_now = false;
let mut overwrite_elsewhere = 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 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 = widgets::show_modal(ctx, "dialog", |ui| {
ui.set_width(match dialog {
Dialog::Properties(_) => 640.0,
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::About => 780.0,
_ => 520.0,
});
match dialog {
Dialog::WordCount => {
close = crate::word_count_ui::body(ui, app);
}
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_wrapped(|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 units = app.units;
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.scope(|ui| {
ui.set_max_width(420.0);
ui.add(
egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(),
);
});
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", crate::bidi::visual(&doc.name).into_owned());
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(widgets::line(v).desired_width(420.0).background_color(t.field).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.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page")));
ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen")));
ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode")));
ui.horizontal(|ui| {
ui.label(tl!("Show:"));
widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name"));
widgets::radio_value(ui, &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.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar")));
ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars")));
ui.add(widgets::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()
},
);
// A restricted document asks for its permissions password first.
if doc.allows_security_change() || doc.can_unlock() {
ui.horizontal(|ui| {
if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() {
link_command = Some("protect.unlock");
}
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", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned());
row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size)));
let p = &i.pages[0];
row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height)));
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));
// Reserve the full wrapped height before the grid starts the next row.
ui.vertical(|ui| {
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| {
widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge"));
widgets::radio_value(ui, &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, widgets::line(&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;
}
widgets::radio_value(ui, &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)));
});
});
widgets::radio_value(ui, &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| {
widgets::radio_value(
ui,
&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| {
widgets::radio_value(ui, &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);
ui.horizontal(|ui| {
ui.label(tl!("Original: replace pages"));
let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count));
ui.label(tl!("to"));
let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count));
if from.changed() || to.changed() {
d.range_edited = true;
}
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted));
});
let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) };
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, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t);
if go {
redact_now = Some(dialog);
}
import_words = import;
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;
if ok && app.redact_prefs.remember {
app.redact_defaults = app.redact_prefs.clone();
}
if cancel && let Some(before) = app.redact_before.take() {
app.redact_prefs = before;
}
if ok {
app.redact_before = None;
}
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.add_space(4.0);
// The name the pages are saved under (#737); separate files add " (page N)".
ui.horizontal(|ui| {
let l = ui.label(tl!("File name"));
ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id);
});
ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting")));
ui.add(widgets::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| {
widgets::radio_value(ui, &mut d.which, 0, tl!("All"));
widgets::radio_value(ui, &mut d.which, 1, tl!("Selection"));
ui.horizontal(|ui| {
widgets::radio_value(ui, &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)]));
widgets::radio_value(ui, &mut d.all, true, tl!("All pages"));
ui.horizontal(|ui| {
widgets::radio_value(ui, &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;
}
// Never reached: `dialogs` shows this movable window above and returns before the modal.
// The arm keeps the match exhaustive.
Dialog::FieldTable => close = true,
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)) => {
app.export_certificate(&c);
}
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.add(widgets::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::OverwriteChanged => {
#[cfg(not(target_arch = "wasm32"))]
let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default();
#[cfg(target_arch = "wasm32")]
let name = String::new();
ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0)));
ui.add_space(8.0);
ui.label(crate::i18n::fmt(
tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."),
&[("name", &name)],
));
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() {
overwrite_now = true;
close = true;
}
if widgets::pill_button(ui, tl!("Save as…"), false).clicked() {
overwrite_elsewhere = 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;
};
if app.print_draft.handling == crate::print_ui::Handling::Label {
app.read_label_crops(id);
}
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 rasters = crate::print_ui::preview_rasters(
&app.print_draft,
&sizes,
&labels,
ui.ctx().pixels_per_point(),
ui.ctx().input(|i| i.max_texture_side) as f32,
);
let view = &mut app.views[i];
view.set_print_grayscale(app.print_draft.grayscale);
view.queue_print_previews(&rasters);
let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| {
view.need_thumbnail(p, true);
view.page_preview(p)
});
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::BulkFieldProps => {
let Some(d) = app.bulk_field_props.as_mut() else {
close = true;
return;
};
let (apply, cancel) = crate::bulk_fields::body(ui, d, &t);
bulk_field_props_now = apply;
close = 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, browse) =
crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo);
if browse {
app.pick_signature_image();
}
if apply {
let d = std::mem::take(&mut app.signature_draft);
let tool = if d.initials {
app.initials = d.saved();
crate::fill_sign::FillTool::Initials
} else {
app.signature = 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::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 => {
// The settings outgrow a short window: scroll them rather than run off the screen.
// The OK row stays below the scroll, in reach however far the settings are scrolled.
let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL);
// A scroll area starts from the room the dialog offers it, and a modal offers more
// each frame for the first few, so with OK outside the scroll the dialog would grow
// over those frames. Asking for the full height as the least it may scroll in
// (which only applies when the settings overflow it) opens the dialog at full size.
// A scrollbar of its own: the floating one lies over the right edge of the theme
// cards and other full-width rows (#3557).
ui.scope(|ui| {
ui.spacing_mut().scroll.floating = false;
egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t));
});
close = crate::js_ui::preferences_buttons(ui);
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);
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"));
ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).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);
use crate::commands::{
ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN,
ZOOM_OUT,
};
// 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| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string()))
})
.collect();
let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s);
let pair = |a, b| format!("{} / {}", key(a), key(b));
// Key names, translated where a catalog has them (not scanned as UI literals).
let named = |k: &str| tl!(k).to_string();
let scroll = crate::i18n::fmt(
tl!("Zoom in / out (also pinch or {key}-scroll)"),
&[("key", crate::commands::command_modifier_label(ui.ctx()))],
);
for (k, v) in [
(pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()),
(key(COPY), tl!("Copy selected text").to_string()),
(named("Double-click"), tl!("Select a word").to_string()),
(crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / close find").to_string()),
(key(ACTUAL_SIZE), tl!("Actual size").to_string()),
(key(PAGE_LEVEL), tl!("Zoom to page level").to_string()),
(key(FIT_WIDTH), tl!("Fit to width").to_string()),
(pair(ZOOM_IN, ZOOM_OUT), scroll),
(pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()),
(format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()),
(format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()),
(named("V"), tl!("Select (V)").to_string()),
(named("H / Space (hold)"), tl!("Hand (H)").to_string()),
(crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()),
(key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()),
] {
rows.push((k, v));
}
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_wrapped(|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", &pdfcraft_engine::build_info::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(16.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);
}
}
// The pages to replace belong to the document the dialog was opened on (#167).
if replace_now
&& let Some(d) = app.replace_draft.take()
&& app.still_pick_target(d.target)
{
let pages = if d.range_edited { (d.from - 1..d.to).collect() } else { d.pages.clone() };
let n = pages.len();
app.apply_edit(Edit::ReplacePages {
pages,
name: d.name.clone(),
bytes: d.bytes.clone(),
src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(),
});
}
// A print or save that fails keeps the dialog open, with the reason in a notice.
if print_go && !app.print_now() {
close = false;
}
if revert_now {
app.revert_active();
}
#[cfg(not(target_arch = "wasm32"))]
{
if overwrite_now {
app.overwrite_changed_file();
}
if overwrite_elsewhere {
app.save_changed_file_elsewhere();
}
}
#[cfg(target_arch = "wasm32")]
let _ = (overwrite_now, overwrite_elsewhere);
// Cancelled or dismissed: the save is dropped.
#[cfg(not(target_arch = "wasm32"))]
if dialog == Dialog::OverwriteChanged && (modal.should_close() || close) {
app.pending_overwrite = None;
}
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();
}
if import_words {
app.pick_files(crate::files::FilePurpose::RedactWords, false);
}
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 bulk_field_props_now {
close = app.apply_bulk_field_props();
}
if field_props_now
&& let Some(d) = app.field_props.take()
&& let Some(props) = d.props()
{
let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
// A rename also renames the field in the formulas that read it.
let before = props.name.as_ref().and_then(|_| form(app));
if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
&& let Some(i) = app.active
{
// Keep the (possibly renamed) field selected.
// A dotted name is the full name; a plain one renames the field within its parent.
let typed = d.name.trim();
let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default();
let name = format!("{prefix}{typed}");
if let (Some(before), Some(after)) = (before, form(app)) {
let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
if n > 0 {
app.notify(crate::i18n::trn(
crate::i18n::current(),
n as u64,
"{n} calculation now uses the new name.",
"{n} calculations now use the new name.",
));
}
}
app.views[i].prepare.selected = Some((name, 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 });
}
}
// Make Properties Default: the look chosen here, even when it was already the comment's.
if d.make_default && d.edited.restylable {
app.make_props_default(d.page, d.index, &d.edited);
}
}
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;
app.bulk_field_props = 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 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 {
if let Some((_, id)) = app.active_ids() {
app.a11y_skipped.retain(|(doc, _)| *doc != id);
}
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 buttons of the "Save changes?" prompt, in the order they sit on screen, left to right.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum PromptButton {
DontSave,
Cancel,
#[default]
Save,
}
impl PromptButton {
const ORDER: [PromptButton; 3] = [PromptButton::DontSave, PromptButton::Cancel, PromptButton::Save];
fn position(self) -> usize {
Self::ORDER.iter().position(|b| *b == self).unwrap_or(2)
}
/// The button `by` places to the right (negative: left). Arrow keys stop at the ends.
fn step(self, by: isize) -> PromptButton {
let to = self.position().saturating_add_signed(by).min(Self::ORDER.len() - 1);
Self::ORDER.get(to).copied().unwrap_or(self)
}
/// The button after (or, with `back`, before) this one, wrapping round: Tab and Shift+Tab.
fn cycle(self, back: bool) -> PromptButton {
let n = Self::ORDER.len();
let to = if back { self.position().checked_sub(1).unwrap_or(n - 1) } else { (self.position() + 1) % n };
Self::ORDER.get(to).copied().unwrap_or(self)
}
/// What pressing the button answers.
fn answer(self) -> Option<bool> {
match self {
PromptButton::Save => Some(true),
PromptButton::DontSave => Some(false),
PromptButton::Cancel => None,
}
}
}
/// The "Save changes?" prompt's keys (issue #8), read before anything else can take them. Save is
/// the button chosen when the prompt opens, so Enter saves; the arrow keys and Tab / Shift+Tab move
/// the choice, and Enter or Space press the chosen button. Escape cancels. Don't save also has
/// ⌘D / Ctrl+D, the macOS convention, and Alt+D / Alt+N, the mnemonics Windows and Linux desktops
/// use for it. Returns the answer once one is given.
pub(crate) fn save_prompt_key(ctx: &egui::Context, chosen: &mut PromptButton) -> Option<Option<bool>> {
use egui::{Key, Modifiers};
ctx.input_mut(|i| {
if i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Space) {
return Some(chosen.answer());
}
if i.consume_key(Modifiers::NONE, Key::Escape) {
return Some(None);
}
if [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)].into_iter().any(|(m, k)| i.consume_key(m, k)) {
return Some(Some(false));
}
// Shift+Tab before Tab: a key press matches a pattern that asks for fewer modifiers.
if i.consume_key(Modifiers::SHIFT, Key::Tab) {
*chosen = chosen.cycle(true);
} else if i.consume_key(Modifiers::NONE, Key::Tab) {
*chosen = chosen.cycle(false);
}
for (key, by) in [(Key::ArrowLeft, -1), (Key::ArrowUp, -1), (Key::ArrowRight, 1), (Key::ArrowDown, 1)] {
if i.consume_key(Modifiers::NONE, key) {
*chosen = chosen.step(by);
}
}
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(id) => app.views.iter().position(|v| v.id == id),
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);
// Wrapping alone (#161) still let a long enough name (a web `?file=` URL has no limit) grow
// the prompt taller than the window (#236); 80 characters wrap to a few lines.
let shown = shorten_middle(&name, 80);
let mut choice: Option<Option<bool>> = None;
let modal = widgets::show_modal(ctx, "save_prompt", |ui| {
ui.set_width(420.0);
ui.horizontal(|ui| {
ui.add(crate::icons::image("save", 22.0, t.accent));
let title = ui.add(
egui::Label::new(
egui::RichText::new(crate::i18n::fmt(tl!("Save changes to “{name}” before closing?"), &[("name", &shown)]))
.font(theme::semibold(16.0)),
)
.wrap(),
);
if shown != name {
title.on_hover_text(&name);
}
});
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| {
let chosen = app.save_prompt_button;
if widgets::pill_button_marked(ui, tl!("Save"), true, chosen == PromptButton::Save).clicked() {
choice = Some(Some(true));
}
if widgets::pill_button_marked(ui, tl!("Cancel"), false, chosen == PromptButton::Cancel).clicked() {
choice = Some(None);
}
ui.add_space(24.0);
if widgets::pill_button_marked(ui, tl!("Don't save"), false, chosen == PromptButton::DontSave).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);
}
}
/// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and
/// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one.
fn shorten_middle(name: &str, max: usize) -> String {
let count = name.chars().count();
if count <= max {
return name.to_string();
}
let tail = max / 4;
let head: String = name.chars().take(max.saturating_sub(tail + 1)).collect();
let end: String = name.chars().skip(count.saturating_sub(tail)).collect();
format!("{head}…{end}")
}
/// "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 = widgets::show_modal(ctx, "link_prompt", |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);
// The host as the browser will connect to it, in punycode when it is international, so a
// lookalike such as `pаypal.com` (Cyrillic `а`) reads as `xn--pypal-4ve.com`. Its Unicode
// form isn't repeated here: a whole-script lookalike would read as the real site.
if let Some(host) = pdfcraft_engine::links::display_host(&pending.url) {
ui.label(egui::RichText::new(&host.ascii).font(theme::semibold(14.0)));
if host.mixed_scripts {
let warning = tl!("This web address mixes letters from different alphabets, a common way to imitate another site's name.");
ui.add(egui::Label::new(egui::RichText::new(warning).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))).wrap());
} else if host.international {
let note = tl!("This web address uses letters from another alphabet, which can look like familiar ones.");
ui.add(egui::Label::new(egui::RichText::new(note).color(t.text)).wrap());
}
}
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);
}
}
/// A document that asks to open in Full Screen mode (`/PageMode /FullScreen`) is asked about
/// first, as Acrobat does: full screen hides every control, so it is the person's choice.
fn full_screen_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(id) = app.pending_full_screen else { return };
// The document was closed before an answer: nothing to ask any more.
if app.session.get(id).is_none() {
app.pending_full_screen = None;
return;
}
let t = Tokens::get(ctx);
let mut choice = None;
let modal = widgets::show_modal(ctx, "full_screen_prompt", |ui| {
ui.set_width(460.0);
ui.horizontal(|ui| {
ui.add(crate::icons::image("maximize", 22.0, t.accent));
ui.label(egui::RichText::new(tl!("Open in Full Screen mode?")).font(theme::semibold(16.0)));
});
ui.add_space(6.0);
ui.add(
egui::Label::new(tl!(
"This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it."
))
.wrap(),
);
ui.add_space(14.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("Not now"), true).clicked() {
choice = Some(false);
}
if widgets::pill_button(ui, tl!("Full Screen"), false).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(allow) = choice {
app.resolve_full_screen_prompt(ctx, allow);
}
}
/// Absolute document-controlled local paths require consent before any filesystem access.
fn remote_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(pending) = app.pending_remote_link.clone() else { return };
let mut choice = None;
let modal = widgets::show_modal(ctx, "remote_link_prompt", |ui| {
ui.set_width(460.0);
ui.label(egui::RichText::new(tl!("Open this local PDF?")).font(theme::semibold(16.0)));
ui.add_space(6.0);
ui.label(tl!("This document is trying to open a local file:"));
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
ui.add(egui::Label::new(egui::RichText::new(pending.path.to_string_lossy()).monospace()).wrap().selectable(true));
});
ui.add_space(8.0);
ui.label(tl!("Only allow this if you trust the source document."));
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, tl!("Allow"), false).clicked() {
choice = Some(true);
}
});
});
if choice.is_none() && modal.should_close() {
choice = Some(false);
}
if let Some(open) = choice {
app.resolve_pending_remote_link(open);
}
}
fn yes(b: bool) -> String {
tl!(if b { "Yes" } else { "No" }).to_string()
}
/// What a password box was answered with.
enum PasswordAnswer {
Submit,
Cancel,
}
/// A password box: a lock, a title, a message, the masked field, an error and two buttons.
fn password_modal(
ctx: &egui::Context,
id: &str,
title: &str,
message: String,
input: &mut String,
error: Option<&str>,
ok: &str,
) -> Option<PasswordAnswer> {
let t = Tokens::get(ctx);
let mut answer = None;
let modal = widgets::show_modal(ctx, id, |ui| {
ui.set_width(400.0);
ui.horizontal(|ui| {
ui.add(crate::icons::image("lock", 22.0, t.accent));
ui.label(egui::RichText::new(title).font(theme::semibold(17.0)));
});
ui.add_space(6.0);
ui.add(egui::Label::new(message).wrap());
ui.add_space(8.0);
let r = ui.add(widgets::line(input).password(true).hint_text(tl!("Password")).desired_width(f32::INFINITY));
// Enter submits. The field keeps focus, 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)) {
answer = Some(PasswordAnswer::Submit);
}
// Only when it lost focus: each request interrupts the input method, and on Linux typed
// keys go through it, so asking on every frame lost everything typed (#2818).
if !r.has_focus() {
r.request_focus();
}
if let Some(e) = 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, ok, true).clicked() {
answer = Some(PasswordAnswer::Submit);
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
answer = Some(PasswordAnswer::Cancel);
}
});
});
answer.or(modal.should_close().then_some(PasswordAnswer::Cancel))
}
/// 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 message = crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)]);
match password_modal(ctx, "password", tl!("Password required"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Open")) {
Some(PasswordAnswer::Submit) => {
let pw = prompt.input.clone();
app.submit_password(Some(pw));
}
Some(PasswordAnswer::Cancel) => app.submit_password(None),
None => {}
}
}
/// Permissions password prompt: lifts the restrictions of an open document (#995).
fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(prompt) = app.unlock_prompt.as_mut() else { return };
let message =
crate::i18n::fmt(tl!("“{name}” has security restrictions. Enter its permissions password to lift them."), &[("name", &prompt.name)]);
match password_modal(ctx, "unlock", tl!("Permissions password"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Unlock")) {
Some(PasswordAnswer::Submit) => {
let pw = prompt.input.clone();
app.submit_unlock(Some(pw));
}
Some(PasswordAnswer::Cancel) => app.submit_unlock(None),
None => {}
}
}
#[cfg(test)]
mod prompt_button_tests {
use super::PromptButton::{self, Cancel, DontSave, Save};
#[test]
fn opens_on_save_and_arrows_stop_at_the_ends() {
assert_eq!(PromptButton::default(), Save);
assert_eq!(Save.step(1), Save);
assert_eq!(Save.step(-1), Cancel);
assert_eq!(Cancel.step(-1), DontSave);
assert_eq!(DontSave.step(-1), DontSave);
assert_eq!(DontSave.step(1000), Save, "a long step clamps");
assert_eq!(Save.step(isize::MIN), DontSave, "and so does a huge negative one");
}
#[test]
fn tab_wraps_both_ways() {
assert_eq!(Save.cycle(false), DontSave);
assert_eq!(DontSave.cycle(true), Save);
assert_eq!(Cancel.cycle(false), Save);
assert_eq!(Cancel.cycle(true), DontSave);
}
#[test]
fn each_button_answers_as_labelled() {
assert_eq!(Save.answer(), Some(true));
assert_eq!(DontSave.answer(), Some(false));
assert_eq!(Cancel.answer(), None);
}
}