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