mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
refactor(ui-egui): split app.rs into app/
New modules: app/files, links, input, keys, clipboard, window, prefs, commands, control, frame (frame holds panel_migration_tests).
This commit is contained in:
+15
-2288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
use super::*;
|
||||
|
||||
/// Why nothing can be copied out of the document: its security doesn't allow it (Table 22,
|
||||
/// bit 5; #568).
|
||||
pub(crate) fn copy_refused() -> &'static str {
|
||||
tl!("The document's security settings don't allow copying text and images")
|
||||
}
|
||||
|
||||
pub(crate) fn print_refused() -> &'static str {
|
||||
tl!("The document's security settings don't allow printing")
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Whether text and images may be copied out of document `id`; says why not when they may
|
||||
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
|
||||
/// anything, §7.6.4.2).
|
||||
pub(crate) fn may_copy(&mut self, id: DocId) -> bool {
|
||||
if self.session.get(id).is_none_or(pdfcraft_engine::Document::allows_copying) {
|
||||
return true;
|
||||
}
|
||||
self.notify(copy_refused().to_string());
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
|
||||
pub(super) fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
|
||||
// ⌘C arrives as a Copy event on most platforms.
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
|
||||
if !copy || ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
self.copy_selected_text(i, ctx);
|
||||
}
|
||||
|
||||
/// Shared permission gate for copying selected page text from menus and keyboard events.
|
||||
/// After Select All of the document (#2639), pages whose text wasn't read for display are read
|
||||
/// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done.
|
||||
pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool {
|
||||
// A newer copy wins: a whole-document copy still reading must not replace it later.
|
||||
self.document_copy = None;
|
||||
let Some(v) = self.views.get(i) else { return false };
|
||||
let id = v.id;
|
||||
if v.all_text && !v.pages_without_text().is_empty() {
|
||||
// Checked before any page is read: a document that refuses copying reads nothing.
|
||||
if !self.may_copy(id) {
|
||||
return false;
|
||||
}
|
||||
let Some(job) = self.views.get(i).and_then(|v| Some(DocumentCopyJob::of(v, self.session.get(id)?))) else { return false };
|
||||
self.document_copy = Some(DocumentCopy::start(id, job, ctx));
|
||||
return true;
|
||||
}
|
||||
let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false };
|
||||
if !self.may_copy(id) {
|
||||
return false;
|
||||
}
|
||||
ctx.copy_text(text);
|
||||
true
|
||||
}
|
||||
|
||||
/// The clipboard text of a whole-document copy, once its pages are read; dropped with the
|
||||
/// document it was copied from, and by any later copy (a text field's, a comment's), which
|
||||
/// it must not replace. Run at the end of the frame, so it sees this frame's copies.
|
||||
pub(crate) fn finish_document_copy(&mut self, ctx: &egui::Context) {
|
||||
let Some(c) = &self.document_copy else { return };
|
||||
let copied = ctx.output(|o| o.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))));
|
||||
if copied || !self.views.iter().any(|v| v.id == c.doc) {
|
||||
self.document_copy = None;
|
||||
return;
|
||||
}
|
||||
match c.rx.try_recv() {
|
||||
Ok(text) => {
|
||||
self.document_copy = None;
|
||||
if !text.is_empty() {
|
||||
ctx.copy_text(text);
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.document_copy = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Run a catalogue command. Commands that aren't implemented yet say which milestone ships them.
|
||||
pub fn run_command(&mut self, command: &str) {
|
||||
if let Some(spec) = pdfcraft_engine::commands::command(command) {
|
||||
use pdfcraft_engine::commands::Needs;
|
||||
// A tool that needs a document, chosen with none open, asks for the file and then
|
||||
// runs on it, instead of only saying to open one first.
|
||||
let needs_document = !matches!(spec.needs, Needs::Nothing | Needs::Undo | Needs::Redo);
|
||||
let is_tool = pdfcraft_engine::catalog::TOOL_GROUPS
|
||||
.iter()
|
||||
.flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter()))
|
||||
.any(|i| i.command == spec.id);
|
||||
if self.active.is_none() && needs_document && is_tool && !self.command_enabled(spec) {
|
||||
self.open_then_run(spec);
|
||||
return;
|
||||
}
|
||||
self.execute(command);
|
||||
return;
|
||||
}
|
||||
// Not implemented yet: say which milestone ships it.
|
||||
let when = pdfcraft_engine::catalog::TOOL_GROUPS
|
||||
.iter()
|
||||
.flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter()))
|
||||
.find(|i| i.command == command)
|
||||
.map(|i| match i.availability {
|
||||
pdfcraft_engine::catalog::Availability::Planned(m) => crate::i18n::fmt(tl!("ships in milestone {m}"), &[("m", m)]),
|
||||
pdfcraft_engine::catalog::Availability::Provider => tl!("needs an AI provider (off by default)").to_string(),
|
||||
pdfcraft_engine::catalog::Availability::Ready => tl!("is available").to_string(),
|
||||
})
|
||||
.unwrap_or_else(|| tl!("is not available yet").to_string());
|
||||
self.notify_fmt("`{command}` {when}", &[("command", command), ("when", &when)]);
|
||||
}
|
||||
|
||||
/// Ask for a file to open, then run `spec` on it once it is open (or unlocked). Nothing runs
|
||||
/// when the picker is cancelled or the file doesn't open.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_then_run(&mut self, spec: &'static pdfcraft_engine::commands::CommandSpec) {
|
||||
self.startup_superseded = true;
|
||||
let dialog = Self::open_picker().set_title(tl!(spec.label).trim_end_matches('…'));
|
||||
let command = spec.id;
|
||||
self.ask_one(pickers::Ask::File(dialog), None, move |app, path| {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
app.open_path_then(&path, Some(command));
|
||||
});
|
||||
}
|
||||
|
||||
/// Browsers read the picked file asynchronously; it opens, and the tool can be chosen again.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open_then_run(&mut self, _spec: &'static pdfcraft_engine::commands::CommandSpec) {
|
||||
self.open_dialog();
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_tools(&mut self) {
|
||||
self.left_open = true;
|
||||
}
|
||||
|
||||
/// Enter a tool from the catalogue, Home, palette or control channel. Organize is a
|
||||
/// workspace, so opening its panel also opens the page grid (without toggling it off).
|
||||
pub(crate) fn select_tool(&mut self, id: &'static str) {
|
||||
// The tool's panel shows, not the expanded All tools view over it.
|
||||
self.all_tools_full = false;
|
||||
self.left = LeftPanel::Tool(id);
|
||||
self.reveal_tools();
|
||||
if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) {
|
||||
if id == "organize" {
|
||||
view.open_organize();
|
||||
} else {
|
||||
view.organize = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the workspace and its matching tool panel, just like the mode bar. The tool goes
|
||||
/// with the workspace as [`Self::enter_mode`] sets it.
|
||||
pub(crate) fn select_mode(&mut self, mode: Mode) {
|
||||
self.enter_mode(mode);
|
||||
self.reveal_tools();
|
||||
if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) {
|
||||
view.organize = false;
|
||||
}
|
||||
self.left = match mode {
|
||||
Mode::Edit => LeftPanel::Tool("edit"),
|
||||
Mode::Convert => LeftPanel::Tool("export"),
|
||||
Mode::Sign => LeftPanel::Tool("fill_sign"),
|
||||
_ => LeftPanel::AllTools,
|
||||
};
|
||||
}
|
||||
|
||||
/// Changes the workspace and the tool that goes with it. The mode bar (through
|
||||
/// [`Self::select_mode`]), the Read mode command and the tool panels' back arrows all come
|
||||
/// here, so they leave Edit alike. Edit turns on Edit text & images at once, as Acrobat does:
|
||||
/// every paragraph and image on the page gets its box and is editable with one click. Leaving
|
||||
/// Edit puts the editing tools down again and applies a paragraph still open in the editor.
|
||||
pub(crate) fn enter_mode(&mut self, mode: Mode) {
|
||||
self.mode = mode;
|
||||
// Another workspace puts the expanded All tools view away.
|
||||
if mode != Mode::AllTools {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
if mode == Mode::Edit {
|
||||
self.quick_tool = QuickTool::EditText;
|
||||
} else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) {
|
||||
self.put_down_edit_text();
|
||||
}
|
||||
// Read mode hides the quick toolbar, so no other tool may stay active there. Opening a
|
||||
// document with Read as the default mode comes here without `switch_mode`.
|
||||
if mode == Mode::Read {
|
||||
self.quick_tool = QuickTool::Select;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the workspace with the expanded All tools view, entering All tools the way the mode
|
||||
/// bar does: leaving Read restores its right panel and leaving Edit puts the editing tool down.
|
||||
pub(crate) fn expand_all_tools(&mut self) {
|
||||
if self.mode != Mode::AllTools {
|
||||
self.switch_mode(Mode::AllTools, true);
|
||||
}
|
||||
self.all_tools_full = true;
|
||||
self.all_tools_tab = self.shown_tab();
|
||||
}
|
||||
|
||||
/// The tab at the front: the active document, or else whether Combine files shows over Home.
|
||||
pub(super) fn shown_tab(&self) -> (Option<DocId>, bool) {
|
||||
(self.active.and_then(|i| self.views.get(i)).map(|v| v.id), self.combine_showing())
|
||||
}
|
||||
|
||||
/// Back to the Select tool from an editing tool. A paragraph still open in the editor is
|
||||
/// applied, as clicking away from it does; left open, its page would keep showing the
|
||||
/// paragraph's text removed with no editor over it.
|
||||
pub(crate) fn put_down_edit_text(&mut self) {
|
||||
self.quick_tool = QuickTool::Select;
|
||||
if let Some(ed) = self.active.and_then(|i| self.views.get_mut(i)).and_then(|v| v.line_editor.take())
|
||||
&& let Some(edit) = edit_text_ui::finished_edit(ed)
|
||||
{
|
||||
self.apply_edit(edit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches workspace because the user chose to (mode bar, Read mode command). Entering Read
|
||||
/// mode closes the right panel, as Acrobat's Read mode hides its panes, and leaving it reopens
|
||||
/// that panel. The rail stays in Read mode, and its buttons open panels there too (#932).
|
||||
/// Read mode hides the quick toolbar, so entering it also puts the current tool down.
|
||||
pub(crate) fn switch_mode(&mut self, mode: Mode, select_tools: bool) {
|
||||
let was_read = self.mode == Mode::Read;
|
||||
if select_tools {
|
||||
self.select_mode(mode);
|
||||
} else {
|
||||
self.enter_mode(mode);
|
||||
}
|
||||
if mode == Mode::Read && !was_read {
|
||||
self.right_before_read = self.right.take();
|
||||
// An editing tool is put down so its open paragraph is applied, as `select_mode` does.
|
||||
if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) {
|
||||
self.put_down_edit_text();
|
||||
} else {
|
||||
self.quick_tool = QuickTool::Select;
|
||||
}
|
||||
} else if mode != Mode::Read && was_read {
|
||||
let before = self.right_before_read.take();
|
||||
if self.right.is_none() {
|
||||
self.right = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a right panel, or closes it with `None`, because the user chose to. Closing Comments
|
||||
/// keeps comment tools from reopening it; opening it again lets them (#225). A choice made in
|
||||
/// Read mode replaces the pane saved on the way in, so leaving Read mode doesn't undo it.
|
||||
pub fn choose_right_panel(&mut self, panel: Option<RightPanel>) {
|
||||
if self.mode == Mode::Read {
|
||||
self.right_before_read = None;
|
||||
}
|
||||
if panel == Some(RightPanel::Comments) {
|
||||
self.comments_panel_closed = false;
|
||||
} else if panel.is_none() && self.right == Some(RightPanel::Comments) {
|
||||
self.comments_panel_closed = true;
|
||||
}
|
||||
if panel.is_some() {
|
||||
self.right_panel_closed = false;
|
||||
} else if self.right.is_some() {
|
||||
self.right_panel_closed = true;
|
||||
}
|
||||
self.right = panel;
|
||||
if let Some(panel) = panel {
|
||||
self.last_right = panel;
|
||||
}
|
||||
}
|
||||
|
||||
/// F4: closes the side panel, or reopens the one open last.
|
||||
pub(crate) fn toggle_side_panel(&mut self) {
|
||||
self.choose_right_panel(if self.right.is_some() { None } else { Some(self.last_right) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
use super::*;
|
||||
use crate::control;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Enable the UI control channel on `ctx` (opt-in; see [`control`]). Returns a client that
|
||||
/// sends requests to this app; [`control::serve`] exposes it on loopback.
|
||||
pub fn attach_control(&mut self, ctx: &egui::Context) -> control::ControlClient {
|
||||
let (control, client) = control::attach(ctx);
|
||||
self.control = Some(control);
|
||||
client
|
||||
}
|
||||
|
||||
/// Apply a named view option (`--page 3`, `--panel bookmarks`, `--theme dark`, …).
|
||||
///
|
||||
/// This is the seed of the UI control channel (M3.9): the same verbs become `ui.set` calls.
|
||||
pub fn set_option(&mut self, key: &str, value: &str) -> Result<(), String> {
|
||||
let view = self.active.and_then(|i| self.views.get_mut(i));
|
||||
match (key, view) {
|
||||
("language", _) => {
|
||||
let language = i18n::normalize_pref(value).ok_or_else(|| {
|
||||
let codes: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect();
|
||||
format!("language must be one of {}", codes.join(", "))
|
||||
})?;
|
||||
self.language = language.to_string();
|
||||
}
|
||||
("theme", _) => {
|
||||
let preference = match value {
|
||||
"system" => ThemePreference::System,
|
||||
"light" => ThemePreference::Light,
|
||||
"dark" => ThemePreference::Dark,
|
||||
_ => return Err("theme must be light, dark, or system".into()),
|
||||
};
|
||||
self.set_theme_preference(preference);
|
||||
}
|
||||
("scroll_speed", _) => {
|
||||
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
|
||||
}
|
||||
("animation_effects", _) => {
|
||||
self.animation_effects = motion::AnimationEffects::parse(value).ok_or("animation_effects must be system, on, or off")?;
|
||||
}
|
||||
("panel", _) => {
|
||||
self.right = match value {
|
||||
"comments" => Some(RightPanel::Comments),
|
||||
"bookmarks" => Some(RightPanel::Bookmarks),
|
||||
"pages" => Some(RightPanel::Pages),
|
||||
"fields" => Some(RightPanel::Fields),
|
||||
"layers" => Some(RightPanel::Layers),
|
||||
"attachments" => Some(RightPanel::Attachments),
|
||||
"signatures" => Some(RightPanel::Signatures),
|
||||
"accessibility" => Some(RightPanel::Accessibility),
|
||||
"tags" => Some(RightPanel::Tags),
|
||||
"search" => Some(RightPanel::Search),
|
||||
"compare" => Some(RightPanel::Compare),
|
||||
"none" => None,
|
||||
other => return Err(format!("unknown panel {other}")),
|
||||
}
|
||||
}
|
||||
("mode", _) => {
|
||||
let mode = Mode::parse(value).unwrap_or(Mode::AllTools);
|
||||
self.mode_override = Some(mode);
|
||||
self.mode = mode;
|
||||
// As switch_mode: another workspace puts the expanded All tools view away.
|
||||
if mode != Mode::AllTools {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
}
|
||||
("default-mode", _) => {
|
||||
self.default_mode = Mode::parse(value).ok_or("default-mode must be all, read, edit, convert or sign")?;
|
||||
}
|
||||
("date-format", _) => self.session.set_date_format(value)?,
|
||||
("date-language", _) => self.session.set_date_language(Some(value).filter(|v| *v != "auto"))?,
|
||||
("tool", _) => {
|
||||
let g = pdfcraft_engine::catalog::group(value).ok_or_else(|| format!("unknown tool {value}"))?;
|
||||
self.select_tool(g.id);
|
||||
}
|
||||
("left", _) => self.left_open = value != "closed",
|
||||
("home", _) => {
|
||||
self.active = None;
|
||||
// With no document open the expanded All tools view covers Home itself, so no
|
||||
// other tab comes forward to put it away.
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
("dialog", _) => {
|
||||
self.dialog = match value {
|
||||
"properties" => Some(Dialog::Properties(PropsTab::Description)),
|
||||
"security" => Some(Dialog::Properties(PropsTab::Security)),
|
||||
"fonts" => Some(Dialog::Properties(PropsTab::Fonts)),
|
||||
"custom" => Some(Dialog::Properties(PropsTab::Custom)),
|
||||
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
|
||||
"space-audit" => Some(Dialog::Properties(PropsTab::SpaceAudit)),
|
||||
"shortcuts" => Some(Dialog::Shortcuts),
|
||||
"split" => Some(Dialog::Split),
|
||||
"protect" => Some(Dialog::Protect),
|
||||
"page-boxes" => Some(Dialog::PageBoxes),
|
||||
"header-footer" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)),
|
||||
"watermark" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)),
|
||||
"background" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Background)),
|
||||
"export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)),
|
||||
"word-count" => Some(Dialog::WordCount),
|
||||
"export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)),
|
||||
"export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)),
|
||||
"accessibility-options" => Some(Dialog::AccessibilityOptions),
|
||||
"recognize-text" => Some(Dialog::RecognizeText),
|
||||
"js-console" => Some(Dialog::JsConsole),
|
||||
"document-js" => Some(Dialog::DocumentJs),
|
||||
"preferences" => Some(Dialog::Preferences),
|
||||
"compare-files" => Some(Dialog::CompareFiles),
|
||||
"action-wizard" => Some(Dialog::ActionWizard),
|
||||
"pdfa" => Some(Dialog::PdfA),
|
||||
"signature" => Some(Dialog::Signature),
|
||||
"optimize" => Some(Dialog::Optimize),
|
||||
"sign" | "certify" => {
|
||||
// Sign with a Digital ID for an invisible signature on the current page.
|
||||
let page = self.active.map_or(0, |i| self.views[i].current);
|
||||
self.start_signing(page, None, None, (value == "certify").then_some(2));
|
||||
Some(Dialog::Sign)
|
||||
}
|
||||
"number-pages" => {
|
||||
// Same path as the menu, so the page range is seeded.
|
||||
self.execute("page.number");
|
||||
Some(Dialog::NumberPages)
|
||||
}
|
||||
"none" => None,
|
||||
_ => Some(Dialog::About),
|
||||
}
|
||||
}
|
||||
("tools", _) if value == "full" => self.expand_all_tools(),
|
||||
("tools", _) => {
|
||||
self.all_tools_full = false;
|
||||
self.all_tools_expanded = value != "collapsed";
|
||||
}
|
||||
("palette", _) => {
|
||||
self.palette_open = true;
|
||||
self.palette_query = value.to_string();
|
||||
}
|
||||
("page", Some(v)) => v.go_to_page(value.parse::<usize>().map_err(|e| e.to_string())?.saturating_sub(1)),
|
||||
("zoom", Some(v)) => v.set_zoom(DocView::parse_zoom_percent(value).ok_or("zoom must be a percentage above 0")?),
|
||||
("layout", Some(v)) => {
|
||||
let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?;
|
||||
v.set_display(layout, continuous);
|
||||
}
|
||||
("continuous", Some(v)) => match value {
|
||||
"on" => v.set_continuous(true),
|
||||
"off" => v.set_continuous(false),
|
||||
_ => return Err("continuous must be on or off".into()),
|
||||
},
|
||||
("page-gaps", Some(_)) => {
|
||||
let show = match value {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("page-gaps must be on or off".into()),
|
||||
};
|
||||
self.set_active_page_gaps(show);
|
||||
}
|
||||
("default-page-gaps", _) => {
|
||||
self.view_defaults.show_page_gaps = match value {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("default-page-gaps must be on or off".into()),
|
||||
};
|
||||
}
|
||||
("cover", Some(v)) => {
|
||||
let on = match value {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("cover must be on or off".into()),
|
||||
};
|
||||
if on && !v.cover_applies() {
|
||||
return Err("switch to two-page view first to show the cover page".into());
|
||||
}
|
||||
v.set_cover(on);
|
||||
}
|
||||
("default-layout", _) => {
|
||||
let (layout, continuous) =
|
||||
canvas::parse_display_token(value).ok_or("default-layout must be single, continuous, two-up or two-page")?;
|
||||
(self.view_defaults.layout, self.view_defaults.continuous) = (layout, continuous);
|
||||
}
|
||||
("default-continuous", _) => {
|
||||
self.view_defaults.continuous = match value {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("default-continuous must be on or off".into()),
|
||||
};
|
||||
}
|
||||
("default-zoom", _) => {
|
||||
self.view_defaults =
|
||||
self.view_defaults.with_zoom(value).ok_or("default-zoom must be fit-width, fit-page or a percentage from 8 to 6400")?;
|
||||
}
|
||||
("always-default-view", _) => {
|
||||
self.always_default_view = match value {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("always-default-view must be on or off".into()),
|
||||
};
|
||||
}
|
||||
("tools-side", _) => {
|
||||
self.side_panels.tools_side = match value {
|
||||
"left" => side_panels::ToolsSide::Left,
|
||||
"right" => side_panels::ToolsSide::Right,
|
||||
_ => return Err("tools-side must be left or right".into()),
|
||||
}
|
||||
}
|
||||
("organize", Some(v)) => {
|
||||
if value == "off" {
|
||||
v.organize = false;
|
||||
} else {
|
||||
v.open_organize();
|
||||
}
|
||||
}
|
||||
// Inspector thumbnail size, in percent, independent of document and grid zoom.
|
||||
("thumbnail-zoom", Some(v)) => {
|
||||
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
|
||||
if !percent.is_finite() || !canvas::THUMBNAIL_ZOOM_RANGE.contains(&(percent / 100.0)) {
|
||||
return Err("thumbnail-zoom must be between 50 and 150".into());
|
||||
}
|
||||
v.set_thumbnail_zoom(percent / 100.0);
|
||||
}
|
||||
// `--grid-zoom 150`: the size of the pages in the organize grid, in percent.
|
||||
("grid-zoom", Some(v)) => {
|
||||
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
|
||||
if !percent.is_finite() || !canvas::GRID_ZOOM_RANGE.contains(&(percent / 100.0)) {
|
||||
return Err("grid-zoom must be between 50 and 300".into());
|
||||
}
|
||||
v.set_grid_zoom(percent / 100.0);
|
||||
// The grid's size is one for every document, as it is remembered.
|
||||
self.organize_zoom = v.grid_zoom();
|
||||
}
|
||||
("rotate", Some(v)) => {
|
||||
let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?;
|
||||
if !deg.is_multiple_of(90) {
|
||||
return Err("rotate: 0, 90, 180 or 270".into());
|
||||
}
|
||||
v.rotation = deg % 360;
|
||||
}
|
||||
("layer", _) => {
|
||||
// `--layer "Name=off"` / `"Name=on"`
|
||||
let (name, state) = value.rsplit_once('=').ok_or("expected NAME=on|off")?;
|
||||
let (i, id) = self.active_ids().ok_or("`layer` needs an open document")?;
|
||||
let idx = self
|
||||
.session
|
||||
.get(id)
|
||||
.and_then(|d| d.info.layers.iter().position(|l| l.name == name))
|
||||
.ok_or_else(|| format!("no layer named {name}"))?;
|
||||
if self.session.set_layer_visible(id, idx, state != "off") {
|
||||
self.views[i].invalidate_content();
|
||||
}
|
||||
}
|
||||
("find", Some(v)) => {
|
||||
v.open_find();
|
||||
if let Some(f) = v.find.as_mut() {
|
||||
f.query = value.to_string();
|
||||
}
|
||||
v.rerun_find();
|
||||
}
|
||||
("fields", Some(v)) => v.highlight_fields = value != "off",
|
||||
("select", Some(v)) => {
|
||||
// The same physical page ranges as the Organize Pages selection field.
|
||||
let pages = crate::page_selection_ui::parse(value, v.page_count()).map_err(|e| e.to_string())?;
|
||||
v.select_pages(&pages);
|
||||
}
|
||||
("notice", Some(v)) => v.notice_dismissed = value == "off",
|
||||
("quick", _) => {
|
||||
// `--quick select|hand|note|freetext|highlight|underline|strikeout|ink|line|arrow|square|circle`
|
||||
self.quick_tool = match value {
|
||||
"select" => QuickTool::Select,
|
||||
measure if measure.starts_with("measure-") => QuickTool::Measure(
|
||||
measure_ui::Tool::from_name(measure.trim_start_matches("measure-")).ok_or_else(|| format!("unknown tool {measure}"))?,
|
||||
),
|
||||
"hand" => QuickTool::Hand,
|
||||
"crop" => QuickTool::Crop,
|
||||
"enhance-camera" => QuickTool::EnhanceCamera,
|
||||
"redact" => QuickTool::Redact,
|
||||
"add-text" => QuickTool::AddText,
|
||||
"edit-text" => QuickTool::EditText,
|
||||
"link" => QuickTool::Link,
|
||||
"sign" => QuickTool::SignArea { certify: false },
|
||||
"marquee-zoom" => QuickTool::MarqueeZoom,
|
||||
"snapshot" => QuickTool::Snapshot,
|
||||
"column-select" => QuickTool::ColumnSelect,
|
||||
"certify" => QuickTool::SignArea { certify: true },
|
||||
custom if custom.starts_with("custom-stamp-") => {
|
||||
let i: usize = custom[13..].parse().map_err(|_| format!("bad stamp {custom}"))?;
|
||||
if i >= self.custom_stamps.len() {
|
||||
return Err(format!("there are {} custom stamps", self.custom_stamps.len()));
|
||||
}
|
||||
QuickTool::CustomStamp(i)
|
||||
}
|
||||
stamp if stamp.starts_with("stamp-") => QuickTool::Stamp(
|
||||
pdfcraft_engine::StampKind::ALL
|
||||
.into_iter()
|
||||
.find(|k| k.name().trim_start_matches("PC").eq_ignore_ascii_case(&stamp[6..]))
|
||||
.ok_or_else(|| format!("unknown stamp {stamp}"))?,
|
||||
),
|
||||
field if field.starts_with("field-") => QuickTool::Field(
|
||||
prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?,
|
||||
),
|
||||
fill if fill.starts_with("fill-") => QuickTool::Fill(
|
||||
fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?,
|
||||
),
|
||||
other => {
|
||||
let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?;
|
||||
self.comment_prefs.group_tool[t.group()] = t;
|
||||
QuickTool::Comment(t)
|
||||
}
|
||||
};
|
||||
}
|
||||
("author", _) => self.comment_prefs.author = value.to_string(),
|
||||
("comment-tool-pinned", _) => {
|
||||
self.comment_prefs.pinned = match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("comment-tool-pinned must be true or false".into()),
|
||||
};
|
||||
}
|
||||
("hide-empty-highlight-hover", _) => {
|
||||
self.comment_prefs.hide_empty_highlight_hover = match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("hide-empty-highlight-hover must be true or false".into()),
|
||||
};
|
||||
}
|
||||
("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?,
|
||||
// `--secured-notice false`: no notice above the pages that a document is secured.
|
||||
("secured-notice", _) => {
|
||||
self.show_secured_notice = match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("secured-notice must be true or false".into()),
|
||||
};
|
||||
}
|
||||
("system-title-bar", _) => {
|
||||
self.system_title_bar = match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("system-title-bar must be true or false".into()),
|
||||
};
|
||||
}
|
||||
("flatten-fill-sign", _) => {
|
||||
self.flatten_fill_sign_on_save = match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("flatten-fill-sign must be true or false".into()),
|
||||
};
|
||||
}
|
||||
("comment", Some(v)) => {
|
||||
// `--comment 2:4` selects the 4th annotation of page 2 (1-based, as comment_list reports).
|
||||
let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?;
|
||||
let (p, i): (usize, usize) =
|
||||
(p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?);
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
// `--combine-view list`: Combine files as thumbnails (grid) or as the table (list).
|
||||
("combine-view", _) => {
|
||||
self.combine_view = combine_ui::CombineView::parse(value).ok_or("combine-view must be grid or list")?;
|
||||
}
|
||||
// `--combine-zoom 150`: the size of the cards in the Combine grid, in percent.
|
||||
("combine-zoom", _) => {
|
||||
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
|
||||
if !percent.is_finite() || !combine_grid::ZOOM_RANGE.contains(&(percent / 100.0)) {
|
||||
let (lo, hi) = (combine_grid::ZOOM_RANGE.start(), combine_grid::ZOOM_RANGE.end());
|
||||
return Err(format!("combine-zoom must be between {} and {}", lo * 100.0, hi * 100.0));
|
||||
}
|
||||
self.set_combine_zoom(percent / 100.0);
|
||||
}
|
||||
(k, None)
|
||||
if [
|
||||
"page",
|
||||
"zoom",
|
||||
"layout",
|
||||
"continuous",
|
||||
"page-gaps",
|
||||
"cover",
|
||||
"organize",
|
||||
"grid-zoom",
|
||||
"thumbnail-zoom",
|
||||
"fields",
|
||||
"find",
|
||||
"rotate",
|
||||
"select",
|
||||
"notice",
|
||||
"comment",
|
||||
]
|
||||
.contains(&k) =>
|
||||
{
|
||||
return Err(format!("`{k}` needs an open document"));
|
||||
}
|
||||
(other, _) => return Err(format!("unknown option {other}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
use super::*;
|
||||
|
||||
/// Whether `a` and `b` name the same file: equal, or equal once made absolute against the
|
||||
/// current directory (links are not resolved, as in `last_session`).
|
||||
pub(crate) fn same_file(a: &str, b: &str) -> bool {
|
||||
a == b || matches!((std::path::absolute(a), std::path::absolute(b)), (Ok(x), Ok(y)) if x == y)
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Open a document the way it asks to be opened: navigation panel, layout, magnification,
|
||||
/// page and the place on it (Document Properties ▸ Initial View, `/OpenAction`). With Always
|
||||
/// use my default zoom and page layout on, the layout and magnification stay at the defaults
|
||||
/// the view opened with, and the view goes to the opening page's top.
|
||||
fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) {
|
||||
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
|
||||
let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len());
|
||||
// A right panel the user closed stays closed, whatever the document asks for (#2884).
|
||||
// A document that asks for full screen gets the panel it names for outside it
|
||||
// (`/NonFullScreenPageMode`, Table 147), there when the prompt is declined or full
|
||||
// screen is left.
|
||||
let navigation = if v.full_screen { v.non_full_screen } else { v.navigation };
|
||||
if self.active == Some(index) && !self.right_panel_closed {
|
||||
match navigation {
|
||||
N::PageOnly => {}
|
||||
N::Bookmarks => self.right = Some(RightPanel::Bookmarks),
|
||||
N::Pages => self.right = Some(RightPanel::Pages),
|
||||
N::Attachments => self.right = Some(RightPanel::Attachments),
|
||||
N::Layers => self.right = Some(RightPanel::Layers),
|
||||
}
|
||||
}
|
||||
let view = &mut self.views[index];
|
||||
if !self.always_default_view {
|
||||
match v.layout {
|
||||
// The view opened in Default page display already.
|
||||
L::Default => {}
|
||||
L::SinglePage => (view.layout, view.continuous) = (canvas::PageLayout::Single, false),
|
||||
L::SinglePageContinuous => (view.layout, view.continuous) = (canvas::PageLayout::Single, true),
|
||||
L::TwoUp => (view.layout, view.continuous) = (canvas::PageLayout::TwoUp, false),
|
||||
L::TwoUpContinuous => (view.layout, view.continuous) = (canvas::PageLayout::TwoUp, true),
|
||||
L::TwoUpCoverPage => {
|
||||
(view.layout, view.continuous, view.cover) = (canvas::PageLayout::TwoUp, false, true);
|
||||
}
|
||||
L::TwoUpContinuousCoverPage => {
|
||||
(view.layout, view.continuous, view.cover) = (canvas::PageLayout::TwoUp, true, true);
|
||||
}
|
||||
}
|
||||
match v.magnification {
|
||||
// The view opened at the default zoom already.
|
||||
M::Default => {}
|
||||
M::ActualSize => view.set_zoom(1.0),
|
||||
M::Percent(p) => view.set_zoom((p / 100.0) as f32),
|
||||
M::FitPage | M::FitVisible => view.fit = canvas::Fit::Page,
|
||||
M::FitWidth => view.fit = canvas::Fit::Width,
|
||||
M::FitHeight => view.fit = canvas::Fit::Height,
|
||||
}
|
||||
}
|
||||
// The opening destination (`/OpenAction`, named destinations resolved): its page, and
|
||||
// where on it and how large, as a bookmark or link to it would (ISO 32000-2 Table 149).
|
||||
// With the default view kept, only the page.
|
||||
let info = self.session.get(view.id).map(|d| &d.info);
|
||||
match info.and_then(|i| i.open_dest.filter(|(page, _)| *page < pages).map(|d| (i, d))) {
|
||||
Some((info, (page, dest))) if !self.always_default_view => view.go_to_dest(page, dest, info),
|
||||
Some((_, (page, _))) => {
|
||||
if page > 0 {
|
||||
view.go_to_page(page);
|
||||
}
|
||||
}
|
||||
None if v.page > 0 && v.page < pages => view.go_to_page(v.page),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a document and make it the active tab. Encrypted files raise the password prompt.
|
||||
pub fn open_bytes(&mut self, name: &str, path: Option<String>, bytes: Vec<u8>) -> Result<(), String> {
|
||||
self.startup_superseded = true;
|
||||
self.open_bytes_with_focus(name, path, bytes, true)
|
||||
}
|
||||
|
||||
pub(super) fn open_bytes_with_focus(&mut self, name: &str, path: Option<String>, bytes: Vec<u8>, activate: bool) -> Result<(), String> {
|
||||
// Images and text files become new, unsaved PDFs (Create a PDF).
|
||||
let active = self.active;
|
||||
if let Some(r) = self.open_converted(name, &bytes) {
|
||||
if !activate {
|
||||
self.active = active;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
self.try_open(name, path, std::sync::Arc::new(bytes), None, activate)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn schedule_open(&mut self, request: OpenRequest) -> Result<u64, String> {
|
||||
if self.open_worker.is_none() {
|
||||
self.open_worker = Some(
|
||||
open_worker::OpenWorker::new(
|
||||
#[cfg(test)]
|
||||
self.open_prepare_gate.clone(),
|
||||
)
|
||||
.map_err(|e| format!("couldn't start the document-open worker: {e}"))?,
|
||||
);
|
||||
}
|
||||
let token = self.next_open_token.wrapping_add(1);
|
||||
let worker = self.open_worker.as_ref().ok_or("the document-open worker is unavailable")?;
|
||||
if let Some(ctx) = &self.ctx {
|
||||
worker.set_context(ctx);
|
||||
}
|
||||
let receiver =
|
||||
worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?;
|
||||
self.next_open_token = token;
|
||||
self.pending_opens.push(PendingOpen {
|
||||
token,
|
||||
receiver,
|
||||
name: request.name,
|
||||
path: request.path,
|
||||
stamp: request.stamp,
|
||||
activate: request.activate,
|
||||
after_open: request.after_open,
|
||||
recovered: request.recovered,
|
||||
then: request.then,
|
||||
});
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn poll_pending_opens(&mut self) {
|
||||
let pending_opens = std::mem::take(&mut self.pending_opens);
|
||||
let mut remaining = pending_opens.into_iter();
|
||||
let mut waiting = Vec::new();
|
||||
while let Some(pending) = remaining.next() {
|
||||
// A tool, password prompt or file picker belongs to the document just admitted.
|
||||
// Keep later replies queued so another open cannot change its recipient.
|
||||
if self.modal_open() || self.pickers.pending() {
|
||||
waiting.push(pending);
|
||||
waiting.extend(remaining);
|
||||
break;
|
||||
}
|
||||
match pending.receiver.try_recv() {
|
||||
Ok(result) if result.token == pending.token => self.finish_pending_open(pending, result.kind),
|
||||
Ok(_) => waiting.push(pending),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A completion may schedule another open; preserve those requests as well as the
|
||||
// earlier waiting replies, without consuming a blocked receiver's result.
|
||||
waiting.append(&mut self.pending_opens);
|
||||
self.pending_opens = waiting;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_pending_open(&mut self, pending: PendingOpen, kind: open_worker::ResultKind) {
|
||||
match kind {
|
||||
open_worker::ResultKind::ReadFailed(reason) => {
|
||||
self.notify_fmt("Couldn't read {name}: {e}", &[("name", &pending.name), ("e", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::RemoteFailed(reason) => {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::RecoveryFailed(reason) => {
|
||||
self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &pending.name), ("e", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::Failed(reason) => {
|
||||
self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::Conversion(bytes) => match self.open_converted(&pending.name, &bytes) {
|
||||
Some(Ok(())) => {
|
||||
if let Some(command) = pending.then {
|
||||
self.execute(command);
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
|
||||
None => {}
|
||||
},
|
||||
open_worker::ResultKind::Prepared { bytes, result } => match *result {
|
||||
Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) {
|
||||
Ok(id) => {
|
||||
if pending.path.is_some() {
|
||||
self.watch.known_as(id, pending.stamp);
|
||||
}
|
||||
if let Some(destination) = pending.after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
if let Some(meta) = &pending.recovered {
|
||||
self.finish_recovery(meta);
|
||||
}
|
||||
if let Some(command) = pending.then {
|
||||
self.execute(command);
|
||||
}
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
|
||||
},
|
||||
Err(error @ (pdfcraft_render::OpenError::NeedsPassword | pdfcraft_render::OpenError::WrongPassword)) => {
|
||||
let password_error =
|
||||
matches!(error, pdfcraft_render::OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
|
||||
self.password_prompt = Some(PasswordPrompt {
|
||||
name: pending.name,
|
||||
path: pending.path,
|
||||
bytes,
|
||||
stamp: pending.stamp,
|
||||
input: String::new(),
|
||||
error: password_error,
|
||||
activate: pending.activate,
|
||||
recovered: pending.recovered,
|
||||
then: pending.then,
|
||||
after_open: pending.after_open,
|
||||
});
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn try_open(
|
||||
&mut self,
|
||||
name: &str,
|
||||
path: Option<String>,
|
||||
bytes: std::sync::Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
activate: bool,
|
||||
) -> Result<(), String> {
|
||||
use pdfcraft_render::OpenError;
|
||||
let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => {
|
||||
let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let stamp = path.as_deref().and_then(file_watch::Stamp::at);
|
||||
self.password_prompt = Some(PasswordPrompt {
|
||||
name: name.to_string(),
|
||||
path,
|
||||
bytes,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp,
|
||||
input: String::new(),
|
||||
error,
|
||||
activate,
|
||||
recovered: None,
|
||||
then: None,
|
||||
after_open: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ())
|
||||
}
|
||||
|
||||
fn finish_open(
|
||||
&mut self,
|
||||
name: &str,
|
||||
path: Option<String>,
|
||||
size: usize,
|
||||
prepared: pdfcraft_engine::PreparedDocument,
|
||||
activate: bool,
|
||||
) -> Result<DocId, String> {
|
||||
let id = self.session.commit_open(prepared);
|
||||
self.password_prompt = None;
|
||||
// The reading-colours preference is app-wide: a newly opened document starts with it.
|
||||
// Idempotent: the engine returns `false` when the document has the setting already.
|
||||
self.session.set_reading_colors(id, self.reading_colors);
|
||||
let doc = self.session.get(id).ok_or("the document could not be opened")?;
|
||||
let pages = doc.info.pages.len();
|
||||
// Acrobat opens straight to the Comments panel when a document has comments.
|
||||
// Read mode opens with the panes closed; the rail still opens them (#932). A right panel
|
||||
// the user closed stays closed (#1590).
|
||||
let opening_mode = self.mode_override.unwrap_or(self.default_mode);
|
||||
let initial = doc.initial_view();
|
||||
if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read {
|
||||
self.right = if !doc.info.annotations.is_empty() {
|
||||
Some(RightPanel::Comments)
|
||||
// A full-screen document names its panel for outside full screen, and with
|
||||
// `/NonFullScreenPageMode /UseNone` (the default) no outline is visible (Table 147).
|
||||
} else if !doc.info.outline.is_empty() && !initial.full_screen {
|
||||
Some(RightPanel::Bookmarks)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
let view = DocView::new(id, &doc.info, self.view_defaults);
|
||||
self.views.push(view);
|
||||
if activate {
|
||||
self.active = Some(self.views.len() - 1);
|
||||
}
|
||||
self.apply_initial_view(self.views.len() - 1, &initial);
|
||||
// Where the reader left off wins over the file's own initial view (#1767).
|
||||
self.apply_last_view(self.views.len() - 1);
|
||||
// After both, so full screen shows one fitted page and leaving it comes back to them.
|
||||
if self.full_screen
|
||||
&& let Some(view) = self.views.last_mut()
|
||||
{
|
||||
view.enter_presentation();
|
||||
}
|
||||
// Full screen is asked for, never taken: a document can't put the app in full screen on
|
||||
// its own (the prompt is `dialogs::full_screen_prompt`).
|
||||
if activate && initial.full_screen {
|
||||
self.pending_full_screen = Some(id);
|
||||
}
|
||||
if activate
|
||||
&& self.left_open
|
||||
&& self.left == LeftPanel::Tool("organize")
|
||||
&& let Some(view) = self.views.last_mut()
|
||||
{
|
||||
view.open_organize();
|
||||
}
|
||||
// The document shows, not the expanded All tools view over it (whichever workspace opens).
|
||||
if activate {
|
||||
self.all_tools_full = false;
|
||||
}
|
||||
if activate && let Some(mode) = self.mode_override {
|
||||
// Explicit mode options do not reset independent --tool / --left choices.
|
||||
self.mode = mode;
|
||||
} else if activate && self.mode != self.default_mode {
|
||||
// Switch workspace like the mode bar, but a left panel the user (or `--left closed`)
|
||||
// closed stays closed, and an unchanged mode keeps the tool panel the user chose.
|
||||
let left_open = self.left_open;
|
||||
self.select_mode(self.default_mode);
|
||||
self.left_open = left_open;
|
||||
}
|
||||
if let Some(p) = path {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_association::repair(std::path::Path::new(&p));
|
||||
self.recent.retain(|r| r.path != p);
|
||||
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
|
||||
self.recent.truncate(12);
|
||||
}
|
||||
// What the form's scripts said while it opened (messages, errors) shows now, not with
|
||||
// the next edit.
|
||||
// Background documents keep their script output until their tab is selected: a print
|
||||
// or save request must never act on the current document instead.
|
||||
if activate {
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Edit ▸ Attach a file…: choose a file, then attach it to the active document and show
|
||||
/// the Attachments panel with it.
|
||||
pub fn attach_file_dialog(&mut self) {
|
||||
if self.active_ids().is_none() {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Attach a file").to_string());
|
||||
let target = self.active_ids().map(|(_, id)| id);
|
||||
self.ask_one(pickers::Ask::File(dialog), target, |app, path| app.attach_file(&path));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify_tr("Attaching files is not available on the web yet");
|
||||
}
|
||||
|
||||
/// Attach the file at `path` to the active document (document level: the Attachments
|
||||
/// panel, not a paper-clip comment on a page).
|
||||
pub fn attach_file(&mut self, path: &std::path::Path) {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string);
|
||||
if self.apply_edit(pdfcraft_engine::Edit::AddAttachment { name, bytes, description: None, mime }) {
|
||||
self.choose_right_panel(Some(RightPanel::Attachments));
|
||||
}
|
||||
}
|
||||
|
||||
/// Save an attachment to disk, or open a PDF attachment in a new tab.
|
||||
pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) {
|
||||
let Some(d) = self.session.get(doc) else { return };
|
||||
let Some(att) = d.info.attachments.get(index).cloned() else { return };
|
||||
let data = pdfcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att);
|
||||
match (data, open) {
|
||||
(Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]),
|
||||
(Ok(bytes), true) => {
|
||||
// The tab name later names files written from the tab (Split, Extract pages).
|
||||
if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]);
|
||||
}
|
||||
}
|
||||
(Ok(bytes), false) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.ask_one(
|
||||
pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))),
|
||||
None,
|
||||
move |app, path| match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) {
|
||||
Ok(()) => app.notify_fmt("Saved {name}", &[("name", &path.display().to_string())]),
|
||||
Err(e) => app.notify_fmt("Couldn't save: {e}", &[("e", &e.to_string())]),
|
||||
},
|
||||
);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify_fmt("Downloading attachments on the web arrives with M3.10 ({n} bytes ready)", &[("n", &bytes.len().to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::attachment_action`] for the file of the attachment comment at (page, index).
|
||||
pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) {
|
||||
let source = pdfcraft_render::AttachmentSource::Annotation { page, index };
|
||||
// The menus offer the items only for a listed file, so a miss is a comment gone since.
|
||||
if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) {
|
||||
self.attachment_action(doc, i, open);
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer the password prompt (`None` cancels).
|
||||
pub fn submit_password(&mut self, password: Option<String>) {
|
||||
let Some(p) = self.password_prompt.take() else { return };
|
||||
let Some(pw) = password else {
|
||||
// A cancelled recovery goes back on offer; its snapshot stays in the store and must
|
||||
// not attach itself to whatever document is unlocked next (#813).
|
||||
if let Some(meta) = p.recovered {
|
||||
self.recoverable.push(meta);
|
||||
}
|
||||
return;
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.ctx.is_some() && !self.run_inline {
|
||||
let result = self.schedule_open(OpenRequest {
|
||||
name: p.name.clone(),
|
||||
path: p.path.clone(),
|
||||
stamp: p.stamp,
|
||||
input: open_worker::Input::Bytes(p.bytes.clone()),
|
||||
password: Some(pw),
|
||||
activate: p.activate,
|
||||
after_open: p.after_open.clone(),
|
||||
recovered: p.recovered.clone(),
|
||||
then: p.then,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
let mut prompt = p;
|
||||
prompt.error = Some(error);
|
||||
self.password_prompt = Some(prompt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp);
|
||||
let after_open = p.after_open.clone();
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) {
|
||||
Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
|
||||
// A recovered encrypted document is open once its prompt is gone.
|
||||
Ok(()) if self.password_prompt.is_none() => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = path_for_watch.as_deref()
|
||||
&& let Some(id) = self
|
||||
.views
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|view| self.session.get(view.id).and_then(|doc| (doc.path.as_deref() == Some(path)).then_some(view.id)))
|
||||
{
|
||||
self.watch.known_as(id, stamp_for_watch);
|
||||
}
|
||||
if let Some(destination) = after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
if let Some(meta) = &p.recovered {
|
||||
self.finish_recovery(meta);
|
||||
}
|
||||
if let Some(command) = p.then {
|
||||
self.execute(command);
|
||||
}
|
||||
}
|
||||
// Wrong password: the new prompt is for the same bytes, so it keeps the snapshot
|
||||
// and the tool waiting for it.
|
||||
Ok(()) => {
|
||||
if let Some(prompt) = self.password_prompt.as_mut() {
|
||||
prompt.recovered = p.recovered;
|
||||
prompt.then = p.then;
|
||||
prompt.after_open = after_open;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the active document's permissions password, then run `then` (if any).
|
||||
pub(crate) fn request_unlock(&mut self, then: Option<&'static str>) {
|
||||
let Some(doc) = self.active_ids().and_then(|(_, id)| self.session.get(id)) else { return };
|
||||
self.unlock_prompt = Some(UnlockPrompt::new(doc, then));
|
||||
}
|
||||
|
||||
/// Answer the permissions password prompt (`None` cancels).
|
||||
pub fn submit_unlock(&mut self, password: Option<String>) {
|
||||
let Some(mut p) = self.unlock_prompt.take() else { return };
|
||||
let Some(pw) = password else { return };
|
||||
match self.session.unlock(p.doc, &pw) {
|
||||
// Carry on with what asked for the password, if its document is still the one shown.
|
||||
Ok(()) => match p.then {
|
||||
Some(command) if self.active_ids().is_some_and(|(_, id)| id == p.doc) => {
|
||||
self.execute(command);
|
||||
}
|
||||
_ => self.notify_tr("Restrictions lifted"),
|
||||
},
|
||||
Err(pdfcraft_engine::EditError::WrongPassword) => {
|
||||
p.input.clear();
|
||||
p.error = Some("Incorrect password. Try again.".to_string());
|
||||
self.unlock_prompt = Some(p);
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't unlock {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab already showing the file at `path` (the same path, or one that names the same
|
||||
/// file once made absolute).
|
||||
pub(crate) fn tab_showing(&self, path: &str) -> Option<usize> {
|
||||
self.views.iter().position(|v| self.session.get(v.id).and_then(|d| d.path.as_deref()).is_some_and(|p| same_file(p, path)))
|
||||
}
|
||||
|
||||
/// Open a file from a recent list: focus the tab already showing it, else open it (File ▸
|
||||
/// Open Recent and the Home view's list share this).
|
||||
pub fn open_recent(&mut self, path: &str) {
|
||||
if let Some(i) = self.tab_showing(path) {
|
||||
self.active = Some(i);
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.open_path(path);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path(&mut self, path: &str) -> OpenOutcome {
|
||||
self.open_path_then(path, None)
|
||||
}
|
||||
|
||||
/// Keep a chosen tool with its file until preparation and any password entry finish. The
|
||||
/// return value is the programmatic open contract (#1620): callers that must know whether
|
||||
/// the file opened, focused its tab, was handed to the background worker, or failed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {
|
||||
// One file, one tab (#2166): a file that is open already comes to the front instead,
|
||||
// and the tool it was opened for runs on it. One still being opened is left to that.
|
||||
if let Some(i) = self.tab_showing(path) {
|
||||
self.startup_superseded = true;
|
||||
self.active = Some(i);
|
||||
if let Some(command) = then {
|
||||
self.execute(command);
|
||||
}
|
||||
return OpenOutcome::Focused;
|
||||
}
|
||||
if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
|
||||
return OpenOutcome::Pending;
|
||||
}
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
|
||||
let stamp = file_watch::Stamp::at(path);
|
||||
// Before the first frame there is no interaction to keep responsive; this also preserves
|
||||
// deterministic inline startup and test flows. Once live, reading and preparation use
|
||||
// the bounded worker and commit on the UI thread.
|
||||
if self.ctx.is_none() || self.run_inline {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let before = self.views.len();
|
||||
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
return OpenOutcome::Failed;
|
||||
}
|
||||
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
|
||||
// Encrypted: the tool runs once the password is in.
|
||||
prompt.then = then;
|
||||
} else if self.views.len() > before {
|
||||
if let Some(doc) = self.views.last().map(|view| view.id) {
|
||||
self.watch.known_as(doc, stamp);
|
||||
}
|
||||
if let Some(command) = then {
|
||||
self.execute(command);
|
||||
}
|
||||
} else {
|
||||
// Open reported success but added no tab and raised no prompt; the
|
||||
// control channel answers this as a failure to open.
|
||||
return OpenOutcome::Failed;
|
||||
}
|
||||
OpenOutcome::Opened
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]);
|
||||
OpenOutcome::Failed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.startup_superseded = true;
|
||||
match self.schedule_open(OpenRequest {
|
||||
name: name.clone(),
|
||||
path: Some(path.to_string()),
|
||||
stamp,
|
||||
input: open_worker::Input::Path(std::path::PathBuf::from(path)),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then,
|
||||
}) {
|
||||
Ok(_) => OpenOutcome::Pending,
|
||||
Err(error) => {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
OpenOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
|
||||
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE)
|
||||
}
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
self.startup_superseded = true;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.pick(
|
||||
pickers::PickFor::Open,
|
||||
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE),
|
||||
true,
|
||||
);
|
||||
// Browsers pick files asynchronously; the bytes arrive through `inbox`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = self.ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(h) = rfd::AsyncFileDialog::new()
|
||||
.add_filter("PDF", &["pdf"])
|
||||
.add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE)
|
||||
.pick_file()
|
||||
.await
|
||||
{
|
||||
let bytes = h.read().await;
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((h.file_name(), bytes));
|
||||
}
|
||||
// The read may finish while the app is idle: wake it to open the file.
|
||||
if let Some(ctx) = ctx {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_tab(&mut self, index: usize) {
|
||||
if index >= self.views.len() {
|
||||
return;
|
||||
}
|
||||
self.remember_view(index);
|
||||
let id = self.views.remove(index).id;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) {
|
||||
if self.closed_tabs.len() == 10 {
|
||||
self.closed_tabs.remove(0);
|
||||
}
|
||||
self.closed_tabs.push(path);
|
||||
}
|
||||
self.forget_recovery(id);
|
||||
self.session.close(id);
|
||||
self.active = match self.active {
|
||||
_ if self.views.is_empty() => None,
|
||||
Some(a) if a > index => Some(a - 1),
|
||||
Some(a) if a >= self.views.len() => Some(self.views.len() - 1),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
/// Moves the tab at `from` so it sits at `to` (#1537). The same document stays active
|
||||
/// wherever it lands; an index out of range changes nothing.
|
||||
pub fn move_tab(&mut self, from: usize, to: usize) {
|
||||
if from >= self.views.len() || to >= self.views.len() || from == to {
|
||||
return;
|
||||
}
|
||||
let active = self.active_ids().map(|(_, id)| id);
|
||||
let view = self.views.remove(from);
|
||||
self.views.insert(to, view);
|
||||
if let Some(id) = active {
|
||||
self.active = self.views.iter().position(|v| v.id == id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Draw the running job's progress card and pass a Cancel click on to the job.
|
||||
pub(super) fn show_progress(&mut self, ctx: &egui::Context) {
|
||||
if widgets::progress_notice(self, ctx) {
|
||||
self.cancel_optimize();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_theme(&mut self, ctx: &egui::Context) {
|
||||
#[cfg_attr(not(target_os = "linux"), expect(unused_mut))]
|
||||
let mut system = ctx.system_theme().map(|theme| theme == egui::Theme::Dark);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let active = self.theme_preference == ThemePreference::System;
|
||||
if self.desktop_theme_enabled && active && self.desktop_theme.is_none() {
|
||||
let ctx = ctx.clone();
|
||||
self.desktop_theme = Some(pdfcraft_platform::desktop_theme::DesktopTheme::start(move || ctx.request_repaint()));
|
||||
}
|
||||
if let Some(desktop) = &self.desktop_theme {
|
||||
desktop.set_active(active);
|
||||
// The standard portal supplies Linux desktop appearance. No preference
|
||||
// must clear an earlier answer rather than retaining stale dark chrome.
|
||||
system = desktop.get().map(|appearance| appearance == pdfcraft_platform::desktop_theme::DesktopAppearance::Dark);
|
||||
}
|
||||
}
|
||||
let kind = self.theme_preference.resolve(system, ThemeKind::Light);
|
||||
if kind != self.theme {
|
||||
self.theme = kind;
|
||||
theme::apply(ctx, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Root-level panel salts whose 0.36 persisted state key changed in 0.37.
|
||||
///
|
||||
/// The shared rail and side panes now own their state. The legacy `rail` and
|
||||
/// `read_right_panel` entries are inert; the former `rail-nav` footer is no longer migrated.
|
||||
const PANEL_SALTS: [&str; 4] = ["tab_strip", "mode_bar", "rail", "read_right_panel"];
|
||||
|
||||
/// Copy persisted 0.36 panel sizes to 0.37's parent-scope key, once, before the first panel.
|
||||
///
|
||||
/// 0.36 stored [`egui::PanelState`] under the panel's own `Id::unique(salt)`; 0.37 derives it
|
||||
/// from the parent `Ui`'s scope plus an `IdSalt`. A missing copy would reset a width the user
|
||||
/// chose. The old entry is left untouched, so rolling back to 0.36 still finds it.
|
||||
pub(super) fn migrate_panel_state(ui: &egui::Ui) {
|
||||
if ui.ctx().viewport_id() != egui::ViewportId::ROOT {
|
||||
return;
|
||||
}
|
||||
for salt in PANEL_SALTS {
|
||||
migrate_panel_state_from(ui, salt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy one 0.36 panel's state to the key derived from this exact parent `Ui`.
|
||||
///
|
||||
/// The parent must be the `Ui` the panel is shown in (for a nested panel, the panel's content
|
||||
/// `Ui`), because 0.37's key includes that `Ui`'s scope.
|
||||
pub(crate) fn migrate_panel_state_from(ui: &egui::Ui, salt: &str) {
|
||||
let old = egui::Id::unique(salt);
|
||||
let new = ui.scope_id().with_salt(egui::IdSalt::new(salt));
|
||||
if old == new || egui::PanelState::load(ui.ctx(), new).is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(state) = egui::PanelState::load(ui.ctx(), old) {
|
||||
ui.ctx().data_mut(|data| data.insert_persisted(new, state));
|
||||
log::debug!("migrated panel {salt} state {:?}", state.size());
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// The parts of [`eframe::App::raw_input_hook`] that must run before egui's input hooks:
|
||||
/// publish this pass's input-target generation to the control plugin, and hand the wheel
|
||||
/// input to the reduced-motion reader.
|
||||
///
|
||||
/// A test host that drives the app without eframe's raw hook can call this first, so its
|
||||
/// frame ordering matches production instead of hiding focus-change bugs.
|
||||
#[doc(hidden)]
|
||||
pub fn prepare_raw_input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// The control plugin's input hook runs next: give it this pass's input generation.
|
||||
if let Some(control) = &self.control {
|
||||
control.set_epoch(self.input_epoch());
|
||||
}
|
||||
// Before egui reads this frame's wheel input.
|
||||
self.scroll_speed.apply(ctx, raw);
|
||||
// The native menu's key equivalents.
|
||||
if let Some(native) = self.native_menu.as_mut() {
|
||||
native.raw_input(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context) {
|
||||
self.finish_document_copy(ctx);
|
||||
// The Combine grid's thumbnails, whichever tab shows (renders under way still finish).
|
||||
self.combine_thumbs_frame(ctx);
|
||||
// Retire hidden documents before admitting the visible document's textures, so the
|
||||
// three cache limits apply to the application, regardless of how many tabs are open.
|
||||
// Side by side: the comparison's old file renders alongside the active one.
|
||||
let partner = self.active.and_then(|i| compare_ui::side_by_side(self, i)).map(|(old, _)| old);
|
||||
for (i, view) in self.views.iter_mut().enumerate() {
|
||||
if Some(i) != self.active
|
||||
&& Some(i) != partner
|
||||
&& let Some(doc) = self.session.get(view.id)
|
||||
{
|
||||
view.suspend_rendering(&doc.renderer);
|
||||
}
|
||||
}
|
||||
for i in self.active.into_iter().chain(partner) {
|
||||
if let Some(view) = self.views.get_mut(i)
|
||||
&& let Some(doc) = self.session.get(view.id)
|
||||
{
|
||||
view.finish_render_frame(ctx, &doc.info, &doc.renderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_migration_tests {
|
||||
use super::*;
|
||||
|
||||
fn state(width: f32) -> egui::PanelState {
|
||||
egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(width, 900.0)) }
|
||||
}
|
||||
|
||||
/// 0.36 stored a panel's persisted size under `Id::unique(salt)`. 0.37 derives the key from
|
||||
/// the root `Ui`'s scope; the upgrade must carry the chosen width over, not reset it.
|
||||
#[test]
|
||||
fn upgrade_from_036_panel_memory_keeps_width() {
|
||||
let ctx = egui::Context::default();
|
||||
let old = egui::Id::unique("rail");
|
||||
ctx.data_mut(|d| d.insert_persisted(old, state(287.0)));
|
||||
let mut migrated = None;
|
||||
let mut new_id = None;
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
migrate_panel_state(ui);
|
||||
new_id = Some(ui.scope_id().with_salt(egui::IdSalt::new("rail")));
|
||||
migrated = egui::PanelState::load(ui.ctx(), new_id.expect("an id"));
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
let migrated = migrated.expect("the 0.36 panel width migrates to the 0.37 key");
|
||||
assert!((migrated.size().x - 287.0).abs() <= 1.0, "width survives: {:?}", migrated.size());
|
||||
assert_eq!(new_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail")));
|
||||
assert!(egui::PanelState::load(&ctx, old).is_some(), "the old key stays for rollback");
|
||||
}
|
||||
|
||||
/// A synthetic nested panel is keyed from its own parent `Ui`, not the root.
|
||||
/// Migrating it at the root would silently miss its 0.36 state; the fixture uses
|
||||
/// legacy rail names but does not model the current shared rail implementation.
|
||||
#[test]
|
||||
fn nested_panel_state_migrates_from_its_actual_parent_ui() {
|
||||
let ctx = egui::Context::default();
|
||||
let old = egui::Id::unique("rail-nav");
|
||||
ctx.data_mut(|d| d.insert_persisted(old, state(140.0)));
|
||||
let mut nested_id = None;
|
||||
let mut migrated = None;
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
egui::Panel::right("rail").resizable(false).exact_size(48.0).show(ui, |ui| {
|
||||
migrate_panel_state_from(ui, "rail-nav");
|
||||
// The key egui will use for the nested panel shown in this same Ui.
|
||||
let key = ui.scope_id().with_salt(egui::IdSalt::new("rail-nav"));
|
||||
nested_id = Some(key);
|
||||
migrated = egui::PanelState::load(ui.ctx(), key);
|
||||
egui::Panel::bottom("rail-nav").resizable(false).show(ui, |_ui| {});
|
||||
});
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
let migrated = migrated.expect("the nested 0.36 state migrates to its parent-scoped key");
|
||||
assert_eq!(migrated.size().x, 140.0);
|
||||
assert_ne!(nested_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail-nav")), "the nested key is not the root key");
|
||||
}
|
||||
|
||||
/// The one-time copy must never overwrite state a 0.37 run has already stored.
|
||||
#[test]
|
||||
fn migration_does_not_replace_a_037_panel_state() {
|
||||
let ctx = egui::Context::default();
|
||||
let old = egui::Id::unique("rail");
|
||||
let new = ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail"));
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_persisted(old, state(287.0));
|
||||
d.insert_persisted(new, state(412.0));
|
||||
});
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| migrate_panel_state(ui));
|
||||
output.drop_without_applying_deltas();
|
||||
let kept = egui::PanelState::load(&ctx, new).expect("the 0.37 state");
|
||||
assert_eq!(kept.size().x, 412.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::*;
|
||||
|
||||
/// Read a file dropped on a page grid (see [`GridFile`]).
|
||||
fn read_grid_file((name, source): GridFile) -> Result<(String, Vec<u8>), (String, String)> {
|
||||
let bytes = match source {
|
||||
Ok(bytes) => bytes,
|
||||
Err(path) => std::fs::read(&path).map_err(|e| (name.clone(), e.to_string()))?,
|
||||
};
|
||||
Ok((name, bytes))
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn open_dropped(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) {
|
||||
let p = f.path().to_string_lossy().into_owned();
|
||||
if !p.is_empty() && f.path().is_absolute() {
|
||||
self.open_path(&p);
|
||||
return;
|
||||
}
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
|
||||
match f.bytes() {
|
||||
Ok(bytes) => {
|
||||
if self.ctx.is_some() && !self.run_inline {
|
||||
let result = self.schedule_open(OpenRequest {
|
||||
name: name.clone(),
|
||||
path: None,
|
||||
stamp: None,
|
||||
input: open_worker::Input::Bytes(std::sync::Arc::new(bytes)),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
}
|
||||
} else if let Err(error) = self.open_bytes(&name, None, bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
}
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The document whose page grid is showing and may take pages, if any.
|
||||
fn grid_target(&self) -> Option<pdfcraft_engine::DocId> {
|
||||
let view = self.active.and_then(|i| self.views.get(i)).filter(|v| v.organize)?;
|
||||
(self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id)
|
||||
}
|
||||
|
||||
/// The document whose Pages panel is under the pointer and may take pages, if any.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn panel_target(&self, ctx: &egui::Context) -> Option<pdfcraft_engine::DocId> {
|
||||
let view = self.active.and_then(|i| self.views.get(i))?;
|
||||
let pointer = ctx.input(|i| i.pointer.hover_pos())?;
|
||||
let over = view.pages_panel.is_some_and(|panel| panel.contains(pointer));
|
||||
(over && self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id)
|
||||
}
|
||||
|
||||
/// Files dropped on the Pages panel, or while the page grid shows, are inserted into the
|
||||
/// document rather than opened: they are kept until [`Self::finish_grid_drop`] knows the
|
||||
/// gap. Returns the files not taken.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn drop_on_grid(&mut self, dropped: Vec<egui::DroppedFileHandle>, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> {
|
||||
if dropped.is_empty() {
|
||||
return dropped;
|
||||
}
|
||||
// The panel first: the grid takes files dropped anywhere in the window.
|
||||
let (id, panel) = match (self.panel_target(ctx), self.grid_target()) {
|
||||
(Some(id), _) => (id, true),
|
||||
(None, Some(id)) => (id, false),
|
||||
(None, None) => return dropped,
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
for f in &dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
|
||||
if f.path().is_absolute() {
|
||||
files.push((name, Err(f.path().to_path_buf())));
|
||||
continue;
|
||||
}
|
||||
match f.bytes() {
|
||||
Ok(b) => files.push((name, Ok(b))),
|
||||
Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e)]),
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
// Where there is no telling where the pointer is during the drag, its place arrives
|
||||
// with the first move after the drop.
|
||||
ctx.request_repaint();
|
||||
self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: web_time::Instant::now() + std::time::Duration::from_secs(1) });
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Insert files dropped on the page grid or the Pages panel at the gap under the pointer,
|
||||
/// or at the end when the pointer hasn't shown up in time.
|
||||
pub(super) fn finish_grid_drop(&mut self, ctx: &egui::Context) {
|
||||
let Some(GridDrop { doc: id, panel, deadline, .. }) = &self.grid_drop else { return };
|
||||
let (id, panel, deadline) = (*id, *panel, *deadline);
|
||||
let still = if panel {
|
||||
self.dialog.is_none()
|
||||
&& self.active_ids().map(|(_, active)| active) == Some(id)
|
||||
&& self.session.get(id).is_some_and(|d| d.allows_assembly())
|
||||
} else {
|
||||
self.grid_target() == Some(id)
|
||||
};
|
||||
if !still {
|
||||
self.grid_drop = None;
|
||||
return self.notify_tr("The document changed while you were choosing a file, so nothing was changed.");
|
||||
}
|
||||
let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return };
|
||||
let gap = match if panel { view.panel_gap } else { view.grid_gap } {
|
||||
Some(gap) => gap,
|
||||
None if web_time::Instant::now() >= deadline => usize::MAX,
|
||||
None => return ctx.request_repaint(),
|
||||
};
|
||||
view.insert_at = Some(gap);
|
||||
if let Some(drop) = self.grid_drop.take() {
|
||||
self.insert_each(drop.files.into_iter().map(read_grid_file));
|
||||
}
|
||||
}
|
||||
|
||||
/// Browsers read dropped files asynchronously; the bytes land in `inbox` and open next frame.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn open_dropped(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) {
|
||||
self.startup_superseded = true;
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
|
||||
if let Ok(bytes) = f.bytes_async().await
|
||||
&& let Ok(mut q) = inbox.lock()
|
||||
{
|
||||
q.push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {
|
||||
use egui::Key;
|
||||
let pasted = self.paste_key_released(ctx);
|
||||
// "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document
|
||||
// properties; Escape cancels it rather than clearing a selection), and nothing may run
|
||||
// underneath it. They are read here, before the canvas can consume them.
|
||||
if self.close_request.is_some() {
|
||||
// These dialogs are drawn after the save prompt and own the keyboard while open.
|
||||
// Don't let Escape or Return resolve unsaved work underneath one.
|
||||
let covered = self.pending_link.is_some()
|
||||
|| self.pending_remote_link.is_some()
|
||||
|| self.pending_full_screen.is_some()
|
||||
|| self.updates.open
|
||||
|| self.dialog.is_some()
|
||||
|| self.unlock_prompt.is_some();
|
||||
if !covered && let Some(choice) = dialogs::save_prompt_key(ctx, &mut self.save_prompt_button) {
|
||||
self.resolve_close(ctx, choice);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The other dialogs and prompts are modal too (⌘W closed the file behind Preferences,
|
||||
// #870).
|
||||
if self.modal_open() {
|
||||
return;
|
||||
}
|
||||
if let Some(view) = self.active.and_then(|i| self.views.get_mut(i))
|
||||
&& view.auto_scroll.escape(ctx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The in-window menu bar holds the keys while one of its menus is open or it has the
|
||||
// keyboard (Alt, F10); a command shortcut leaves it and runs.
|
||||
craft_menu::bar::leave_for_shortcut(ctx);
|
||||
if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) {
|
||||
self.registry_shortcuts(ctx);
|
||||
}
|
||||
// (Esc closes a Combine file shown large first.)
|
||||
if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
self.set_full_screen(ctx, false);
|
||||
}
|
||||
if let Some(i) = self.active {
|
||||
// Select all belongs to the document or page grid, unless a text field or
|
||||
// overlay owns the keyboard. Other canvas shortcuts keep their own handling.
|
||||
if self.dialog.is_none()
|
||||
&& !self.palette_open
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
|
||||
{
|
||||
if self.is_preparing() {
|
||||
if let Some(doc) = self.session.get(self.views[i].id) {
|
||||
prepare::select_all(&mut self.views[i], &doc.form);
|
||||
}
|
||||
} else {
|
||||
self.views[i].select_all();
|
||||
}
|
||||
}
|
||||
self.tool_keys(i, ctx);
|
||||
if self.quick_tool == QuickTool::EditText
|
||||
&& let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification())
|
||||
{
|
||||
object_ui::nudge_keys(ctx, &mut self.views[i], doc);
|
||||
object_ui::delete_keys(ctx, &mut self.views[i]);
|
||||
} else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) {
|
||||
comments::nudge_keys(ctx, &mut self.views[i], &doc.info);
|
||||
}
|
||||
// A selected field moves with the arrows too (Prepare a form, #2608), before the view
|
||||
// turns the page with them.
|
||||
if self.is_preparing()
|
||||
&& let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification())
|
||||
{
|
||||
prepare::nudge_keys(ctx, &mut self.views[i], doc);
|
||||
}
|
||||
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
|
||||
self.copy_key(i, ctx);
|
||||
self.paste_image_key(i, ctx, pasted);
|
||||
self.image_pick_keys(i, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it
|
||||
/// is released. Plain letters, so not while a text field, a form field, a dialog or the
|
||||
/// palette has the keyboard.
|
||||
fn tool_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::Key;
|
||||
let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && self.views[i].forms.focus.is_none();
|
||||
let (plain, space) = ctx.input(|input| (input.modifiers.is_none(), input.key_down(Key::Space)));
|
||||
if let Some(previous) = self.space_hand
|
||||
&& !space
|
||||
{
|
||||
self.space_hand = None;
|
||||
if self.quick_tool == QuickTool::Hand {
|
||||
self.quick_tool = previous;
|
||||
}
|
||||
}
|
||||
if !free || !plain {
|
||||
return;
|
||||
}
|
||||
if ctx.input(|input| input.key_pressed(Key::V)) {
|
||||
self.quick_tool = QuickTool::Select;
|
||||
self.space_hand = None;
|
||||
}
|
||||
if ctx.input(|input| input.key_pressed(Key::H)) {
|
||||
self.quick_tool = QuickTool::Hand;
|
||||
self.space_hand = None;
|
||||
}
|
||||
if space && self.space_hand.is_none() && self.quick_tool != QuickTool::Hand {
|
||||
self.space_hand = Some(self.quick_tool);
|
||||
self.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn open_remote_link(&mut self, source: DocId, file: String, destination: pdfcraft_render::RemoteDestination) {
|
||||
if self.pending_remote_link.is_some() || self.pending_link.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(source_doc) = self.session.get(source) else { return };
|
||||
let source_path = source_doc.path.clone();
|
||||
let path = match pdfcraft_engine::resolve_remote_document_path(source_path.as_deref().map(std::path::Path::new), &file) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if pdfcraft_engine::remote_document_path_is_absolute(&file) {
|
||||
self.pending_remote_link = Some(PendingRemoteLink { source, path, destination });
|
||||
return;
|
||||
}
|
||||
self.open_resolved_remote_link(path, destination);
|
||||
}
|
||||
|
||||
/// Take a pending request exactly once. Closing its source invalidates the request.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn resolve_pending_remote_link(&mut self, open: bool) {
|
||||
if let Some(pending) = self.pending_remote_link.take()
|
||||
&& open
|
||||
&& self.session.get(pending.source).is_some()
|
||||
{
|
||||
self.open_resolved_remote_link(pending.path, pending.destination);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_resolved_remote_link(&mut self, path: std::path::PathBuf, destination: pdfcraft_render::RemoteDestination) {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone());
|
||||
let stamp = file_watch::Stamp::at(&path);
|
||||
self.startup_superseded = true;
|
||||
let result = self.schedule_open(OpenRequest {
|
||||
name: name.clone(),
|
||||
path: Some(path.clone()),
|
||||
stamp,
|
||||
input: open_worker::Input::RemotePath(std::path::PathBuf::from(path)),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: Some(destination),
|
||||
recovered: None,
|
||||
then: None,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &error)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn open_remote_link(&mut self, _source: DocId, _file: String, _destination: pdfcraft_render::RemoteDestination) {
|
||||
self.notify_tr("Local cross-PDF links require the desktop app");
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn resolve_pending_remote_link(&mut self, _open: bool) {
|
||||
self.pending_remote_link = None;
|
||||
}
|
||||
|
||||
pub(super) fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) {
|
||||
let Some(index) = self.views.len().checked_sub(1) else { return };
|
||||
let Some(doc) = self.session.get(self.views[index].id) else { return };
|
||||
let warning = match destination {
|
||||
pdfcraft_render::RemoteDestination::Default => None,
|
||||
pdfcraft_render::RemoteDestination::Named(_) => Some("The linked PDF opened, but its named destination was not applied."),
|
||||
pdfcraft_render::RemoteDestination::Page { page, view } if page < doc.info.pages.len() => {
|
||||
self.views[index].go_to_dest(page, view, &doc.info);
|
||||
None
|
||||
}
|
||||
pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."),
|
||||
};
|
||||
if let Some(warning) = warning {
|
||||
self.notify_tr(warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a web link in the system browser (a new tab on the web). Only for PdfCraft's own
|
||||
/// links; an address that came from a document goes through [`Self::request_document_url`].
|
||||
pub fn open_url(&mut self, url: &str) {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
self.last_opened_url = Some(url.to_string());
|
||||
}
|
||||
|
||||
/// A document asks to open `url` (a link, a button's URI action or `app.launchURL`). Web and
|
||||
/// email addresses wait for the user to allow them; anything else is refused with a notice
|
||||
/// (#90, #91). While one request is waiting, further ones are dropped, so a script can't
|
||||
/// queue up a stream of dialogs.
|
||||
pub fn request_document_url(&mut self, url: &str, origin: LinkOrigin) {
|
||||
match pdfcraft_engine::links::document_url(url) {
|
||||
Ok(url) => {
|
||||
if self.pending_link.is_none() && self.pending_remote_link.is_none() {
|
||||
self.pending_link = Some(PendingLink { url, origin });
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
use pdfcraft_engine::links::BlockedLink;
|
||||
let template = if matches!(e, BlockedLink::MailFile | BlockedLink::MailEncodedWord) {
|
||||
"{who} in this document tried to open an address PdfCraft won't open: {e}."
|
||||
} else {
|
||||
"{who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents."
|
||||
};
|
||||
self.notify_fmt(template, &[("who", tl!(origin.noun())), ("e", &e.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's answer to [`Self::pending_link`]: open it, or drop it.
|
||||
pub fn resolve_pending_link(&mut self, open: bool) {
|
||||
if let Some(p) = self.pending_link.take()
|
||||
&& open
|
||||
{
|
||||
self.open_url(&p.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use super::*;
|
||||
|
||||
/// [`PdfCraftApp::date_text`] for callers that already borrow other parts of the app.
|
||||
pub(crate) fn date_text(session: &Session, fmt: Option<&str>) -> Result<String, String> {
|
||||
let lang = session.date_language().unwrap_or(i18n::current().code());
|
||||
session.today_text(fmt, Some(lang))
|
||||
}
|
||||
|
||||
/// Today in Preferences ▸ Date format for stamping into a PDF; refused when it has characters
|
||||
/// Fill & Sign can't write yet (see [`pdfcraft_engine::dates::unwritable`]).
|
||||
pub(crate) fn date_text_for_pdf(session: &Session) -> Result<String, String> {
|
||||
let lang = session.date_language().unwrap_or(i18n::current().code());
|
||||
session.today_text_for_pdf(None, Some(lang))
|
||||
}
|
||||
|
||||
/// The longest author name kept (Preferences ▸ Identity, restored settings).
|
||||
pub(crate) const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
/// A restored colour: an `[r, g, b]` array of finite numbers, each clamped to 0–1 (restored
|
||||
/// settings are untrusted). Anything else (a string, a wrong length, a null or a non-finite
|
||||
/// number) is refused whole, so the caller keeps its default.
|
||||
fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> {
|
||||
let a = v.as_array()?;
|
||||
if a.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0.0f64; 3];
|
||||
for (slot, x) in out.iter_mut().zip(a.iter()) {
|
||||
let n = x.as_f64()?;
|
||||
if !n.is_finite() {
|
||||
return None;
|
||||
}
|
||||
*slot = n.clamp(0.0, 1.0);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
pub fn set_theme_preference(&mut self, preference: ThemePreference) {
|
||||
self.theme_preference = preference;
|
||||
if let Some(ctx) = self.ctx.clone() {
|
||||
self.sync_theme(&ctx);
|
||||
theme::apply(&ctx, self.theme);
|
||||
} else {
|
||||
self.theme = preference.resolve(None, ThemeKind::Light);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-wide reading-colours preference: sets it and applies it to every open document,
|
||||
/// not just the active one (Preferences ▸ Reading colours, Acrobat's Replace Document
|
||||
/// Colors). A document the change actually touches re-renders.
|
||||
pub fn set_reading_colors(&mut self, colors: pdfcraft_render::ReadingColors) {
|
||||
self.reading_colors = colors;
|
||||
for i in 0..self.views.len() {
|
||||
// Idempotent per document: the engine says whether anything changed.
|
||||
if self.session.set_reading_colors(self.views[i].id, colors) {
|
||||
self.views[i].invalidate_content();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable the Linux desktop portal in a native window. Headless contexts keep
|
||||
/// their supplied input and never connect to the user's session bus.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn enable_desktop_theme(&mut self) {
|
||||
self.desktop_theme_enabled = true;
|
||||
}
|
||||
|
||||
/// Today in `fmt` (or Preferences ▸ Date format), with month and weekday names in the date
|
||||
/// language, or the interface language when it follows that.
|
||||
pub fn date_text(&self, fmt: Option<&str>) -> Result<String, String> {
|
||||
date_text(&self.session, fmt)
|
||||
}
|
||||
|
||||
/// Serialize the user's persistent state (recent files, theme). Local only.
|
||||
pub fn persist(&self) -> String {
|
||||
let trusted: Vec<String> = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect();
|
||||
let mut v = serde_json::json!({
|
||||
"side_panels": self.side_panels.to_json(self.left_open),
|
||||
"docking_right": self.right,
|
||||
"recent": self.recent,
|
||||
"reopen_last_session": self.reopen_last_session,
|
||||
"restore_last_view": self.restore_last_view,
|
||||
"last_views": self.last_views_to_save(),
|
||||
// Kept only while the preference is on.
|
||||
"last_session": self.reopen_last_session.then(|| self.session_to_save()),
|
||||
"reload_changed_files": self.reload_changed_files,
|
||||
"system_title_bar": self.system_title_bar,
|
||||
"pinned_folders": self.pinned.folders,
|
||||
"theme": self.theme_preference,
|
||||
"scroll_speed": self.scroll_speed,
|
||||
"animation_effects": self.animation_effects,
|
||||
"default_mode": self.default_mode,
|
||||
"default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous),
|
||||
"default_zoom": self.view_defaults.zoom_name(),
|
||||
"always_default_view": self.always_default_view,
|
||||
"highlight_fields": self.view_defaults.highlight_fields,
|
||||
"units": self.units,
|
||||
"language": self.language,
|
||||
"reading_colors": self.reading_colors,
|
||||
// Null follows the system's text scaling.
|
||||
"ui_scale": self.ui_scale,
|
||||
"flatten_fill_sign": self.flatten_fill_sign_on_save,
|
||||
"fill_text_size": self.fill_text_size,
|
||||
"date_format": self.session.date_format(),
|
||||
// Null follows the interface language.
|
||||
"date_language": self.session.date_language(),
|
||||
"author": self.comment_prefs.author,
|
||||
// Per-tool comment defaults (colours, opacity, widths), so Make Current
|
||||
// Properties Default survives a restart (#340).
|
||||
"comment_styles": self
|
||||
.comment_prefs
|
||||
.styles()
|
||||
.map(|(t, s)| serde_json::json!({
|
||||
"tool": t.command(),
|
||||
"color": s.color,
|
||||
"opacity": s.opacity,
|
||||
"width": s.width,
|
||||
"fill": s.fill,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
// Drawn signatures keep their original form (older settings read the same).
|
||||
"signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None },
|
||||
"signature_text": match &self.signature { Some(fill_sign::SavedSig::Typed(t)) => Some(t), _ => None },
|
||||
"signature_image": match &self.signature { Some(s @ fill_sign::SavedSig::Image(_)) => Some(s), _ => None },
|
||||
"initials": self.initials,
|
||||
"sign_graphic": self.sign_graphic,
|
||||
// macOS Keychain and Windows store identities are read from their OS key stores each time.
|
||||
"digital_ids": self.digital_ids.iter().filter(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")).collect::<Vec<_>>(),
|
||||
"trusted": trusted,
|
||||
"custom_stamps": stamps_ui::encode(&self.custom_stamps),
|
||||
"javascript": self.session.javascript(),
|
||||
"actions": actions_ui::encode(&self.custom_actions),
|
||||
"combine_columns": self.combine_columns.to_json(),
|
||||
"combine_view": self.combine_view.as_str(),
|
||||
"combine_zoom": self.combine_zoom,
|
||||
"comments_panel_closed": self.comments_panel_closed,
|
||||
"right_panel_closed": self.right_panel_closed,
|
||||
});
|
||||
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
|
||||
v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps);
|
||||
v["redact_prefs"] = self.redact_defaults.to_json();
|
||||
// Set outside json! above, which is at serde_json's macro recursion limit.
|
||||
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
|
||||
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
|
||||
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
|
||||
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
|
||||
// Canonical names survive changes to the order of PAPERS and do not depend on UI language.
|
||||
v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name));
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// Restore state written by `persist`. Unknown or malformed data is ignored.
|
||||
pub fn restore(&mut self, json: &str) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return };
|
||||
let panels = side_panels::restore(&v);
|
||||
self.side_panels = panels.panels;
|
||||
if let Some(open) = panels.tools_open {
|
||||
self.left_open = open;
|
||||
}
|
||||
if let Some(right) = v.get("docking_right") {
|
||||
self.right = serde_json::from_value(right.clone()).ok().flatten().filter(|_| !panels.side_closed);
|
||||
}
|
||||
self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]);
|
||||
if let Some(view) = v["combine_view"].as_str().and_then(combine_ui::CombineView::parse) {
|
||||
self.combine_view = view;
|
||||
}
|
||||
if let Some(zoom) = v["combine_zoom"].as_f64() {
|
||||
self.set_combine_zoom(zoom as f32);
|
||||
}
|
||||
// Clamped like any grid zoom when the grid next shows; nonsense keeps the usual size.
|
||||
if let Some(zoom) = v["organize_zoom"].as_f64().map(|z| z as f32).filter(|z| z.is_finite()) {
|
||||
self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end());
|
||||
}
|
||||
if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(v["recent"].clone()) {
|
||||
// Only keep entries whose files still exist.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
|
||||
self.recent = r;
|
||||
}
|
||||
if let Some(on) = v["reopen_last_session"].as_bool() {
|
||||
self.reopen_last_session = on;
|
||||
}
|
||||
if let Some(on) = v["restore_last_view"].as_bool() {
|
||||
self.restore_last_view = on;
|
||||
}
|
||||
self.restore_last_views(&v["last_views"]);
|
||||
self.last_session = last_session::LastSession::from_json(&v["last_session"]);
|
||||
if let Some(on) = v["system_title_bar"].as_bool() {
|
||||
self.system_title_bar = on;
|
||||
}
|
||||
if let Some(on) = v["reload_changed_files"].as_bool() {
|
||||
self.reload_changed_files = on;
|
||||
}
|
||||
if let Some(on) = v["show_secured_notice"].as_bool() {
|
||||
self.show_secured_notice = on;
|
||||
}
|
||||
self.pinned.restore(&v["pinned_folders"]);
|
||||
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
|
||||
self.set_theme_preference(preference);
|
||||
}
|
||||
if let Ok(speed) = serde_json::from_value::<scroll_speed::ScrollSpeed>(v["scroll_speed"].clone()) {
|
||||
self.scroll_speed = speed;
|
||||
}
|
||||
if let Ok(effects) = serde_json::from_value::<motion::AnimationEffects>(v["animation_effects"].clone()) {
|
||||
self.animation_effects = effects;
|
||||
}
|
||||
if let Ok(mode) = serde_json::from_value::<Mode>(v["default_mode"].clone()) {
|
||||
self.default_mode = mode;
|
||||
}
|
||||
if let Some((layout, continuous)) = v["default_layout"].as_str().and_then(canvas::parse_display_token) {
|
||||
(self.view_defaults.layout, self.view_defaults.continuous) = (layout, continuous);
|
||||
}
|
||||
if let Some(show) = v["default_page_gaps"].as_bool() {
|
||||
self.view_defaults.show_page_gaps = show;
|
||||
}
|
||||
if let Ok(units) = serde_json::from_value::<units::Unit>(v["units"].clone()) {
|
||||
self.units = units;
|
||||
}
|
||||
if let Some(on) = v["always_default_view"].as_bool() {
|
||||
self.always_default_view = on;
|
||||
}
|
||||
if let Some(paper) = v
|
||||
.get("print_paper")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name))
|
||||
{
|
||||
self.print_draft.paper = paper;
|
||||
}
|
||||
if let Some(on) = v["highlight_fields"].as_bool() {
|
||||
self.view_defaults.highlight_fields = on;
|
||||
}
|
||||
if let Some(closed) = v["comments_panel_closed"].as_bool() {
|
||||
self.comments_panel_closed = closed;
|
||||
}
|
||||
if let Some(closed) = v["right_panel_closed"].as_bool() {
|
||||
self.right_panel_closed = closed;
|
||||
}
|
||||
if let Some(defaults) = v["default_zoom"].as_str().and_then(|zoom| self.view_defaults.with_zoom(zoom)) {
|
||||
self.view_defaults = defaults;
|
||||
}
|
||||
if let Some(language) = v["language"].as_str().and_then(i18n::normalize_pref) {
|
||||
self.language = language.to_string();
|
||||
}
|
||||
if let Some(on) = v["flatten_fill_sign"].as_bool() {
|
||||
self.flatten_fill_sign_on_save = on;
|
||||
}
|
||||
if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) {
|
||||
self.fill_text_size = size;
|
||||
}
|
||||
if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) {
|
||||
self.redact_prefs = prefs.clone();
|
||||
self.redact_defaults = prefs;
|
||||
}
|
||||
// Settings are untrusted: an unreadable value (a colour that isn't three bytes, say)
|
||||
// fails to deserialize and keeps Off.
|
||||
if let Ok(colors) = serde_json::from_value::<pdfcraft_render::ReadingColors>(v["reading_colors"].clone()) {
|
||||
self.reading_colors = colors;
|
||||
}
|
||||
// Settings are untrusted: a size out of range follows the system instead.
|
||||
self.ui_scale = v["ui_scale"].as_f64().and_then(ui_scale::valid);
|
||||
// Settings are untrusted: an unusable pattern keeps the default.
|
||||
if let Some(f) = v["date_format"].as_str() {
|
||||
let _ = self.session.set_date_format(f);
|
||||
}
|
||||
if let Some(l) = v["date_language"].as_str() {
|
||||
let _ = self.session.set_date_language(Some(l));
|
||||
}
|
||||
// An empty or missing name keeps the login-name default; settings are untrusted, so the
|
||||
// name is cut to a sane length.
|
||||
if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) {
|
||||
self.comment_prefs.author = author.chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
}
|
||||
if let Some(hide) = v["hide_empty_highlight_hover"].as_bool() {
|
||||
self.comment_prefs.hide_empty_highlight_hover = hide;
|
||||
}
|
||||
if let Some(pinned) = v["comment_tool_pinned"].as_bool() {
|
||||
self.comment_prefs.pinned = pinned;
|
||||
}
|
||||
// Per-tool comment styles (#340): each field goes through its clamping setter, so a
|
||||
// malformed or hostile entry is clamped or ignored rather than trusted.
|
||||
if let Some(styles) = v["comment_styles"].as_array() {
|
||||
for e in styles {
|
||||
let Some(tool) = e["tool"].as_str().and_then(crate::comments::CommentTool::from_command) else { continue };
|
||||
if let Some(c) = rgb_triple(&e["color"]) {
|
||||
self.comment_prefs.set_color(tool, c);
|
||||
}
|
||||
if let Some(o) = e["opacity"].as_f64() {
|
||||
self.comment_prefs.set_opacity(tool, o);
|
||||
}
|
||||
if let Some(w) = e["width"].as_f64() {
|
||||
self.comment_prefs.set_width(tool, w);
|
||||
}
|
||||
// `"fill": null` is a saved "no fill", which clears a tool's default fill; a
|
||||
// missing or malformed fill keeps it.
|
||||
let fill = match e.get("fill") {
|
||||
Some(serde_json::Value::Null) => Some(None),
|
||||
Some(v) => rgb_triple(v).map(Some),
|
||||
None => None,
|
||||
};
|
||||
if let Some(fill) = fill {
|
||||
let mut s = self.comment_prefs.style(tool);
|
||||
s.fill = fill;
|
||||
self.comment_prefs.set_style(tool, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(s) = serde_json::from_value::<Vec<Vec<[f32; 2]>>>(v["signature"].clone())
|
||||
&& s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite())))
|
||||
{
|
||||
self.signature = Some(fill_sign::SavedSig::Drawn(s));
|
||||
}
|
||||
if let Some(t) = v["signature_text"].as_str().filter(|t| !t.trim().is_empty()) {
|
||||
self.signature = Some(fill_sign::SavedSig::Typed(t.to_string()));
|
||||
}
|
||||
if let Ok(s @ fill_sign::SavedSig::Image(_)) = serde_json::from_value::<fill_sign::SavedSig>(v["signature_image"].clone()) {
|
||||
self.signature = Some(s);
|
||||
}
|
||||
if let Ok(i) = serde_json::from_value::<fill_sign::SavedSig>(v["initials"].clone()) {
|
||||
self.initials = Some(i);
|
||||
}
|
||||
// An unreadable choice (a damaged image) falls back to the name.
|
||||
self.sign_graphic = serde_json::from_value(v["sign_graphic"].clone()).unwrap_or_default();
|
||||
if let Ok(ids) = serde_json::from_value::<Vec<DigitalIdEntry>>(v["digital_ids"].clone()) {
|
||||
self.digital_ids = ids;
|
||||
}
|
||||
self.custom_stamps = stamps_ui::decode(&v["custom_stamps"]);
|
||||
self.custom_actions = actions_ui::decode(&v["actions"]);
|
||||
if let Some(on) = v["javascript"].as_bool() {
|
||||
self.session.set_javascript(on);
|
||||
}
|
||||
if let Ok(pems) = serde_json::from_value::<Vec<String>>(v["trusted"].clone()) {
|
||||
let certs = pems.iter().filter_map(|p| pdfcraft_engine::sign::x509::load_certificates(p.as_bytes()).ok()).flatten().collect();
|
||||
self.apply_trusted_certificates(certs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves
|
||||
/// the frame to the app and Use system title bar is off.
|
||||
pub fn draws_window_frame(&self) -> bool {
|
||||
self.window_controls.is_some() && !self.system_title_bar
|
||||
}
|
||||
|
||||
/// Follow Use system title bar at once: the window starts without winit's frame wherever the
|
||||
/// app may draw its own, and gets it back (or loses it again) when the preference changes.
|
||||
pub(super) fn sync_window_frame(&mut self, ctx: &egui::Context) {
|
||||
if self.window_controls.is_none() {
|
||||
return;
|
||||
}
|
||||
let app_frame = self.draws_window_frame();
|
||||
if self.app_frame_shown != Some(app_frame) {
|
||||
// The window opens undecorated, so only a change from that needs a command.
|
||||
if self.app_frame_shown.is_some() || !app_frame {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(!app_frame));
|
||||
}
|
||||
self.app_frame_shown = Some(app_frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter or leave full-screen reading (Acrobat: View ▸ Full Screen Mode, ⌘L).
|
||||
pub fn set_full_screen(&mut self, ctx: &egui::Context, on: bool) {
|
||||
self.apply_full_screen(on);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
|
||||
}
|
||||
|
||||
/// The full-screen state without the window: every document shows one page (or spread) at a
|
||||
/// time fitted to the screen while it is on (Acrobat's Full Screen Mode, #379, #1459), and
|
||||
/// gets its display back when it is off.
|
||||
pub fn apply_full_screen(&mut self, on: bool) {
|
||||
// A click turns the page only with the Select tool, and full screen hides the tools:
|
||||
// select it there, and give the tool back on leaving (review on #2070).
|
||||
if on && !self.full_screen {
|
||||
self.full_screen_tool = Some(self.quick_tool);
|
||||
self.quick_tool = QuickTool::Select;
|
||||
} else if !on && let Some(tool) = self.full_screen_tool.take() {
|
||||
self.quick_tool = tool;
|
||||
}
|
||||
self.full_screen = on;
|
||||
for view in &mut self.views {
|
||||
if on {
|
||||
view.enter_presentation();
|
||||
} else {
|
||||
view.leave_presentation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer the Full Screen mode prompt a document raised on opening: enter it, or leave the
|
||||
/// window as it is.
|
||||
pub fn resolve_full_screen_prompt(&mut self, ctx: &egui::Context, allow: bool) {
|
||||
let Some(id) = self.pending_full_screen.take() else { return };
|
||||
if allow && self.session.get(id).is_some() {
|
||||
self.set_full_screen(ctx, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user