mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
* feat(ui): preference to hide author-only highlight hover previews (#1432) * Keep the Identity heading, translate the new hover preference, move its persist key out of json! The heading rename dropped the existing Identity translation in every catalog; the new checkbox label now has an entry in every catalog. The json! object in persist() is at serde_json's recursion limit, so the new key is set after it like redact_prefs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * cargo fmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refresh catalog hashes after merging main --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3186 lines
141 KiB
Rust
3186 lines
141 KiB
Rust
//! pdfcraft-ui-egui — the first PdfCraft shell (L7).
|
||
//!
|
||
//! Layout grammar follows plan/acrobat/02-ui-ux.md §1: tab strip, mode bar, left tool panel,
|
||
//! floating quick-action bar, document area, right panel + right rail with page navigation.
|
||
//! Everything here is presentation: documents, rendering and the tool catalogue live in
|
||
//! `pdfcraft-engine`.
|
||
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Translate an English UI string into the current language (see [`i18n`]).
|
||
macro_rules! tl {
|
||
($s:expr) => {
|
||
$crate::i18n::t($s)
|
||
};
|
||
}
|
||
|
||
/// [`tl!`] for a label whose meaning depends on where it appears ("Type" is a column and a
|
||
/// button): a catalog can translate it under `context`, otherwise the plain translation is used.
|
||
macro_rules! tl_ctx {
|
||
($context:expr, $s:expr) => {
|
||
$crate::i18n::tr_ctx($crate::i18n::current(), $context, $s)
|
||
};
|
||
}
|
||
|
||
mod a11y_ui;
|
||
mod actions_ui;
|
||
mod appearance;
|
||
pub mod canvas;
|
||
mod chrome;
|
||
mod combine_grid;
|
||
mod image_pick;
|
||
mod tags_ui;
|
||
pub use combine_grid::ThumbState as CombineThumb;
|
||
mod combine_ui;
|
||
pub mod docking;
|
||
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
|
||
mod camera_ui;
|
||
mod commands;
|
||
mod comment_props;
|
||
pub mod comments;
|
||
mod comments_panel;
|
||
mod compare_ui;
|
||
pub mod control;
|
||
mod create_multiple_ui;
|
||
mod create_ui;
|
||
mod credits;
|
||
mod crop;
|
||
mod drag_pointer;
|
||
mod export_ui;
|
||
mod js_ui;
|
||
mod marks_ui;
|
||
mod measure_ui;
|
||
mod ocr_ui;
|
||
mod optimize_ui;
|
||
mod search_ui;
|
||
mod sign_ui;
|
||
mod stamps_ui;
|
||
mod standards_ui;
|
||
mod zoom_snap;
|
||
/// Header & footer / watermark / background dialog types (tests and automation).
|
||
pub mod marks {
|
||
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
|
||
}
|
||
mod content_ui;
|
||
mod link_ui;
|
||
mod object_ui;
|
||
pub use create_ui::Clip;
|
||
pub use link_ui::LinkDraft;
|
||
pub use optimize_ui::{OptimizeDraft, OptimizeTab};
|
||
pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep};
|
||
mod autoscroll;
|
||
mod bidi;
|
||
pub mod bulk_fields;
|
||
mod dialogs;
|
||
mod edit_text_ui;
|
||
pub use edit_text_ui::CropMode;
|
||
mod editing;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub mod file_watch;
|
||
mod files;
|
||
pub mod fill_sign;
|
||
mod fling;
|
||
pub mod folders_ui;
|
||
pub mod forms_ui;
|
||
mod home;
|
||
mod icon_data;
|
||
pub mod icons;
|
||
pub mod last_session;
|
||
pub mod motion;
|
||
mod page_selection_ui;
|
||
mod pageboxes;
|
||
mod palette;
|
||
mod panels;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
mod pickers;
|
||
pub mod prepare;
|
||
mod print_ui;
|
||
mod signature_drag;
|
||
pub mod units;
|
||
mod web_print;
|
||
mod word_count_ui;
|
||
pub use print_ui::{Handling as PrintHandling, PrintDraft, PrinterSource, Which as PrintWhich, system_printers};
|
||
mod redact_ui;
|
||
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
|
||
pub mod i18n;
|
||
|
||
/// [`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;
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
mod open_worker;
|
||
mod organize_panel;
|
||
pub mod portable;
|
||
mod protect;
|
||
mod recovery;
|
||
pub mod scroll_speed;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
mod system_fonts;
|
||
mod tab_context;
|
||
pub mod theme;
|
||
pub mod ui_scale;
|
||
pub mod updates;
|
||
mod wheel_pager;
|
||
mod widgets;
|
||
pub mod window_controls;
|
||
|
||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||
mod open_worker_tests;
|
||
|
||
use pdfcraft_engine::{DocId, Session};
|
||
|
||
pub use canvas::{DocView, RasterMemory};
|
||
pub use editing::{CloseRequest, SaveTarget};
|
||
pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan};
|
||
pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore};
|
||
use theme::{ThemeKind, ThemePreference};
|
||
|
||
/// Top-level workspace modes (Acrobat's mode bar).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum Mode {
|
||
#[serde(rename = "all")]
|
||
AllTools,
|
||
Read,
|
||
Edit,
|
||
Convert,
|
||
Sign,
|
||
}
|
||
|
||
impl Mode {
|
||
fn parse(value: &str) -> Option<Self> {
|
||
match value {
|
||
"all" => Some(Self::AllTools),
|
||
"read" => Some(Self::Read),
|
||
"edit" => Some(Self::Edit),
|
||
"convert" => Some(Self::Convert),
|
||
"sign" => Some(Self::Sign),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What the left panel shows.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum LeftPanel {
|
||
AllTools,
|
||
Tool(&'static str),
|
||
}
|
||
|
||
/// Right-hand panels, opened from the rail.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum RightPanel {
|
||
Comments,
|
||
Bookmarks,
|
||
Pages,
|
||
Fields,
|
||
Layers,
|
||
Attachments,
|
||
Signatures,
|
||
/// Accessibility Checker results.
|
||
Accessibility,
|
||
/// Reachable PDF structure tags and their properties.
|
||
Tags,
|
||
/// Advanced Search results.
|
||
Search,
|
||
/// Compare files: the differences.
|
||
Compare,
|
||
}
|
||
|
||
/// Quick-action bar tools (the vertical floating strip).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum QuickTool {
|
||
Select,
|
||
Hand,
|
||
/// A commenting tool (Add comments).
|
||
Comment(comments::CommentTool),
|
||
Measure(measure_ui::Tool),
|
||
/// Crop pages by dragging a rectangle.
|
||
Crop,
|
||
/// Scan & OCR ▸ Enhance ▸ Camera image: drag four corners, then Enhance page.
|
||
EnhanceCamera,
|
||
/// A Fill & Sign tool.
|
||
Fill(fill_sign::FillTool),
|
||
/// A Prepare a form field tool.
|
||
Field(prepare::FieldTool),
|
||
/// Redact text and images (drag across text or draw a box).
|
||
Redact,
|
||
/// Edit a PDF ▸ Add content ▸ Text.
|
||
AddText,
|
||
/// Edit a PDF ▸ Edit text: click a line of existing text to edit it.
|
||
EditText,
|
||
/// Add a stamp: click to place this stamp.
|
||
Stamp(pdfcraft_engine::StampKind),
|
||
/// A custom stamp from the library (its index).
|
||
CustomStamp(usize),
|
||
/// Edit a PDF ▸ Link: draw link areas, select and edit links.
|
||
Link,
|
||
/// Use a certificate ▸ Digitally sign / Certify (visible): drag the signature's rectangle.
|
||
SignArea {
|
||
certify: bool,
|
||
},
|
||
/// View ▸ Zoom ▸ Marquee Zoom.
|
||
MarqueeZoom,
|
||
/// Edit ▸ Take a Snapshot.
|
||
Snapshot,
|
||
/// Edit ▸ Column Select: drag a rectangle to select only the text inside it (#740). The
|
||
/// Select tool does the same while Alt/Option is held at the start of a drag.
|
||
ColumnSelect,
|
||
}
|
||
|
||
/// A file dropped on a page grid: its name, and its bytes or, for a file on disk, the path they are
|
||
/// read from when the file's turn to be inserted comes (so a long drop holds one file at a time).
|
||
type GridFile = (String, Result<Vec<u8>, std::path::PathBuf>);
|
||
|
||
/// 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))
|
||
}
|
||
|
||
/// Files dropped on a document's page grid or its Pages panel.
|
||
struct GridDrop {
|
||
doc: pdfcraft_engine::DocId,
|
||
/// Dropped on the Pages panel (its gap decides where they go), not the page grid.
|
||
panel: bool,
|
||
files: Vec<GridFile>,
|
||
/// When to stop waiting for the pointer (egui time, seconds).
|
||
deadline: f64,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Dialog {
|
||
WordCount,
|
||
CreateImages,
|
||
Properties(PropsTab),
|
||
About,
|
||
Shortcuts,
|
||
Split,
|
||
/// Pages ▸ Number pages… (page labels).
|
||
NumberPages,
|
||
/// Protect Using Password.
|
||
Protect,
|
||
/// Set Page Boxes (crop, trim, bleed, art, media).
|
||
PageBoxes,
|
||
/// Add / Update Header and Footer, Watermark, Background.
|
||
Marks(pdfcraft_engine::MarkKind),
|
||
/// Export a PDF ▸ Image / Text.
|
||
Export(export_ui::ExportKind),
|
||
/// Fill & Sign ▸ Create signature (the drawing pad).
|
||
Signature,
|
||
/// Comment Properties.
|
||
CommentProps,
|
||
/// Replace Pages (after choosing the file).
|
||
ReplacePages,
|
||
/// Prepare a form ▸ Field Properties.
|
||
FieldProps,
|
||
/// Shared properties of several selected form fields.
|
||
BulkFieldProps,
|
||
/// Redact a PDF ▸ Redact pages, Find text and redact, Set properties, apply confirmation.
|
||
RedactPages,
|
||
RedactSearch,
|
||
RedactProps,
|
||
RedactApply,
|
||
/// File ▸ Print.
|
||
Print,
|
||
/// File ▸ Revert confirmation.
|
||
Revert,
|
||
/// Save over a file that changed on disk since the document came from it (#2166).
|
||
OverwriteChanged,
|
||
/// Link Properties.
|
||
LinkProps,
|
||
/// Organize ▸ Extract (options), Rotate Pages.
|
||
Extract,
|
||
RotatePages,
|
||
/// Remove Hidden Information and Sanitize Document.
|
||
RemoveHidden,
|
||
Sanitize,
|
||
/// Documents from a session that ended unexpectedly.
|
||
Recovery,
|
||
/// Comments ▸ Summarize comments (options).
|
||
SummarizeComments,
|
||
/// Sign with a Digital ID ▸ Configure ▸ Sign as.
|
||
Sign,
|
||
/// Optimize PDF ▸ Advanced optimization.
|
||
Optimize,
|
||
/// Prepare a form ▸ right-click a field ▸ Duplicate.
|
||
DuplicateField,
|
||
/// Check for accessibility ▸ Accessibility Checker Options.
|
||
AccessibilityOptions,
|
||
/// Scan & OCR ▸ Recognize text.
|
||
RecognizeText,
|
||
/// The JavaScript console (⌘J).
|
||
JsConsole,
|
||
/// Document JavaScripts.
|
||
DocumentJs,
|
||
/// Preferences.
|
||
Preferences,
|
||
/// Compare files: choose the older version.
|
||
CompareFiles,
|
||
/// Action Wizard.
|
||
ActionWizard,
|
||
/// Standards ▸ PDF/A.
|
||
PdfA,
|
||
/// Custom stamps ▸ Create.
|
||
CreateStamp,
|
||
/// Prepare for accessibility ▸ Add alternate text.
|
||
AltText,
|
||
/// PDF Optimizer ▸ Audit space usage (then back to the optimizer).
|
||
AuditSpace,
|
||
/// Signatures ▸ Show certificate.
|
||
CertificateViewer,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum PropsTab {
|
||
Description,
|
||
InitialView,
|
||
Security,
|
||
Fonts,
|
||
Advanced,
|
||
}
|
||
|
||
/// Duplicate Field: the field and the pages (all, or a range, 1-based).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct DuplicateDraft {
|
||
pub name: String,
|
||
pub all: bool,
|
||
pub from: usize,
|
||
pub to: usize,
|
||
}
|
||
|
||
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PageClip {
|
||
/// The document the pages were copied from: pasting back into it keeps field names.
|
||
pub from: DocId,
|
||
pub name: String,
|
||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||
pub pages: Vec<usize>,
|
||
}
|
||
|
||
/// Files delivered asynchronously: (name, bytes).
|
||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||
|
||
/// Files that failed to arrive asynchronously (a browser `?file=` URL that couldn't be fetched):
|
||
/// `(name, error)`, reported to the user on the next frame (#173).
|
||
pub type FailedInbox = std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>;
|
||
|
||
/// A request the operating system sends the running app, outside its window: on macOS, Finder
|
||
/// double-clicks, Open With and drops on the Dock icon arrive as Apple events, not arguments.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum OsEvent {
|
||
/// Open these files.
|
||
Open(Vec<String>),
|
||
/// Quit (the Dock's Quit, logging out), asking about unsaved changes first.
|
||
Quit,
|
||
}
|
||
|
||
/// Returns the [`OsEvent`]s that arrived since it was last called (set by the desktop app).
|
||
pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
|
||
|
||
/// Hands a saved PDF to the page embedding PdfCraft (web): `(name, bytes, save_as)`. `save_as`
|
||
/// is `true` for Save As. Set by the web app when a host page (such as a Nextcloud app) asked
|
||
/// for saves; without it the browser downloads the file.
|
||
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
|
||
|
||
/// Told every frame whether any tab has unsaved work, so a host page can warn before leaving.
|
||
pub type HostDirtyFn = Box<dyn FnMut(bool)>;
|
||
|
||
pub struct PasswordPrompt {
|
||
pub name: String,
|
||
pub path: Option<String>,
|
||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
stamp: Option<file_watch::Stamp>,
|
||
pub input: String,
|
||
pub error: Option<String>,
|
||
/// A late startup file must stay in the background even after it is unlocked.
|
||
activate: bool,
|
||
/// The bytes are a recovery snapshot: once the document is open it takes the snapshot's
|
||
/// path and recovery entry. Cancelling the prompt drops the association with it (#813).
|
||
recovered: Option<RecoveryMeta>,
|
||
/// The tool that asked for this file (a tool chosen with no document open): it runs once
|
||
/// the document is unlocked.
|
||
then: Option<&'static str>,
|
||
/// A link destination to apply after the linked PDF is unlocked.
|
||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
struct PendingOpen {
|
||
token: u64,
|
||
receiver: std::sync::mpsc::Receiver<open_worker::ResultMessage>,
|
||
name: String,
|
||
path: Option<String>,
|
||
stamp: Option<file_watch::Stamp>,
|
||
activate: bool,
|
||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||
recovered: Option<RecoveryMeta>,
|
||
then: Option<&'static str>,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
struct OpenRequest {
|
||
name: String,
|
||
path: Option<String>,
|
||
stamp: Option<file_watch::Stamp>,
|
||
input: open_worker::Input,
|
||
password: Option<String>,
|
||
activate: bool,
|
||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||
recovered: Option<RecoveryMeta>,
|
||
then: Option<&'static str>,
|
||
}
|
||
|
||
/// Asks for the permissions password of an open document whose security restricts it, which
|
||
/// lifts the restrictions (#995). Acrobat asks for it when such a document's security is changed.
|
||
pub struct UnlockPrompt {
|
||
pub doc: DocId,
|
||
pub name: String,
|
||
pub input: String,
|
||
pub error: Option<String>,
|
||
/// The command to run once unlocked (Protect using password, Remove security), if any.
|
||
pub then: Option<&'static str>,
|
||
}
|
||
|
||
impl UnlockPrompt {
|
||
pub(crate) fn new(doc: &pdfcraft_engine::Document, then: Option<&'static str>) -> Self {
|
||
Self { doc: doc.id, name: doc.name.clone(), input: String::new(), error: None, then }
|
||
}
|
||
}
|
||
|
||
#[derive(serde::Serialize, serde::Deserialize)]
|
||
pub struct RecentFile {
|
||
pub name: String,
|
||
pub path: String,
|
||
pub pages: usize,
|
||
pub size: usize,
|
||
}
|
||
|
||
pub struct PdfCraftApp {
|
||
pub docking: docking::Workspace,
|
||
pub(crate) pending_panel_reveals: Vec<docking::Panel>,
|
||
pub session: Session,
|
||
pub views: Vec<DocView>,
|
||
pub(crate) closed_tabs: Vec<String>,
|
||
/// `None` shows the Home tab.
|
||
pub active: Option<usize>,
|
||
pub mode: Mode,
|
||
/// Workspace used for newly opened PDFs; independent of PDF Initial View metadata.
|
||
pub default_mode: Mode,
|
||
/// Page display and zoom for newly opened PDFs that don't ask for their own (Preferences ▸
|
||
/// Documents and view). Continuous scrolling at fit width by default, which never snaps
|
||
/// between pages.
|
||
pub view_defaults: canvas::ViewDefaults,
|
||
/// The unit page sizes and margins are shown in (Preferences ▸ Documents and view).
|
||
pub units: units::Unit,
|
||
/// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid
|
||
/// pattern (the session keeps the last valid one).
|
||
pub date_format_draft: Option<String>,
|
||
/// Explicit CLI/control mode lasts for this session and is never persisted.
|
||
mode_override: Option<Mode>,
|
||
pub left: LeftPanel,
|
||
pub left_open: bool,
|
||
pub right: Option<RightPanel>,
|
||
/// The right panel that was open when the user switched to Read mode, which starts with the
|
||
/// panes closed; leaving Read mode brings it back unless the user opened or closed a pane in
|
||
/// Read mode meanwhile.
|
||
right_before_read: Option<RightPanel>,
|
||
/// The user closed the Comments panel, so picking a comment tool leaves it closed until they
|
||
/// open it again (#225). Remembered across restarts.
|
||
pub comments_panel_closed: bool,
|
||
/// The user closed the right panel, so opening a document leaves it closed instead of
|
||
/// showing its comments or bookmarks (#1590). Remembered across restarts.
|
||
pub right_panel_closed: bool,
|
||
pub quick_tool: QuickTool,
|
||
/// The tool to go back to when Space, held for a temporary Hand, is released.
|
||
space_hand: Option<QuickTool>,
|
||
/// Comment author, per-tool colours and widths, pin.
|
||
pub comment_prefs: comments::CommentPrefs,
|
||
/// Resolved colours, including the current OS theme when following the system.
|
||
pub theme: ThemeKind,
|
||
pub theme_preference: ThemePreference,
|
||
/// The reading-colours preference (Preferences ▸ Reading colours): applied to every open
|
||
/// document, and documents open with it (Acrobat's Replace Document Colors).
|
||
pub reading_colors: pdfcraft_render::ReadingColors,
|
||
/// The Linux portal is enabled only by the native host, and starts on the first System choice.
|
||
#[cfg(target_os = "linux")]
|
||
desktop_theme: Option<pdfcraft_platform::desktop_theme::DesktopTheme>,
|
||
#[cfg(target_os = "linux")]
|
||
desktop_theme_enabled: bool,
|
||
/// How far a mouse-wheel notch scrolls.
|
||
pub scroll_speed: scroll_speed::ScrollSpeed,
|
||
/// Preferences ▸ Animation effects: on, off, or as the system's animation setting says.
|
||
pub animation_effects: motion::AnimationEffects,
|
||
/// Whether the system asked for no animations, and how the desktop app asks it.
|
||
pub motion: motion::Motion,
|
||
/// Interface language preference: `auto` (follow the system) or a code from [`i18n::LANGUAGES`].
|
||
pub language: String,
|
||
/// Preferences ▸ Interface size as a factor, or `None` to follow [`Self::system_text_scale`].
|
||
pub ui_scale: Option<f32>,
|
||
/// The desktop's text scaling (GNOME's text scaling factor), which the desktop app reads at
|
||
/// launch. 1.0 elsewhere, so tests and screenshots don't depend on the machine.
|
||
pub system_text_scale: f32,
|
||
/// The interface size last given to egui ([`ui_scale::sync`]).
|
||
ui_scale_applied: Option<f32>,
|
||
/// Preferences: bake Fill & Sign marks into the page when saving. Off, so a normal save stays editable.
|
||
pub flatten_fill_sign_on_save: bool,
|
||
/// Point size for new Fill & Sign text and dates, kept across launches.
|
||
pub fill_text_size: f64,
|
||
pub dialog: Option<Dialog>,
|
||
/// How to ask for the latest release (the desktop app sets it; see `updates`).
|
||
pub update_source: Option<updates::UpdateSource>,
|
||
pub(crate) updates: updates::Updates,
|
||
/// Lists the Print dialog's printers in the background (the desktop app sets it; see
|
||
/// [`PrinterSource`]).
|
||
pub printer_source: Option<PrinterSource>,
|
||
// A browser has no background print work: the type is empty there.
|
||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||
pub(crate) print_tasks: print_ui::PrintTasks,
|
||
pub palette_open: bool,
|
||
pub palette_query: String,
|
||
pub all_tools_expanded: bool,
|
||
pub recent: Vec<RecentFile>,
|
||
/// Preferences: reopen the files that were open when PdfCraft last closed (#442).
|
||
pub reopen_last_session: bool,
|
||
/// The files open when PdfCraft last closed, read from the settings for
|
||
/// [`PdfCraftApp::reopen_last_files`].
|
||
pub last_session: last_session::LastSession,
|
||
/// Quitting closes unsaved tabs one by one: what was open when the quit began.
|
||
quit_session: Option<last_session::LastSession>,
|
||
/// Preferences: reload a document when its file changes on disk (#431).
|
||
pub reload_changed_files: bool,
|
||
/// Open documents' files, watched for changes on disk.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(crate) watch: file_watch::FileWatch,
|
||
/// One native worker prepares opened documents without owning app or session state.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
open_worker: Option<open_worker::OpenWorker>,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pending_opens: Vec<PendingOpen>,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
next_open_token: u64,
|
||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||
open_prepare_gate: Option<std::sync::Arc<open_worker::PrepareGate>>,
|
||
/// Folders pinned to Home, and what they held when last listed.
|
||
pub pinned: folders_ui::PinnedFolders,
|
||
pub toast: Option<(String, f64)>,
|
||
/// Whether the macOS title bar is drawn by us (traffic lights over our tab strip).
|
||
pub integrated_titlebar: bool,
|
||
/// Set when the desktop leaves the window frame to the app (GNOME on Wayland): the tab strip
|
||
/// carries these window buttons and the window's edges resize it.
|
||
pub window_controls: Option<window_controls::Layout>,
|
||
/// Preferences ▸ Use system title bar: where `window_controls` is set, give the window back
|
||
/// winit's frame instead of drawing the header bar (#1001).
|
||
pub system_title_bar: bool,
|
||
/// Whether the window currently has the app's header bar (`None` until the first frame).
|
||
app_frame_shown: Option<bool>,
|
||
pub password_prompt: Option<PasswordPrompt>,
|
||
pub unlock_prompt: Option<UnlockPrompt>,
|
||
pub full_screen: bool,
|
||
/// Files delivered asynchronously (web drag-and-drop, web file picker).
|
||
pub inbox: Inbox,
|
||
/// The browser's startup `?file=` download. Unlike a deliberate Open or drop, a late
|
||
/// arrival opens in the background once the user has started working (#171).
|
||
pub startup_inbox: Inbox,
|
||
startup_superseded: bool,
|
||
/// Asynchronous opens that failed (web `?file=` fetches), shown as a notice.
|
||
pub failed_inbox: FailedInbox,
|
||
/// Mirrors "some document has unsaved work" each frame, so the web page's `beforeunload` handler can read it (#812).
|
||
pub unsaved_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||
/// Requests from the operating system, polled every frame (macOS Apple events).
|
||
pub os_events: Option<OsEventsFn>,
|
||
/// Save delivers to the embedding page instead of downloading (web, see [`HostSaveFn`]).
|
||
pub host_save: Option<HostSaveFn>,
|
||
/// Reports unsaved work to the embedding page (web, see [`HostDirtyFn`]).
|
||
pub host_dirty: Option<HostDirtyFn>,
|
||
/// A pending "save changes?" question (closing a dirty tab or quitting).
|
||
pub close_request: Option<CloseRequest>,
|
||
/// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub pending_overwrite: Option<editing::PendingOverwrite>,
|
||
/// Save to this path instead of asking (tests and automation).
|
||
pub save_override: Option<String>,
|
||
/// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords).
|
||
pub props_draft: Option<(DocId, [String; 4])>,
|
||
/// Document Properties ▸ Initial View (and reading options) being edited.
|
||
pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>,
|
||
/// Files picked asynchronously for combine / insert (web).
|
||
pub requests: files::Requests,
|
||
/// Native file pickers in flight (they never block the frame; see `pickers`).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pickers: pickers::Pickers,
|
||
/// Pick these files instead of showing a picker (tests and automation).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub pick_override: Option<Vec<String>>,
|
||
/// Write exported files (split) here instead of asking (tests and automation).
|
||
pub export_dir_override: Option<String>,
|
||
/// Split dialog settings.
|
||
pub split_draft: SplitDraft,
|
||
pub extract_draft: ExtractDraft,
|
||
pub rotate_draft: RotateDraft,
|
||
/// Summarize Comments: sort order.
|
||
pub summary_sort: pdfcraft_engine::SummarySort,
|
||
/// The signing dialogs' state.
|
||
pub sign_draft: Option<SignDraft>,
|
||
/// Digital ID files the user has created or added.
|
||
pub digital_ids: Vec<DigitalIdEntry>,
|
||
/// Signatures panel: expanded entries (field names).
|
||
pub sig_expanded: Vec<String>,
|
||
/// Accessibility Checker: options, the last check, and rules skipped by hand.
|
||
pub a11y_options: a11y_ui::A11yOptions,
|
||
/// Scan & OCR: the Recognize Text choices, the running job, and (tests) run it inline.
|
||
pub ocr_draft: ocr_ui::OcrDraft,
|
||
pub ocr_run: Option<ocr_ui::OcrRun>,
|
||
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
|
||
/// Background jobs (OCR, actions, listing pinned folders) run inline instead (tests).
|
||
pub run_inline: bool,
|
||
/// Action Wizard: the user's actions, the dialog state, the running action and (tests) the
|
||
/// files to use instead of a picker.
|
||
pub custom_actions: Vec<pdfcraft_engine::actions::Action>,
|
||
pub wizard: actions_ui::Wizard,
|
||
pub action_run: Option<std::sync::Arc<std::sync::Mutex<actions_ui::RunProgress>>>,
|
||
pub action_files_override: Option<Vec<String>>,
|
||
/// Standards ▸ PDF/A: level and last result.
|
||
pub pdfa: standards_ui::PdfaState,
|
||
/// Compare files: the chosen older document and the last result.
|
||
pub compare_old: Option<DocId>,
|
||
pub compare: Option<compare_ui::CompareState>,
|
||
/// The JavaScript console and the Document JavaScripts draft.
|
||
pub js_console: js_ui::JsConsole,
|
||
pub doc_js: js_ui::DocJsDraft,
|
||
pub a11y: a11y_ui::A11yState,
|
||
pub(crate) tags_state: tags_ui::State,
|
||
/// Rules skipped by hand, for the document they were skipped in.
|
||
pub a11y_skipped: std::collections::HashSet<(DocId, pdfcraft_engine::a11y::Rule)>,
|
||
pub alt_draft: a11y_ui::AltDraft,
|
||
/// List the OS key store's signing identities among the digital IDs (the desktop app).
|
||
pub os_key_store_ids: bool,
|
||
pub cert_viewer: Option<sign_ui::CertViewer>,
|
||
/// The last space audit.
|
||
pub space_audit: Vec<pdfcraft_engine::optimize::SpaceUse>,
|
||
/// Combine files: the files staged so far.
|
||
pub combine_draft: Vec<combine_ui::CombineFile>,
|
||
/// The combine list's field-name conflict preview (computed once per list, never saved).
|
||
pub combine_conflicts: combine_ui::ConflictPreview,
|
||
/// The Combine files tab: whether it is open, shown, its selection and undo history.
|
||
pub combine_tab: combine_ui::CombineTab,
|
||
/// The Combine files table's column order and widths (kept in the settings).
|
||
pub combine_columns: combine_ui::Columns,
|
||
/// Combine files as thumbnails or as the table (kept in the settings).
|
||
pub combine_view: combine_ui::CombineView,
|
||
/// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set
|
||
/// with [`PdfCraftApp::set_combine_zoom`].
|
||
pub combine_zoom: f32,
|
||
/// The Combine grid's thumbnails: rendered off the UI thread, a few at a time.
|
||
pub(crate) combine_thumbs: combine_grid::Thumbs,
|
||
/// Images waiting for the resolution choice (released on cancel).
|
||
pub image_import: Option<create_ui::ImageImport>,
|
||
/// The custom stamp library, and the stamp being created.
|
||
pub custom_stamps: Vec<stamps_ui::CustomStamp>,
|
||
pub stamp_draft: stamps_ui::StampDraft,
|
||
/// PDF Optimizer choices.
|
||
pub optimize_draft: OptimizeDraft,
|
||
/// The running optimization (Optimize PDF ▸ Advanced optimization).
|
||
pub optimize_run: Option<optimize_ui::OptimizeRun>,
|
||
/// A background job's progress card (see [`widgets::progress_notice`]).
|
||
pub progress_notice: Option<widgets::ProgressNotice>,
|
||
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
|
||
pub page_clipboard: Option<PageClip>,
|
||
/// Files dropped on the page grid, waiting for the pointer to say which gap they go to.
|
||
grid_drop: Option<GridDrop>,
|
||
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
|
||
/// system clipboard (tests turn that off).
|
||
pub last_snapshot: Option<(u32, u32, pdfcraft_render::Pixels)>,
|
||
/// The last image copied with Copy Image (width, height, straight RGBA), also put on the
|
||
/// system clipboard when `system_clipboard` is on.
|
||
pub last_copied_image: Option<(u32, u32, Vec<u8>)>,
|
||
/// The last image copied in its original format (file name, bytes), also put on the
|
||
/// system clipboard as a file when `system_clipboard` is on.
|
||
pub last_copied_file: Option<(String, Vec<u8>)>,
|
||
pub system_clipboard: bool,
|
||
/// Attach file: the file to attach instead of asking (tests, automation).
|
||
pub attach_override: Option<(String, Vec<u8>)>,
|
||
/// Duplicate Field: which field and onto which pages.
|
||
pub duplicate_draft: Option<DuplicateDraft>,
|
||
/// Prepare a form ▸ Preview: fill the form instead of editing its fields.
|
||
pub form_preview: bool,
|
||
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
|
||
pub recovery: Option<RecoveryStore>,
|
||
/// Entries left by a previous session, offered in the Recovery dialog.
|
||
pub recoverable: Vec<RecoveryMeta>,
|
||
recovery_keys: std::collections::HashMap<DocId, String>,
|
||
last_autosave: f64,
|
||
allow_quit: bool,
|
||
/// Shortcuts pressed while a text field had the keyboard, run on the next frame (see
|
||
/// `registry_shortcuts`).
|
||
deferred_commands: Vec<(&'static str, Option<DocId>)>,
|
||
/// The dialog seen at the last check, and a counter bumped whenever it changes (see
|
||
/// [`Self::dialog_epoch`]).
|
||
dialog_seen: Option<Dialog>,
|
||
dialog_epoch: u64,
|
||
/// The egui context, for commands that change window or theme state.
|
||
ctx: Option<egui::Context>,
|
||
styled: bool,
|
||
fonts_ready: bool,
|
||
/// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`).
|
||
fonts_cjk: theme::CjkPreference,
|
||
/// The window title last sent to the platform.
|
||
pub window_title: String,
|
||
/// The UI control channel, when enabled (`--control`; off by default).
|
||
control: Option<control::Control>,
|
||
/// A bookmark being renamed in the Bookmarks panel: (path, text so far).
|
||
pub bookmark_rename: Option<(Vec<usize>, String)>,
|
||
/// Number pages dialog settings (1-based pages).
|
||
pub number_draft: NumberDraft,
|
||
pub(crate) word_count_draft: word_count_ui::Draft,
|
||
/// Protect Using Password dialog state.
|
||
pub protect_draft: protect::ProtectDraft,
|
||
/// Set Page Boxes dialog state.
|
||
pub boxes_draft: pageboxes::BoxesDraft,
|
||
/// Header & footer / watermark / background dialog state.
|
||
pub marks_draft: marks_ui::MarksDraft,
|
||
/// Export dialog settings.
|
||
pub export_draft: export_ui::ExportDraft,
|
||
/// A running export's progress.
|
||
export_status: Option<export_ui::ExportStatus>,
|
||
/// The saved Fill & Sign signature and initials (drawn or typed).
|
||
pub signature: Option<fill_sign::SavedSig>,
|
||
pub initials: Option<fill_sign::SavedSig>,
|
||
/// The Create signature / initials dialog, and its typed preview.
|
||
pub signature_draft: fill_sign::SigDraft,
|
||
pub(crate) signature_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>,
|
||
/// The Scan tab's photo, keyed by its version.
|
||
pub(crate) signature_photo: Option<(u64, egui::TextureHandle)>,
|
||
pub(crate) saved_signature_previews: [Option<(fill_sign::SavedSig, egui::TextureHandle)>; 2],
|
||
/// What visible digital signatures show on their left, and its preview in Sign as.
|
||
pub sign_graphic: sign_ui::SignGraphic,
|
||
pub(crate) sign_graphic_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>,
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub(crate) signature_images: fill_sign::ImageInbox,
|
||
/// The Comment Properties dialog's state.
|
||
pub comment_props: Option<comment_props::PropsDraft>,
|
||
pub field_props: Option<prepare::FieldDraft>,
|
||
pub bulk_field_props: Option<bulk_fields::Draft>,
|
||
pub redact_prefs: RedactPrefs,
|
||
/// The Redaction Tool Properties kept for the next run (what the last OK with "Make
|
||
/// properties default" ticked left), and the values before the dialog opened, for Cancel.
|
||
pub redact_defaults: RedactPrefs,
|
||
pub redact_before: Option<RedactPrefs>,
|
||
pub redact_pages_draft: RedactPagesDraft,
|
||
pub redact_search: RedactSearchDraft,
|
||
pub hidden_draft: HiddenDraft,
|
||
pub print_draft: PrintDraft,
|
||
pub link_draft: Option<LinkDraft>,
|
||
/// The style new text gets (Edit a PDF ▸ Format text).
|
||
pub text_style: pdfcraft_engine::AddedText,
|
||
/// The Replace Pages dialog's state.
|
||
pub replace_draft: Option<files::ReplaceDraft>,
|
||
/// The last web link the app asked the system to open (tests and automation).
|
||
pub last_opened_url: Option<String>,
|
||
/// A document asked to open this address; the user hasn't answered yet (#90, #91).
|
||
pub pending_link: Option<PendingLink>,
|
||
/// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`):
|
||
/// the person is asked first, as in Acrobat, and this is the document waiting for the answer.
|
||
pub pending_full_screen: Option<DocId>,
|
||
/// An absolute GoToR target waiting for approval, before any target-file I/O (#1364).
|
||
pub pending_remote_link: Option<PendingRemoteLink>,
|
||
}
|
||
|
||
/// Where in a document a request to open an address came from.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum LinkOrigin {
|
||
/// A link on the page.
|
||
Link,
|
||
/// A push button's URI action.
|
||
Button,
|
||
/// A script (`app.launchURL`).
|
||
Script,
|
||
}
|
||
|
||
impl LinkOrigin {
|
||
fn noun(self) -> &'static str {
|
||
match self {
|
||
Self::Link => "A link",
|
||
Self::Button => "A button",
|
||
Self::Script => "A script",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An address a document asked to open, waiting for the user to allow or cancel it.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct PendingLink {
|
||
pub url: String,
|
||
pub origin: LinkOrigin,
|
||
}
|
||
|
||
/// A resolved local target and its original source/destination, independent of the active tab.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PendingRemoteLink {
|
||
pub source: DocId,
|
||
pub path: std::path::PathBuf,
|
||
pub destination: pdfcraft_render::RemoteDestination,
|
||
}
|
||
|
||
/// Settings for the Number pages dialog.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct NumberDraft {
|
||
pub from: usize,
|
||
pub to: usize,
|
||
pub style: pdfcraft_engine::LabelStyle,
|
||
pub prefix: String,
|
||
pub start: u32,
|
||
}
|
||
|
||
impl Default for PdfCraftApp {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
/// 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 new() -> Self {
|
||
Self {
|
||
session: Session::new(),
|
||
views: Vec::new(),
|
||
closed_tabs: Vec::new(),
|
||
active: None,
|
||
mode: Mode::AllTools,
|
||
default_mode: Mode::AllTools,
|
||
view_defaults: Default::default(),
|
||
units: Default::default(),
|
||
date_format_draft: None,
|
||
mode_override: None,
|
||
left: LeftPanel::AllTools,
|
||
docking: docking::Workspace::default(),
|
||
pending_panel_reveals: Vec::new(),
|
||
left_open: true,
|
||
right: None,
|
||
right_before_read: None,
|
||
comments_panel_closed: false,
|
||
right_panel_closed: false,
|
||
quick_tool: QuickTool::Select,
|
||
space_hand: None,
|
||
comment_prefs: Default::default(),
|
||
theme: ThemeKind::Light,
|
||
theme_preference: ThemePreference::Light,
|
||
reading_colors: Default::default(),
|
||
#[cfg(target_os = "linux")]
|
||
desktop_theme: None,
|
||
#[cfg(target_os = "linux")]
|
||
desktop_theme_enabled: false,
|
||
scroll_speed: scroll_speed::ScrollSpeed::default(),
|
||
animation_effects: motion::AnimationEffects::default(),
|
||
motion: motion::Motion::default(),
|
||
language: i18n::AUTO.to_string(),
|
||
ui_scale: None,
|
||
system_text_scale: 1.0,
|
||
ui_scale_applied: None,
|
||
flatten_fill_sign_on_save: false,
|
||
fill_text_size: fill_sign::TEXT_SIZE,
|
||
dialog: None,
|
||
update_source: None,
|
||
updates: updates::Updates::default(),
|
||
printer_source: None,
|
||
print_tasks: print_ui::PrintTasks::default(),
|
||
palette_open: false,
|
||
palette_query: String::new(),
|
||
all_tools_expanded: false,
|
||
recent: Vec::new(),
|
||
reopen_last_session: false,
|
||
last_session: Default::default(),
|
||
quit_session: None,
|
||
reload_changed_files: true,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
watch: Default::default(),
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
open_worker: None,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pending_opens: Vec::new(),
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
next_open_token: 0,
|
||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||
open_prepare_gate: None,
|
||
pinned: Default::default(),
|
||
toast: None,
|
||
integrated_titlebar: false,
|
||
window_controls: None,
|
||
system_title_bar: false,
|
||
app_frame_shown: None,
|
||
password_prompt: None,
|
||
unlock_prompt: None,
|
||
full_screen: false,
|
||
inbox: Default::default(),
|
||
startup_inbox: Default::default(),
|
||
startup_superseded: false,
|
||
failed_inbox: Default::default(),
|
||
unsaved_flag: Default::default(),
|
||
os_events: None,
|
||
host_save: None,
|
||
host_dirty: None,
|
||
close_request: None,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pending_overwrite: None,
|
||
save_override: None,
|
||
props_draft: None,
|
||
view_draft: None,
|
||
requests: Default::default(),
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pickers: Default::default(),
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pick_override: None,
|
||
export_dir_override: None,
|
||
split_draft: SplitDraft::default(),
|
||
extract_draft: ExtractDraft::default(),
|
||
rotate_draft: RotateDraft::default(),
|
||
summary_sort: Default::default(),
|
||
sign_draft: None,
|
||
digital_ids: Vec::new(),
|
||
sig_expanded: Vec::new(),
|
||
a11y_options: a11y_ui::A11yOptions::default(),
|
||
ocr_draft: ocr_ui::OcrDraft::default(),
|
||
ocr_run: None,
|
||
ocr_batch: None,
|
||
run_inline: false,
|
||
custom_actions: Vec::new(),
|
||
wizard: Default::default(),
|
||
action_run: None,
|
||
action_files_override: None,
|
||
pdfa: Default::default(),
|
||
compare_old: None,
|
||
compare: None,
|
||
js_console: Default::default(),
|
||
doc_js: Default::default(),
|
||
a11y: a11y_ui::A11yState::default(),
|
||
tags_state: Default::default(),
|
||
a11y_skipped: Default::default(),
|
||
alt_draft: Default::default(),
|
||
os_key_store_ids: false,
|
||
cert_viewer: None,
|
||
space_audit: Vec::new(),
|
||
combine_draft: Vec::new(),
|
||
combine_conflicts: Default::default(),
|
||
combine_tab: Default::default(),
|
||
combine_columns: Default::default(),
|
||
combine_view: Default::default(),
|
||
combine_zoom: 1.0,
|
||
combine_thumbs: Default::default(),
|
||
image_import: None,
|
||
custom_stamps: Vec::new(),
|
||
stamp_draft: Default::default(),
|
||
optimize_draft: OptimizeDraft::default(),
|
||
optimize_run: None,
|
||
progress_notice: None,
|
||
page_clipboard: None,
|
||
grid_drop: None,
|
||
last_snapshot: None,
|
||
last_copied_image: None,
|
||
last_copied_file: None,
|
||
system_clipboard: true,
|
||
attach_override: None,
|
||
duplicate_draft: None,
|
||
form_preview: false,
|
||
window_title: String::new(),
|
||
recovery: None,
|
||
recoverable: Vec::new(),
|
||
recovery_keys: Default::default(),
|
||
last_autosave: 0.0,
|
||
allow_quit: false,
|
||
deferred_commands: Vec::new(),
|
||
dialog_seen: None,
|
||
dialog_epoch: 0,
|
||
ctx: None,
|
||
styled: false,
|
||
fonts_ready: false,
|
||
fonts_cjk: theme::CjkPreference::Default,
|
||
control: None,
|
||
bookmark_rename: None,
|
||
last_opened_url: None,
|
||
pending_link: None,
|
||
pending_full_screen: None,
|
||
pending_remote_link: None,
|
||
protect_draft: Default::default(),
|
||
boxes_draft: Default::default(),
|
||
marks_draft: Default::default(),
|
||
export_draft: Default::default(),
|
||
export_status: None,
|
||
signature: None,
|
||
initials: None,
|
||
signature_draft: Default::default(),
|
||
signature_preview: None,
|
||
signature_photo: None,
|
||
saved_signature_previews: [None, None],
|
||
sign_graphic: sign_ui::SignGraphic::Name,
|
||
sign_graphic_preview: None,
|
||
#[cfg(target_arch = "wasm32")]
|
||
signature_images: Default::default(),
|
||
comment_props: None,
|
||
field_props: None,
|
||
bulk_field_props: None,
|
||
redact_prefs: RedactPrefs::default(),
|
||
redact_defaults: RedactPrefs::default(),
|
||
redact_before: None,
|
||
redact_pages_draft: RedactPagesDraft::default(),
|
||
redact_search: RedactSearchDraft::default(),
|
||
hidden_draft: HiddenDraft::default(),
|
||
print_draft: PrintDraft::default(),
|
||
link_draft: None,
|
||
text_style: content_ui::default_style(),
|
||
replace_draft: None,
|
||
number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 },
|
||
word_count_draft: word_count_ui::Draft::default(),
|
||
}
|
||
}
|
||
|
||
/// Fields are being edited (Prepare a form is open or a field tool is picked), unless the
|
||
/// form is being previewed.
|
||
pub fn is_preparing(&self) -> bool {
|
||
((self.left_open && self.left == LeftPanel::Tool("form")) || matches!(self.quick_tool, QuickTool::Field(_))) && !self.form_preview
|
||
}
|
||
|
||
/// Open a document the way it asks to be opened: navigation panel, layout, magnification,
|
||
/// page (Document Properties ▸ Initial View).
|
||
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());
|
||
if self.active == Some(index) {
|
||
match v.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];
|
||
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,
|
||
}
|
||
if v.page > 0 && v.page < pages {
|
||
view.go_to_page(v.page);
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
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"))]
|
||
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"))]
|
||
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);
|
||
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)
|
||
} else if !doc.info.outline.is_empty() {
|
||
Some(RightPanel::Bookmarks)
|
||
} else {
|
||
None
|
||
};
|
||
}
|
||
let initial = doc.initial_view();
|
||
self.views.push(DocView::new(id, &doc.info, self.view_defaults));
|
||
if activate {
|
||
self.active = Some(self.views.len() - 1);
|
||
}
|
||
self.apply_initial_view(self.views.len() - 1, &initial);
|
||
// 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();
|
||
}
|
||
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 {
|
||
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)
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
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"))]
|
||
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: ctx.input(|i| i.time) + 1.0 });
|
||
}
|
||
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.
|
||
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 ctx.input(|i| i.time) >= 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")]
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// A number that changes whenever the open dialog changes (opened, closed or replaced by
|
||
/// another), checked every frame. A picker started from a dialog's Browse… button answers
|
||
/// only into the same showing of that dialog.
|
||
pub(crate) fn dialog_epoch(&mut self) -> u64 {
|
||
if self.dialog != self.dialog_seen {
|
||
self.dialog_seen = self.dialog;
|
||
self.dialog_epoch = self.dialog_epoch.wrapping_add(1);
|
||
}
|
||
self.dialog_epoch
|
||
}
|
||
|
||
/// 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) => {
|
||
if let Err(e) = self.open_bytes(&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())]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
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.full_screen = on;
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
|
||
/// 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(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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn open_path(&mut self, path: &str) {
|
||
self.open_path_then(path, None);
|
||
}
|
||
|
||
/// Keep a chosen tool with its file until preparation and any password entry finish.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn open_path_then(&mut self, path: &str, then: Option<&'static str>) {
|
||
// 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.active = Some(i);
|
||
if let Some(command) = then {
|
||
self.execute(command);
|
||
}
|
||
return;
|
||
}
|
||
if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
|
||
return;
|
||
}
|
||
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)]);
|
||
} else if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]),
|
||
}
|
||
return;
|
||
}
|
||
self.startup_superseded = true;
|
||
if let Err(error) = 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,
|
||
}) {
|
||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||
}
|
||
}
|
||
|
||
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
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;
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
pub fn active_ids(&self) -> Option<(usize, DocId)> {
|
||
self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id)))
|
||
}
|
||
|
||
/// A dialog or prompt is open over the window: the keyboard is its own, and nothing may act
|
||
/// on the document underneath it.
|
||
pub(crate) fn modal_open(&self) -> bool {
|
||
self.dialog.is_some()
|
||
|| self.close_request.is_some()
|
||
|| self.password_prompt.is_some()
|
||
|| self.unlock_prompt.is_some()
|
||
|| self.pending_link.is_some()
|
||
|| self.pending_remote_link.is_some()
|
||
|| self.updates.open
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
|
||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||
self.toast = Some((msg.into(), 0.0));
|
||
}
|
||
|
||
/// Notify with a static message in the UI language.
|
||
pub fn notify_tr(&mut self, text: &str) {
|
||
self.notify(tl!(text).to_string());
|
||
}
|
||
|
||
/// Notify with an error's own text. Engine and OS messages are not translated: matching their
|
||
/// English wording would break silently whenever it changes.
|
||
pub fn notify_error(&mut self, error: impl std::fmt::Display) {
|
||
self.notify(error.to_string());
|
||
}
|
||
|
||
/// Notify with a `{name}`-style template in the UI language (placeholders filled once).
|
||
pub fn notify_fmt(&mut self, template: &str, args: &[(&str, &str)]) {
|
||
self.notify(i18n::fmt(tl!(template), args));
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// Draw the running job's progress card and pass a Cancel click on to the job.
|
||
fn show_progress(&mut self, ctx: &egui::Context) {
|
||
if widgets::progress_notice(self, ctx) {
|
||
self.cancel_optimize();
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
pub(crate) fn reveal_tools(&mut self) {
|
||
self.left_open = true;
|
||
self.pending_panel_reveals.push(docking::Panel::Tools);
|
||
}
|
||
|
||
/// 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) {
|
||
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. 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.
|
||
pub(crate) fn select_mode(&mut self, mode: Mode) {
|
||
self.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,
|
||
};
|
||
if mode == Mode::Edit {
|
||
self.quick_tool = QuickTool::EditText;
|
||
} else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) {
|
||
self.put_down_edit_text();
|
||
}
|
||
}
|
||
|
||
/// 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).
|
||
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.mode = mode;
|
||
}
|
||
if mode == Mode::Read && !was_read {
|
||
self.right_before_read = self.right.take();
|
||
} else if mode != Mode::Read && was_read {
|
||
let before = self.right_before_read.take();
|
||
if self.right.is_none() {
|
||
self.right = before;
|
||
}
|
||
if self.right.is_some() {
|
||
self.pending_panel_reveals.push(docking::Panel::Inspector);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 panel.is_some() {
|
||
self.pending_panel_reveals.push(docking::Panel::Inspector);
|
||
}
|
||
}
|
||
|
||
/// 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!({
|
||
"docking": self.docking,
|
||
"docking_left_open": self.left_open,
|
||
"docking_right": self.right,
|
||
"recent": self.recent,
|
||
"reopen_last_session": self.reopen_last_session,
|
||
// 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(),
|
||
"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["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.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 };
|
||
if let Some(saved) = v.get("docking")
|
||
&& let Ok(workspace) = serde_json::from_value::<docking::Workspace>(saved.clone())
|
||
&& workspace.validate().is_ok()
|
||
{
|
||
self.docking = workspace;
|
||
self.left_open =
|
||
v.get("docking_left_open").and_then(serde_json::Value::as_bool).unwrap_or(self.docking.layout.contains(&docking::Panel::Tools));
|
||
self.right = v.get("docking_right").and_then(|value| serde_json::from_value(value.clone()).ok()).flatten();
|
||
}
|
||
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);
|
||
}
|
||
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;
|
||
}
|
||
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;
|
||
}
|
||
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 Ok(units) = serde_json::from_value::<units::Unit>(v["units"].clone()) {
|
||
self.units = units;
|
||
}
|
||
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;
|
||
}
|
||
// 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.session.set_trusted_certificates(certs);
|
||
}
|
||
}
|
||
|
||
/// `true` while any open document still waits for page renders (used by headless capture).
|
||
pub fn render_pending(&self) -> bool {
|
||
self.views.iter().any(|v| v.render_pending())
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
("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,
|
||
("dialog", _) => {
|
||
self.dialog = match value {
|
||
"properties" => Some(Dialog::Properties(PropsTab::Description)),
|
||
"security" => Some(Dialog::Properties(PropsTab::Security)),
|
||
"fonts" => Some(Dialog::Properties(PropsTab::Fonts)),
|
||
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
|
||
"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", _) => 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(value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())? / 100.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()),
|
||
},
|
||
("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")?;
|
||
}
|
||
("tools-side", _) => self.docking.set_tools_on_right(match value {
|
||
"left" => false,
|
||
"right" => true,
|
||
_ => return Err("tools-side must be left or right".into()),
|
||
})?,
|
||
("organize", Some(v)) => {
|
||
if value == "off" {
|
||
v.organize = false;
|
||
} else {
|
||
v.open_organize();
|
||
}
|
||
}
|
||
// `--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);
|
||
}
|
||
("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(),
|
||
("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)?,
|
||
("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)) {
|
||
return Err("combine-zoom must be between 60 and 200".into());
|
||
}
|
||
self.set_combine_zoom(percent / 100.0);
|
||
}
|
||
(k, None)
|
||
if [
|
||
"page",
|
||
"zoom",
|
||
"layout",
|
||
"continuous",
|
||
"cover",
|
||
"organize",
|
||
"grid-zoom",
|
||
"fields",
|
||
"find",
|
||
"rotate",
|
||
"select",
|
||
"notice",
|
||
"comment",
|
||
]
|
||
.contains(&k) =>
|
||
{
|
||
return Err(format!("`{k}` needs an open document"));
|
||
}
|
||
(other, _) => return Err(format!("unknown option {other}")),
|
||
}
|
||
if key == "panel" && self.right.is_some() {
|
||
self.pending_panel_reveals.push(docking::Panel::Inspector);
|
||
}
|
||
if key == "left" && self.left_open {
|
||
self.pending_panel_reveals.push(docking::Panel::Tools);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn shortcuts(&mut self, ctx: &egui::Context) {
|
||
use egui::Key;
|
||
// "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() {
|
||
if let Some(choice) = dialogs::save_prompt_key(ctx) {
|
||
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;
|
||
}
|
||
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);
|
||
}
|
||
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
|
||
self.copy_key(i, ctx);
|
||
self.image_pick_keys(i, ctx);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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")
|
||
}
|
||
|
||
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).
|
||
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.
|
||
pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool {
|
||
let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))).filter(|(_, text)| !text.is_empty()) else {
|
||
return false;
|
||
};
|
||
if !self.may_copy(id) {
|
||
return false;
|
||
}
|
||
ctx.copy_text(text);
|
||
true
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl eframe::App for PdfCraftApp {
|
||
/// While files are dragged over the window the system sends no pointer moves, so egui
|
||
/// would keep the place the pointer entered at: tell it where the pointer really is, so the
|
||
/// page grid can show (and use) the gap under it.
|
||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||
// Before egui reads this frame's wheel input.
|
||
self.scroll_speed.apply(ctx, raw);
|
||
if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() {
|
||
return;
|
||
}
|
||
if let Some(pos) = drag_pointer::in_window(ctx) {
|
||
raw.events.push(egui::Event::PointerMoved(pos));
|
||
}
|
||
ctx.request_repaint();
|
||
}
|
||
|
||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||
storage.set_string("pdfcraft", self.persist());
|
||
}
|
||
|
||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||
self.ctx = Some(ctx.clone());
|
||
self.dialog_epoch();
|
||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||
let lang = i18n::Lang::from_pref(&self.language);
|
||
i18n::set_current(lang);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
if let Some(worker) = &self.open_worker {
|
||
worker.set_context(ctx);
|
||
}
|
||
self.poll_pending_opens();
|
||
}
|
||
// Chinese wants its own faces before the Japanese ones (one baseline per line).
|
||
let cjk = theme::CjkPreference::for_language(lang.code());
|
||
if !self.styled {
|
||
egui_extras::install_image_loaders(ctx);
|
||
theme::install_fonts_for(ctx, cjk);
|
||
self.fonts_cjk = cjk;
|
||
theme::apply(ctx, self.theme);
|
||
self.styled = true;
|
||
} else {
|
||
self.fonts_ready = true;
|
||
if cjk != self.fonts_cjk {
|
||
// Same family names as before, so named fonts stay valid while the new set loads.
|
||
theme::install_fonts_for(ctx, cjk);
|
||
self.fonts_cjk = cjk;
|
||
}
|
||
}
|
||
self.sync_theme(ctx);
|
||
let native = ctx.native_pixels_per_point().unwrap_or(1.0);
|
||
ui_scale::sync(ctx, ui_scale::zoom(self.ui_scale, self.system_text_scale, native), &mut self.ui_scale_applied);
|
||
// Remember user work even if its tab is subsequently closed or Home is selected.
|
||
self.startup_superseded |= !self.views.is_empty() || self.combine_showing() || self.dialog.is_some() || self.password_prompt.is_some();
|
||
// Also done in `raw_input_hook`, before input; here for hosts that don't call the hook.
|
||
self.scroll_speed.set_line_speed(ctx);
|
||
// Before anything reads this frame's wheel input or draws, and with the line speed just set.
|
||
self.motion.begin_pass(ctx, self.animation_effects);
|
||
// Before taking this frame's drop: the grid must be drawn once with the pointer where
|
||
// the files were let go before the gap is read.
|
||
self.finish_grid_drop(ctx);
|
||
// Showing a document hides the Combine files tab.
|
||
if self.active.is_some() {
|
||
self.combine_tab.focused = false;
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
self.process_signature_images();
|
||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let dropped = self.drop_on_signature_pad(dropped);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let dropped = self.drop_on_grid(dropped, ctx);
|
||
for f in dropped {
|
||
// Files dropped on the Combine files tab join its list instead of opening.
|
||
if self.combine_showing() {
|
||
self.drop_into_combine(f, ctx);
|
||
} else {
|
||
self.open_dropped(f, ctx);
|
||
}
|
||
}
|
||
let arrived: Vec<(String, Vec<u8>)> = self.inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
|
||
for (name, bytes) in arrived {
|
||
if let Err(e) = self.open_bytes(&name, None, bytes) {
|
||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||
}
|
||
}
|
||
// Don't replace a pending prompt or import. A startup file may need its own password
|
||
// prompt, so its bytes stay queued until the current interaction finishes.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let opening = !self.pending_opens.is_empty();
|
||
#[cfg(target_arch = "wasm32")]
|
||
let opening = false;
|
||
if !opening
|
||
&& self.password_prompt.is_none()
|
||
&& self.image_import.is_none()
|
||
&& self.dialog.is_none()
|
||
&& self.close_request.is_none()
|
||
&& self.pending_link.is_none()
|
||
&& self.pending_remote_link.is_none()
|
||
{
|
||
let startup = self.startup_inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
|
||
for (name, bytes) in startup {
|
||
let activate = !self.startup_superseded;
|
||
if let Err(e) = self.open_bytes_with_focus(&name, None, bytes, activate) {
|
||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e)]);
|
||
}
|
||
}
|
||
}
|
||
let failed: Vec<(String, String)> = self.failed_inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
|
||
for (name, e) in failed {
|
||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e)]);
|
||
}
|
||
let os_events = self.os_events.as_mut().map(|poll| poll()).unwrap_or_default();
|
||
for e in os_events {
|
||
match e {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
OsEvent::Open(paths) => paths.iter().for_each(|p| self.open_path(p)),
|
||
#[cfg(target_arch = "wasm32")]
|
||
OsEvent::Open(_) => {}
|
||
// Like closing the window: `guard_quit` asks about unsaved changes.
|
||
OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||
}
|
||
}
|
||
if let Some((_, id)) = self.active_ids() {
|
||
let out = self.session.take_js_output(id);
|
||
self.handle_js(id, out);
|
||
}
|
||
if let Some(mut control) = self.control.take() {
|
||
control.tick(ctx, self);
|
||
self.control = Some(control);
|
||
}
|
||
self.guard_quit(ctx);
|
||
// macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136).
|
||
craft_quit::on_close_request(ctx);
|
||
let now = ctx.input(|i| i.time);
|
||
self.autosave_tick(now);
|
||
// Reload documents whose files a build rewrote (#431).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
self.watch_files();
|
||
if self.host_dirty.is_some() {
|
||
let dirty = self.first_dirty().is_some();
|
||
if let Some(report) = self.host_dirty.as_mut() {
|
||
report(dirty);
|
||
}
|
||
}
|
||
self.poll_updates();
|
||
self.poll_print();
|
||
// Shortcuts deferred last frame: the text field has taken that frame's typing since.
|
||
let deferred = std::mem::take(&mut self.deferred_commands);
|
||
self.shortcuts(ctx);
|
||
// Scrolling and group-drag previews are transient: never resume after changing tabs,
|
||
// opening a modal/palette, or returning to a window that lost focus.
|
||
let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused);
|
||
for (index, view) in self.views.iter_mut().enumerate() {
|
||
view.objects.set_input_blocked(blocked || self.active != Some(index));
|
||
if blocked || self.active != Some(index) {
|
||
view.auto_scroll.cancel();
|
||
}
|
||
}
|
||
// A field that refused its value this frame keeps the shortcut from saving or printing
|
||
// behind the user's back; so does a save prompt opened since.
|
||
if self.process_pending_edits() && self.close_request.is_none() {
|
||
for (id, doc) in deferred {
|
||
// Only in the document the shortcut was pressed in.
|
||
if self.active_ids().map(|(_, active)| active) == doc {
|
||
self.execute(id);
|
||
}
|
||
}
|
||
}
|
||
self.poll_export();
|
||
self.poll_ocr();
|
||
self.poll_optimize();
|
||
self.poll_action();
|
||
self.process_file_requests();
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
self.process_picked();
|
||
}
|
||
|
||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||
let ctx = ui.ctx().clone();
|
||
i18n::set_current(i18n::Lang::from_pref(&self.language));
|
||
// Fonts registered via set_fonts only take effect next frame; named families would panic now.
|
||
if !self.fonts_ready {
|
||
ctx.request_repaint();
|
||
return;
|
||
}
|
||
for view in &mut self.views {
|
||
view.begin_render_frame();
|
||
}
|
||
// The window shows the active document's name (or title, if it asks for that).
|
||
let title = self
|
||
.active
|
||
.and_then(|i| self.session.get(self.views[i].id))
|
||
.map(|d| d.display_name())
|
||
.or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned()))
|
||
.map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft"));
|
||
if title != self.window_title {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||
self.window_title = title;
|
||
}
|
||
if self.full_screen && self.active.is_some() {
|
||
// Full screen: the page, nothing else (Esc or ⌘L to leave).
|
||
let t = theme::Tokens::get(&ctx);
|
||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(if t.dark() { t.pasteboard } else { egui::Color32::from_gray(32) })).show(
|
||
ui,
|
||
|ui| {
|
||
if let Some(i) = self.active {
|
||
match compare_ui::side_by_side(self, i) {
|
||
Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
|
||
None => canvas::document_area(self, i, ui),
|
||
}
|
||
}
|
||
},
|
||
);
|
||
dialogs::show(self, &ctx);
|
||
// Notices too: a refused field value or a failed save must be seen in full screen.
|
||
self.show_progress(&ctx);
|
||
widgets::toast(self, &ctx);
|
||
self.finish_render_frame(&ctx);
|
||
return;
|
||
}
|
||
self.sync_window_frame(&ctx);
|
||
if self.draws_window_frame() {
|
||
window_controls::resize_edges(&ctx);
|
||
}
|
||
chrome::tab_strip(self, ui);
|
||
chrome::mode_bar(self, ui);
|
||
if self.active.is_some() {
|
||
chrome::right_rail(self, ui);
|
||
// Read mode hides the tools, not the navigation panes: the rail stays visible
|
||
// there, so its buttons (Bookmarks, Page thumbnails, ...) must open their panels.
|
||
// Outside Read mode the docking workspace draws the inspector.
|
||
if self.mode == Mode::Read && self.right.is_some() {
|
||
egui::Panel::right("read_right_panel")
|
||
.resizable(true)
|
||
.default_size(330.0)
|
||
.size_range(260.0..=520.0)
|
||
.frame(egui::Frame::NONE)
|
||
.show(ui, |ui| panels::right_panel(self, ui));
|
||
}
|
||
}
|
||
let t = theme::Tokens::get(&ctx);
|
||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
|
||
if self.mode == Mode::Read {
|
||
match self.active {
|
||
None if self.combine_showing() => combine_ui::page(self, ui),
|
||
None => home::show(self, ui),
|
||
Some(index) => match compare_ui::side_by_side(self, index) {
|
||
Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
|
||
None => canvas::document_area(self, index, ui),
|
||
},
|
||
}
|
||
} else {
|
||
docking::show(self, ui);
|
||
}
|
||
});
|
||
self.process_pending_edits();
|
||
palette::show(self, &ctx);
|
||
dialogs::show(self, &ctx);
|
||
self.show_progress(&ctx);
|
||
widgets::toast(self, &ctx);
|
||
self.finish_render_frame(&ctx);
|
||
}
|
||
}
|
||
|
||
impl PdfCraftApp {
|
||
fn finish_render_frame(&mut self, ctx: &egui::Context) {
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|