diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 8d19a2320a..3d9d197ba0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1011,11 +1011,7 @@ fn target_destination_unit<'a>( match order { ArrangeOrder::BringToFront => { if target_idx == units.len().saturating_sub(1) { - if has_other_paint { - Ok((target_unit, true)) - } else { - Err(EditError::Invalid("the image is already in front".into())) - } + if has_other_paint { Ok((target_unit, true)) } else { Err(EditError::Invalid("the image is already in front".into())) } } else { let u = units.last().ok_or_else(|| EditError::Invalid("the image is already in front".into()))?; Ok((u, true)) @@ -1023,11 +1019,7 @@ fn target_destination_unit<'a>( } ArrangeOrder::SendToBack => { if target_idx == 0 { - if has_other_paint { - Ok((target_unit, false)) - } else { - Err(EditError::Invalid("the image is already at the back".into())) - } + if has_other_paint { Ok((target_unit, false)) } else { Err(EditError::Invalid("the image is already at the back".into())) } } else { let u = units.first().ok_or_else(|| EditError::Invalid("the image is already at the back".into()))?; Ok((u, false)) @@ -1035,11 +1027,7 @@ fn target_destination_unit<'a>( } ArrangeOrder::BringForward => { if target_idx == units.len().saturating_sub(1) { - if has_other_paint { - Ok((target_unit, true)) - } else { - Err(EditError::Invalid("the image is already in front".into())) - } + if has_other_paint { Ok((target_unit, true)) } else { Err(EditError::Invalid("the image is already in front".into())) } } else { let u = units.get(target_idx.saturating_add(1)).ok_or_else(|| EditError::Invalid("the image is already in front".into()))?; Ok((u, true)) @@ -1047,13 +1035,12 @@ fn target_destination_unit<'a>( } ArrangeOrder::SendBackward => { if target_idx == 0 { - if has_other_paint { - Ok((target_unit, false)) - } else { - Err(EditError::Invalid("the image is already at the back".into())) - } + if has_other_paint { Ok((target_unit, false)) } else { Err(EditError::Invalid("the image is already at the back".into())) } } else { - let u = target_idx.checked_sub(1).and_then(|i| units.get(i)).ok_or_else(|| EditError::Invalid("the image is already at the back".into()))?; + let u = target_idx + .checked_sub(1) + .and_then(|i| units.get(i)) + .ok_or_else(|| EditError::Invalid("the image is already at the back".into()))?; Ok((u, false)) } } @@ -1383,14 +1370,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None }; let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC")); - let unit_ops = build_relocated_unit_ops( - marked_open, - &source_gs_ops, - extracted, - dest_ctm, - img.draw_matrix, - has_closing_emc, - )?; + let unit_ops = build_relocated_unit_ops(marked_open, &source_gs_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?; let mut serialized = Vec::new(); serialized.push(b'\n'); diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index ab38aa089f..c536284530 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -250,12 +250,7 @@ impl EditCtx { } } -fn run_edit_page_image( - doc: &mut pdfcraft_cos::Document, - page: usize, - index: usize, - change: &ImageEdit, -) -> Result<(), EditError> { +fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> { match change { ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => { return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into()))); @@ -291,9 +286,7 @@ fn run_edit_page_image( )), ImageEdit::Replace { name, bytes } => { if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into()))); } pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } @@ -315,8 +308,7 @@ fn run_edit_page_image( }, }; - let added_items: Vec<(usize, pdfcraft_edit::Added)> = - pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect(); + let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect(); let matched_added = if let Some(st_ref) = target_stream_obj { added_items.iter().find(|(_, a)| a.obj == st_ref) } else if let Some(target_ref) = img.object { @@ -339,10 +331,7 @@ fn run_edit_page_image( let rotated_transform = matches!(change, ImageEdit::Transform(t) if t[1].abs() >= 1e-6 || t[2].abs() >= 1e-6); let should_promote = has_active_crop || rotated_transform - || matches!( - change, - ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete - ); + || matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete); if should_promote { doc.update_dict(added_item.obj, |d| { diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index fcaed67581..3fb84cc889 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -566,6 +566,7 @@ fn build_cmap_table(char_to_new_gid: &[(u32, u32)]) -> Vec { /// Subset a TrueType font to only the specified glyph IDs. /// Returns the new sfnt font binary and a mapping from old GID to new GID. +#[allow(clippy::cognitive_complexity)] pub fn subset_truetype(bytes: &[u8], used_gids: &[u16], postscript_name: &str) -> Result<(Vec, HashMap), String> { validate(bytes)?; let map = read_tables(bytes).ok_or("cannot parse font table directory")?; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 25937f33e3..f53857c245 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -227,7 +227,8 @@ impl PdfCraftApp { system_clipboard: true, clipboard_override: None, v_pressed: false, - last_command_time: None, + frame_count: 0, + last_command_frame: None, attach_override: None, duplicate_draft: None, page_props_draft: None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3666abbf5d..8fe38fdd8e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1,4256 +1,4244 @@ -//! The document area: page layout, zoom, render scheduling, overlays (links, annotation hovers, -//! form-field highlights), and the organize-pages grid. -//! -//! Page images come from the engine's render pool as whole-page rasters at the current zoom; -//! stale rasters are shown stretched until the sharp one arrives (tiling comes in M3.3). - -use std::collections::{BTreeSet, HashMap, HashSet}; -use std::ops::Range; -use std::sync::Arc; - -use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; -use pdfcraft_engine::{DocId, Edit}; -use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; - -use crate::page_cursor::{Layer, Subject}; -use crate::page_pointer::PagePointer; -use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; - -mod area; -pub(super) mod autoscroll; -mod bars; -mod cache; -pub(super) mod crop; -mod find; -pub(super) mod fling; -pub(super) mod image_pick; -mod navigation; -mod organize; -pub(super) mod page_cursor; -pub(super) mod page_pointer; -pub(super) mod page_router; -pub mod scroll_speed; -mod selection; -pub(super) mod signature_drag; -pub(super) mod wheel_boost; -pub(super) mod wheel_pager; -mod xform; -mod zoom; -pub(super) mod zoom_snap; - -pub(crate) use area::signature_badges; -use area::{ - SUPPRESS_SECS, SUPPRESS_TAIL_SECS, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor, -}; -#[cfg(test)] -use bars::in_size_hint_zone; -use bars::{Notice, notices, page_size_hint, quick_bar}; -pub use cache::RasterMemory; -pub(crate) use cache::{BASE_SIDE, TILE_THRESHOLD, texture_image, thumbnail_rows}; -#[cfg(test)] -use cache::{ - GRAYSCALE_TAG, GRID_TAG, MAX_CACHED_TEXT_PAGES, MAX_THUMB_DEMAND, PAGE_BYTES, THUMB_BYTES, THUMB_H, THUMB_TAG, THUMB_W, TILE, grayscale, - rgba_bytes, -}; -use cache::{OLD_TILES, PageTex, STALE_TAG, TEXT_TAG, TILE_BYTES, scale_tag, tag_scale, texture_bytes, tile_side}; -pub use find::Find; -use find::find_bar; -pub use navigation::shortcuts; -pub(crate) use organize::drop_gap; -#[cfg(test)] -pub(crate) use organize::grid_page_rect; -use organize::organize_grid; -#[cfg(test)] -use organize::sharp_enough; -use selection::{Selection, Span, click_unit, selection_actions}; -pub use xform::PageXform; -use xform::snap_to_pixels; -use zoom::anchored_scroll; -pub use zoom::{GRID_ZOOM_RANGE, THUMBNAIL_ZOOM_RANGE}; - -/// Logical pixels per PDF point at 100% (96 dpi, like browsers). -pub const PT: f32 = 96.0 / 72.0; -const GAP: f32 = 14.0; -const MARGIN: f32 = 28.0; -/// Horizontal gutter that keeps pages clear of the floating quick-action bar. -const SIDE: f32 = 70.0; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Fit { - Width, - Page, - Height, - None, -} - -use crate::edit_text_ui::ImageHandle; - -/// Extension trait on `egui::Painter` to draw polygons. -pub trait PainterExt { - fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx; -} - -impl PainterExt for egui::Painter { - fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx { - self.add(egui::Shape::convex_polygon(points, fill, stroke)) - } -} - -/// User-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. -pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { - if let Some(clip) = im.clip_rect { - let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; - if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { - return r; - } - } - im.visible_rect.unwrap_or(im.rect) -} - -/// Screen-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// the final selectable `egui::Rect` is the mathematical intersection of the image's -/// full CTM-derived rect and the clipping path's rect. -pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect_f64(info, page, im.rect); - if let Some(clip) = im.clip_rect { - let clip_r = xf.user_rect_f64(info, page, clip); - let inter = full.intersect(clip_r); - if inter.is_positive() { - return inter; - } - } - if let Some(vr) = im.visible_rect { - let r = xf.user_rect_f64(info, page, vr); - if r.is_positive() { - return r; - } - } - full -} - -/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, -/// with 8 resize handles and rotated local axes. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ImageObb { - pub s00: Pos2, - pub s10: Pos2, - pub s11: Pos2, - pub s01: Pos2, - pub s_bc: Pos2, - pub s_rc: Pos2, - pub s_tc: Pos2, - pub s_lc: Pos2, - pub s_rot: Pos2, - pub screen_center: Pos2, - pub width: f32, - pub height: f32, - pub theta: f32, - pub u_axis: (f64, f64), - pub v_axis: (f64, f64), - pub is_rotated: bool, -} - -impl ImageObb { - /// Calculates the 4 rotated corners manually using center, width, height, and rotation angle (θ). - /// Local axes: `u_axis = (cos θ, sin θ)` and `v_axis = (-sin θ, cos θ)`. - /// Places the 8 resize handles exactly at these rotated vertices and midpoints. - pub fn from_center_size_angle(center: Pos2, width: f32, height: f32, theta: f32) -> Self { - let cos_t = theta.cos(); - let sin_t = theta.sin(); - let u_axis = (cos_t as f64, sin_t as f64); - let v_axis = (-sin_t as f64, cos_t as f64); - let u_vec = vec2(cos_t, sin_t); - let v_vec = vec2(-sin_t, cos_t); - let hw = width * 0.5; - let hh = height * 0.5; - - let s00 = center - u_vec * hw - v_vec * hh; - let s10 = center + u_vec * hw - v_vec * hh; - let s11 = center + u_vec * hw + v_vec * hh; - let s01 = center - u_vec * hw + v_vec * hh; - - let s_bc = center - v_vec * hh; - let s_rc = center + u_vec * hw; - let s_tc = center + v_vec * hh; - let s_lc = center - u_vec * hw; - - let s_rot = s_tc + v_vec * 24.0; - let is_rotated = theta.abs() > 1e-4; - - Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center: center, width, height, theta, u_axis, v_axis, is_rotated } - } - - pub fn new(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self { - Self::from_matrix(matrix, xf, info, page) - } - - pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self { - let m = crate::edit_text_ui::Matrix(matrix); - let p00 = m.apply(0.0, 0.0); - let p10 = m.apply(1.0, 0.0); - let p11 = m.apply(1.0, 1.0); - let p01 = m.apply(0.0, 1.0); - - let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1); - let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1); - let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1); - let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1); - - let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5); - let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5); - let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5); - let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5); - - let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25); - let out_dir = s_tc - screen_center; - let out_len = out_dir.length().max(1e-4); - let out_norm = out_dir / out_len; - let s_rot = s_tc + out_norm * 24.0; - - let [a, b, c, d, ..] = matrix; - let w_u = (a * a + b * b).sqrt().max(1e-6); - let h_v = (c * c + d * d).sqrt().max(1e-6); - let u_axis = (a / w_u, b / w_u); - let v_axis = (c / h_v, d / h_v); - let theta = (b).atan2(a) as f32; - let is_rotated = b.abs() > 1e-4 || c.abs() > 1e-4; - - Self { - s00, - s10, - s11, - s01, - s_bc, - s_rc, - s_tc, - s_lc, - s_rot, - screen_center, - width: (s10 - s00).length(), - height: (s01 - s00).length(), - theta, - u_axis, - v_axis, - is_rotated, - } - } - - pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self { - let [_, b, c, ..] = im.matrix; - let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4; - let bounds = image_selectable_bounds(im); - let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] { - [bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]] - } else { - im.matrix - }; - Self::from_matrix(matrix, xf, info, page) - } - - pub fn handles(&self) -> [(ImageHandle, Pos2); 8] { - [ - (ImageHandle::TopLeft, self.s01), - (ImageHandle::TopCenter, self.s_tc), - (ImageHandle::TopRight, self.s11), - (ImageHandle::RightCenter, self.s_rc), - (ImageHandle::BottomRight, self.s10), - (ImageHandle::BottomCenter, self.s_bc), - (ImageHandle::BottomLeft, self.s00), - (ImageHandle::LeftCenter, self.s_lc), - ] - } - - pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 { - match handle { - ImageHandle::TopLeft => self.s01, - ImageHandle::TopCenter => self.s_tc, - ImageHandle::TopRight => self.s11, - ImageHandle::RightCenter => self.s_rc, - ImageHandle::BottomRight => self.s10, - ImageHandle::BottomCenter => self.s_bc, - ImageHandle::BottomLeft => self.s00, - ImageHandle::LeftCenter => self.s_lc, - } - } - - pub fn handle_id(&self, base_id: egui::Id, handle: ImageHandle) -> egui::Id { - base_id.with(match handle { - ImageHandle::TopLeft => "handle_tl", - ImageHandle::TopCenter => "handle_tc", - ImageHandle::TopRight => "handle_tr", - ImageHandle::RightCenter => "handle_rc", - ImageHandle::BottomRight => "handle_br", - ImageHandle::BottomCenter => "handle_bc", - ImageHandle::BottomLeft => "handle_bl", - ImageHandle::LeftCenter => "handle_lc", - }) - } - - pub fn handles_with_ids(&self, base_id: egui::Id) -> [(ImageHandle, Pos2, egui::Id); 8] { - [ - (ImageHandle::TopLeft, self.s01, self.handle_id(base_id, ImageHandle::TopLeft)), - (ImageHandle::TopCenter, self.s_tc, self.handle_id(base_id, ImageHandle::TopCenter)), - (ImageHandle::TopRight, self.s11, self.handle_id(base_id, ImageHandle::TopRight)), - (ImageHandle::RightCenter, self.s_rc, self.handle_id(base_id, ImageHandle::RightCenter)), - (ImageHandle::BottomRight, self.s10, self.handle_id(base_id, ImageHandle::BottomRight)), - (ImageHandle::BottomCenter, self.s_bc, self.handle_id(base_id, ImageHandle::BottomCenter)), - (ImageHandle::BottomLeft, self.s00, self.handle_id(base_id, ImageHandle::BottomLeft)), - (ImageHandle::LeftCenter, self.s_lc, self.handle_id(base_id, ImageHandle::LeftCenter)), - ] - } - - pub fn corners(&self) -> [Pos2; 4] { - [self.s00, self.s10, self.s11, self.s01] - } - - /// Resizes the OBB proportionally along its local axes using projected mouse delta, - /// locking the diagonally opposite corner as the fixed anchor point. - pub fn scale_proportional(&self, handle: ImageHandle, delta: Vec2, min_size: f32) -> Self { - let u_vec = vec2(self.u_axis.0 as f32, self.u_axis.1 as f32); - let v_vec = vec2(self.v_axis.0 as f32, self.v_axis.1 as f32); - let delta_u = delta.dot(u_vec); - let delta_v = delta.dot(v_vec); - let min_s = min_size.max(5.0); - - match handle { - ImageHandle::TopRight => { - let anchor = self.s00; - let s_u = (self.width + delta_u) / self.width.max(1e-4); - let s_v = (self.height + delta_v) / self.height.max(1e-4); - let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; - let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); - let nw = self.width * s; - let nh = self.height * s; - let new_center = anchor + u_vec * (nw * 0.5) + v_vec * (nh * 0.5); - Self::from_center_size_angle(new_center, nw, nh, self.theta) - } - ImageHandle::TopLeft => { - let anchor = self.s10; - let s_u = (self.width - delta_u) / self.width.max(1e-4); - let s_v = (self.height + delta_v) / self.height.max(1e-4); - let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; - let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); - let nw = self.width * s; - let nh = self.height * s; - let new_center = anchor - u_vec * (nw * 0.5) + v_vec * (nh * 0.5); - Self::from_center_size_angle(new_center, nw, nh, self.theta) - } - ImageHandle::BottomLeft => { - let anchor = self.s11; - let s_u = (self.width - delta_u) / self.width.max(1e-4); - let s_v = (self.height - delta_v) / self.height.max(1e-4); - let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; - let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); - let nw = self.width * s; - let nh = self.height * s; - let new_center = anchor - u_vec * (nw * 0.5) - v_vec * (nh * 0.5); - Self::from_center_size_angle(new_center, nw, nh, self.theta) - } - ImageHandle::BottomRight => { - let anchor = self.s01; - let s_u = (self.width + delta_u) / self.width.max(1e-4); - let s_v = (self.height - delta_v) / self.height.max(1e-4); - let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; - let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); - let nw = self.width * s; - let nh = self.height * s; - let new_center = anchor + u_vec * (nw * 0.5) - v_vec * (nh * 0.5); - Self::from_center_size_angle(new_center, nw, nh, self.theta) - } - ImageHandle::RightCenter => { - let anchor = self.s_lc; - let nw = (self.width + delta_u).max(min_s); - let new_center = anchor + u_vec * (nw * 0.5); - Self::from_center_size_angle(new_center, nw, self.height, self.theta) - } - ImageHandle::LeftCenter => { - let anchor = self.s_rc; - let nw = (self.width - delta_u).max(min_s); - let new_center = anchor - u_vec * (nw * 0.5); - Self::from_center_size_angle(new_center, nw, self.height, self.theta) - } - ImageHandle::TopCenter => { - let anchor = self.s_bc; - let nh = (self.height + delta_v).max(min_s); - let new_center = anchor + v_vec * (nh * 0.5); - Self::from_center_size_angle(new_center, self.width, nh, self.theta) - } - ImageHandle::BottomCenter => { - let anchor = self.s_tc; - let nh = (self.height - delta_v).max(min_s); - let new_center = anchor - v_vec * (nh * 0.5); - Self::from_center_size_angle(new_center, self.width, nh, self.theta) - } - } - } -} - -/// Projects mouse delta onto local unit axes `u_axis` and `v_axis` using the dot product. -pub fn project_delta(delta: Vec2, u_axis: (f64, f64), v_axis: (f64, f64)) -> (f64, f64) { - let dx = delta.x as f64; - let dy = delta.y as f64; - (dx * u_axis.0 + dy * u_axis.1, dx * v_axis.0 + dy * v_axis.1) -} - -/// The page display as it was before full screen, put back on leaving it -/// ([`DocView::enter_presentation`]). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct SavedDisplay { - pub layout: PageLayout, - pub continuous: bool, - pub cover: bool, - pub fit: Fit, - pub zoom: f32, -} - -/// The base page display: one page across, or a two-page spread. Whether it scrolls continuously -/// is a separate flag (`continuous`), as in Acrobat, so every combination is reachable (#752). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PageLayout { - Single, - TwoUp, -} - -impl PageLayout { - /// The base page displays, in the order the View menu, the rail's Page display button and - /// Preferences list them. - pub const ORDER: [Self; 2] = [Self::Single, Self::TwoUp]; - - /// The name settings and view options use for the base layout: `single` or `two-up`. - pub fn as_str(self) -> &'static str { - match self { - Self::Single => "single", - Self::TwoUp => "two-up", - } - } - - /// The base layout an [`as_str`](Self::as_str) name stands for, in any case. `None` for - /// anything else, so callers can reject a typo rather than switch layouts silently. - pub fn try_parse(value: &str) -> Option { - Self::ORDER.into_iter().find(|l| l.as_str().eq_ignore_ascii_case(value.trim())) - } - - /// Rail icon for this layout. - pub fn icon(self) -> &'static str { - match self { - Self::Single => "file-text", - Self::TwoUp => "columns-2", - } - } - - /// Registry command that switches to this base layout (`PdfCraftApp::execute`). - pub fn command(self) -> &'static str { - match self { - Self::Single => "view.layout.single", - Self::TwoUp => "view.layout.two_up", - } - } - - /// The base layout a [`command`](Self::command) id switches to. - pub fn from_command(id: &str) -> Option { - Self::ORDER.into_iter().find(|l| l.command() == id) - } - - /// Menu label: the label of [`command`](Self::command), which the catalogs translate. - pub fn label(self) -> &'static str { - match self { - Self::Single => "Single page", - Self::TwoUp => "Two-page view", - } - } -} - -/// The single page-display token settings and the control channel use, combining the base layout -/// with continuous scrolling. `single` and `two-page` show one page or one spread at a time; -/// `continuous` (Single, scrolling) and `two-up` (Two-page, scrolling) keep their earlier names -/// for back-compat. -pub fn display_token(layout: PageLayout, continuous: bool) -> &'static str { - match (layout, continuous) { - (PageLayout::Single, false) => "single", - (PageLayout::Single, true) => "continuous", - (PageLayout::TwoUp, true) => "two-up", - (PageLayout::TwoUp, false) => "two-page", - } -} - -/// The base layout and continuous flag a [`display_token`] names, in any case. `None` for a typo. -pub fn parse_display_token(value: &str) -> Option<(PageLayout, bool)> { - match value.trim().to_ascii_lowercase().as_str() { - "single" => Some((PageLayout::Single, false)), - "continuous" => Some((PageLayout::Single, true)), - "two-up" => Some((PageLayout::TwoUp, true)), - "two-page" | "two-up-single" => Some((PageLayout::TwoUp, false)), - _ => None, - } -} - -/// How a newly opened document is shown (Preferences ▸ Documents and view). A PDF that asks -/// for its own layout or zoom gets that instead. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ViewDefaults { - pub layout: PageLayout, - /// Whether the base layout scrolls continuously. - pub continuous: bool, - /// Separate pages with a gutter and shadow; off joins adjacent page edges. - pub show_page_gaps: bool, - /// [`Fit::Width`] or [`Fit::Page`], or [`Fit::None`] for a fixed `zoom`. - pub fit: Fit, - /// The zoom with [`Fit::None`] (1.0 = 100%). - pub zoom: f32, - /// Highlight existing fields: a preference in Acrobat, so it carries over to the next - /// document and the next session. On for new settings; a stored choice wins. - pub highlight_fields: bool, -} - -impl Default for ViewDefaults { - fn default() -> Self { - // Single page, scrolling continuously at fit width: the earlier "continuous" default. - Self { layout: PageLayout::Single, continuous: true, show_page_gaps: true, fit: Fit::Width, zoom: 1.0, highlight_fields: true } - } -} - -impl ViewDefaults { - /// The zoom as settings and view options name it: `fit-width`, `fit-page` or a percentage. - pub fn zoom_name(&self) -> String { - match self.fit { - Fit::Page => "fit-page".into(), - Fit::None => format!("{}%", (self.zoom * 100.0).round()), - Fit::Width | Fit::Height => "fit-width".into(), - } - } - - /// These defaults with the zoom a [`zoom_name`](Self::zoom_name) names, in any case. `None` - /// for anything else, including a percentage outside the 8–6400% the view can show. - pub fn with_zoom(self, name: &str) -> Option { - let name = name.trim(); - let (fit, zoom) = if name.eq_ignore_ascii_case("fit-width") { - (Fit::Width, self.zoom) - } else if name.eq_ignore_ascii_case("fit-page") { - (Fit::Page, self.zoom) - } else { - let percent = name.strip_suffix('%').unwrap_or(name).trim().parse::().ok().filter(|p| (8.0..=6400.0).contains(p))?; - (Fit::None, percent / 100.0) - }; - Some(Self { fit, zoom, ..self }) - } -} - -/// A historical snapshot opened from another document: View signed version opens the bytes -/// a signature covers, View revision an earlier incremental revision. Repeats reuse the tab, -/// and the tab and notice bar name what it is, instead of piling up identical tabs. -/// What a historical snapshot shows: the bytes a signature covers, or an earlier -/// incremental revision. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SnapshotKind { - SignedVersion, - Revision, -} - -#[derive(Clone, Debug)] -pub struct Snapshot { - pub kind: SnapshotKind, - /// The 1-based revision shown. - pub revision: usize, - /// The name of the document it was opened from. - pub from: String, -} - -/// The name shown for a document tab, the window title and the Combine picker. -/// A snapshot reads like its source document, plus the snapshot suffix that tells -/// the tabs apart. Without a title the file name (which already carries the suffix) -/// is enough. -pub(crate) fn tab_label(app: &PdfCraftApp, index: usize) -> Option { - let view = app.views.get(index)?; - let doc = app.session.get(view.id)?; - let Some(snap) = view.snapshot.as_ref() else { return Some(doc.display_name()) }; - Some(snapshot_tab_name(&doc.display_name(), &doc.name, snap)) -} - -/// A snapshot's tab name from its source title, file name and marker. Pure so the -/// tab strip, window title and picker cannot disagree with each other. -pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapshot) -> String { - if title == file_name { - return file_name.into(); - } - if snapshot.kind == SnapshotKind::SignedVersion { - format!("{title} (signed version)") - } else { - format!("{title} (revision {})", snapshot.revision) - } -} - -pub struct DocView { - pub id: DocId, - pub zoom: f32, - pub fit: Fit, - pub layout: PageLayout, - /// Whether the base layout scrolls continuously (independent of `layout`, as in Acrobat). - pub continuous: bool, - pub show_page_gaps: bool, - /// View rotation in degrees clockwise (0, 90, 180, 270); display only, never saved. - pub rotation: u16, - pub current: usize, - pub organize: bool, - pub highlight_fields: bool, - pub page_input: String, - /// Select the replacement page label once if its editor remains focused. - pub(crate) page_input_reselect: bool, - pub notice_dismissed: bool, - /// The user has been told that the form's scripts were turned off (it is said once). - pub scripts_off_noted: bool, - /// The file changed on disk while this document had unsaved changes: the notice bar asks - /// before replacing it (#431). - pub disk_changed: bool, - /// The snapshot notice bar's Close was clicked: the tab closes at the top of the next - /// frame, where no document borrow is held (the bar itself can't close it). - pub close_requested: bool, - /// A historical snapshot of another document (see [`Snapshot`]): `None` for real documents. - pub snapshot: Option, - /// Two-page view: show the first page alone, as a cover (View ▸ Page display). - pub cover: bool, - /// Previous view / Next view: pages visited before (and after, once going back). - pub back: Vec, - pub forward: Vec, - /// Pending navigation: page and fraction down the page to align with the viewport top. - pub goto: Option<(usize, f32)>, - /// Pending position within that page from a destination ([`Self::go_to_dest`]): the point - /// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points - /// from the viewport's top-left corner. - goto_point: Option<(usize, Option, Option, Vec2)>, - /// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down / - /// Page Up where the pages scroll. - pub key_scroll: f32, - /// Single-page view, as last drawn: the page shown and whether the view was at its top and - /// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273). - single_edges: Option<(usize, bool, bool)>, - /// Briefly outline an annotation after navigating to it from a panel. - pub flash: Option<(usize, [f32; 4], f64)>, - /// Compare files: differences shaded on this document's pages (page, user-space box, colour). - pub compare_marks: Vec<(usize, [f32; 4], Color32)>, - /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view - /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. - presentation: Option, - pages: HashMap, - /// Pages the renderer could not draw, with the reason (never re-requested). - errors: HashMap, - /// When each still-missing page was first shown, to flag unusually slow renders. - waiting_since: HashMap, - thumbs: HashMap, - /// This frame's consumers, with visible cells ahead of prefetch. - thumb_demand: HashMap, - frame_queue: Vec, - /// A page-list edit after layout invalidates this frame's scales and page indexes. - frame_geometry_changed: bool, - /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport - /// bounds them, and the byte and count limits apply to prefetch only. - frame_visible: HashSet, - /// Thumbnails that are out of date (still shown until their replacement arrives). - stale_thumbs: HashSet, - /// Tiles that are out of date (still shown until their replacement arrives). - stale_tiles: HashSet<(usize, u64, u32, u32)>, - /// Scale tag of the current zoom level, as last drawn. - current_scale_tag: u64, - /// Print dialog: the current sheet's pages at the preview pane's device resolution. - print_pages: HashMap, - /// Print dialog: preview rasters become luminance when Print ▸ Print in grayscale is on. - grayscale: bool, - /// Sharp tiles of large pages: (page, scale tag, tile x, tile y) → texture. Tiles of an - /// earlier zoom stay, drawn stretched, until those of the current one cover the page. - tiles: HashMap<(usize, u64, u32, u32), TextureHandle>, - /// Text layers, extracted in the background on demand (selection, find, copy). - pub(crate) texts: HashMap>, - pub(crate) text_failed: HashSet, - pub find: Option, - selection: Option, - /// Select All while scrolling continuously (#2639): the text of every page of the document, - /// highlighted on each page as its text arrives and copied whole ([`DocView::whole_text`]). - pub(crate) all_text: bool, - /// The selection whose action bar was closed by its Copy button: the bar stays hidden - /// until the selection changes, as it does once a markup button consumes the selection. - actions_dismissed: Option, - /// The drag in progress started a text selection: the wheel scrolls the page during it - /// (#721), which egui's scroll area refuses while anything is dragged. - selection_drag: bool, - /// Clicks in the current run on the text layer: two select a word, three the line, four the page. - clicks: u32, - last_press_time: f64, - last_press_pos: Option, - /// Full screen: a click's page turn, when it falls due (input time) and which way. It waits - /// out the double-click time, as the click may be the first of a double-click selecting a word. - present_turn: Option<(f64, bool)>, - last_queue: Vec, - /// Finished renders wait in the pool instead of reaching the view (tests): what the view - /// shows before a raster arrives can be looked at however fast the renderer is. - pub hold_rasters: bool, - viewport_w: f32, - viewport_h: f32, - page_count: usize, - /// Page heights in points (view space), for mapping text positions to scroll offsets. - page_heights: Vec, - /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). - screen_rects: Vec<(usize, Rect)>, - screen_xforms: Vec<(usize, PageXform)>, - /// The scroll viewport on screen last frame. - viewport_screen: Rect, - /// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset. - zoom_anchor: Option<(usize, f32, f32, Vec2)>, - /// The page view has been shown, so its scroll offset in egui's memory is this document's. - shown: bool, - /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). - pub(crate) auto_ocr_tried: std::collections::HashSet, - /// Pages Edit looked at and found not to be a scan, with the document's edit generation then: - /// they are looked at again only after the document changes (an image added, say). - pub(crate) auto_ocr_skipped: std::collections::HashMap, - /// Turns wheel input into page turns in single-page view. - wheel: crate::wheel_pager::WheelPager, - /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. - /// A precision touchpad sends pixel-precise `MouseWheel` events; on Windows they arrive with - /// no `TouchPhase::Start`, so egui eases deltas of 8 px or more and the page lags the finger - /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as - /// it decays, instead of adding on top of the 1:1 motion. - precise_scroll_until: f64, - /// Per-axis line-wheel state; horizontal input cannot extend a vertical burst. - wheel_boost_x: crate::wheel_boost::WheelBoost, - wheel_boost_y: crate::wheel_boost::WheelBoost, - /// Touchpad momentum after the fingers lift (#531). - fling: crate::fling::Fling, - pub(crate) auto_scroll: crate::autoscroll::AutoScroll, - /// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page". - pub selected: BTreeSet, - pub(crate) organize_range: crate::page_selection_ui::PageSelectionInput, - /// Anchor for ⇧-click range selection in the organize grid. - select_anchor: Option, - /// An edit requested by the view (organize toolbar, keys), applied by the app this frame. - pub pending_edit: Option, - /// Edit text: the lines per page (with the document generation they were read at), and the - /// line being edited. - pub(crate) edit_lines: HashMap)>, - pub line_editor: Option, - /// The paragraph editor whose edit was just queued. It opens again with its typing if the - /// edit fails, as a refused form field does. - pub(crate) applied_editor: Option, - /// The box a click switched to while another paragraph was being edited: it opens once the - /// queued edit has applied, so its index and geometry come from the document it now is (#2022). - /// It is held as the paragraph's text and box, not an index: the edit can regroup the page. - pub(crate) pending_text_open: Option<(usize, pdfcraft_engine::BlockKey)>, - pub(crate) text_background: crate::edit_text_ui::TextBackground, - pub(crate) move_background: crate::content_ui::MoveBackground, - /// Edit text & images: the images per page (by document generation), and the selected one. - pub(crate) edit_images: HashMap)>, - pub(crate) objects: crate::object_ui::ObjectSelection, - pub image_selection: Option, - /// Select tool: the picture clicked, to copy in its original format. - pub picked_image: Option, - /// A paragraph box being dragged (moved, or resized from its right edge) in Edit text. - pub block_drag: Option, - /// Commenting state: selected comment, gestures, composer. - pub comments: crate::comments::CommentView, - pub measure: crate::measure_ui::MeasureView, - /// Form filling state: the focused field. - pub forms: crate::forms_ui::FormView, - /// Prepare a form: the selected field and the gesture in progress. - pub prepare: crate::prepare::PrepareView, - /// Prepare a form ▸ Calculation order. - pub(crate) calc_order: crate::calc_order_ui::CalcOrderView, - /// Edit a PDF: the selected added item, the text being typed. - pub content: crate::content_ui::ContentView, - /// Edit a PDF ▸ Link tool state. - pub links: crate::link_ui::LinkView, - /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. - pub redact_drag: crate::redact_ui::AreaDrag, - pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, - /// A crop rectangle being dragged (Crop tool). - pub crop_drag: crate::crop::CropDrag, - /// Enhance ▸ Camera image: the quadrilateral being placed. - pub camera: Option, - /// Enhance ▸ Camera image: the page just enhanced, while the bar offers rotate, crop and - /// organize. - pub camera_done: Option, - /// Use a certificate: a signature rectangle being drawn, or an empty signature field clicked. - pub sign: crate::sign_ui::SignView, - /// Organize: the pages being dragged to a new place. - pub org_drag: Option>, - /// Pages panel: the pages being dragged to a new place. - pub panel_drag: Option>, - /// Transient keyboard owner for this document's Pages sidebar. - pub(crate) pages_focus_id: Option, - /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. - pub marquee: Option<(usize, Pos2)>, - pub marquee_done: Option, - /// Fill & Sign text being typed. - pub fill_text: Option, - /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. - pub(crate) fill_signature_page: Option, - pub(crate) signature_drag: crate::signature_drag::SignatureDrag, - /// What the page modules claim for the cursor this frame; `document_area` sets it once. - pub(crate) cursor: crate::page_cursor::PageCursor, - /// Who the press the pages hold belongs to. - pub(crate) router: crate::page_router::PageRouter, - /// Escape cancelled the press the pages hold: the page ignores it until the button is up, so - /// its release neither clicks nor ends a drag. - pub(crate) press_cancelled: bool, - /// A non-edit action requested by the organize toolbar, handled by the app. - pub pending_action: Option, - /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they - /// go after the selection. - pub insert_at: Option, - /// The page-grid gap under the pointer this frame (where dropped files go), when it can - /// take pages. - pub grid_gap: Option, - /// The visible part of the Pages panel as last drawn, when it can take dropped files - /// (cleared each frame before the panels draw). - pub pages_panel: Option, - /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. - pub panel_gap: Option, - /// The current page the Pages panel last followed: it scrolls to the current page's - /// thumbnail only when the page changes. - pub(crate) panel_followed: Option, - /// Scale of the thumbnails in the Pages inspector, independent of document and grid zoom. - thumbnail_zoom: f32, - /// How large the page grid draws its pages (1.0 = the usual thumbnails). - grid_zoom: f32, - /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid - /// out, so it can keep the same pages in view. - grid_zoom_request: Option, - /// The page to keep in place after a grid zoom, and how far below the top of the grid its - /// cell was. - grid_anchor: Option<(usize, f32)>, - /// The page the grid scrolls into view on its next frame (Organize just opened on it). - grid_reveal: Option, - /// Sharp renders of the pages the grid shows larger than a thumbnail: only those in view, - /// at the size drawn (device pixels wide). - grid_pages: HashMap, - /// Zoom-settle debounce for heavy pages: the last requested device scale, and the egui - /// time until which sharp whole-page/tile re-renders are held back. While the zoom is - /// still moving, every frame would otherwise cancel the in-flight full-page interpret - /// (seconds on image-heavy CAD plots) and start it over, so a sharp raster never - /// arrives. The stale raster stays on screen stretched meanwhile. - last_scale: f32, - sharp_hold_until: f64, - /// Pan-settle debounce for tiled pages: the scroll offset last frame, and the egui time - /// until which sharp tile re-renders are held back. Each tile costs a full page - /// interpret, so a drag would otherwise queue a storm of them (one per frame) while the - /// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay - /// stretched meanwhile; fresh tiles go out once the pan settles. - pub(crate) last_scroll: Vec2, - /// Side-by-side compare: scroll to here this frame, following the other half. - pub(crate) sync_scroll: Option, - /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. - page_widget: Option, - pan_hold_until: f64, - /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan - /// settle holds. Fast pages render straight through, with no added latency. - slow_pages: HashSet, -} - -/// Organize-toolbar actions that need the app (file pickers, new tabs, dialogs). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ViewAction { - InsertFromFile, - /// Insert files at a gap of the page grid (0 = before the first page). - InsertFromFileAt(usize), - /// Replace the selected pages with pages from another file. - ReplacePages, - /// Save the document as the grid shows it. - Save, - Extract, - Split, - /// Split before each selected page: opens Split on that mode. - SplitBeforeSelected, - /// Copy the selected pages (Cut also deletes them). - CopyPages { - cut: bool, - }, - /// Paste copied pages after the selection. - PastePages, - /// Enhance ▸ Camera image: straighten what the corners enclose. - EnhanceCamera, - /// Leave Enhance ▸ Camera image (Cancel before Enhance page, Done after it). - CancelCamera, - /// After Enhance page: turn the enhanced page a quarter. - CameraRotate { - clockwise: bool, - }, - /// After Enhance page: enhance again at the slider's clean-up level. - CameraLevel, - /// After Enhance page: leave the tool for this command (Crop pages, Organize pages). - CameraNext(&'static str), -} - -/// Default page right-click menu actions. -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum PageMenuAction { - RotateCCW(usize), - RotateCW(usize), - Print, - SelectTool, - HandTool, - MarqueeZoom, - Copy, - Paste(usize, [f64; 2]), - AddComment(usize, [f64; 2]), - AddBookmark(usize), - TakeSnapshot, - EditPdf, - AddText, - AddImage, - DeletePage(usize), - ExportPdf, - Find, - DocProperties, - FlattenComments, -} - -pub(crate) struct PageMenuPerms { - pub can_modify: bool, - pub can_assemble: bool, - pub can_annotate: bool, - pub can_print: bool, - pub can_copy: bool, - pub can_paste: bool, - pub has_annotations: bool, -} - -pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option) { - // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate view counterclockwise")).clicked() { - *action = Some(PageMenuAction::RotateCCW(page)); - ui.close(); - } - ui.add_enabled_ui(perms.can_print, |ui| { - if crate::icons::button(ui, "printer", 24.0, false, tl!("Print")).clicked() { - *action = Some(PageMenuAction::Print); - ui.close(); - } - }); - if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, tl!("Select Tool")).clicked() { - *action = Some(PageMenuAction::SelectTool); - ui.close(); - } - if crate::icons::button(ui, "hand", 24.0, false, tl!("Hand Tool")).clicked() { - *action = Some(PageMenuAction::HandTool); - ui.close(); - } - if crate::icons::button(ui, "zoom-in", 24.0, false, tl!("Marquee Zoom")).clicked() { - *action = Some(PageMenuAction::MarqueeZoom); - ui.close(); - } - }); - - ui.separator(); - - if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() { - *action = Some(PageMenuAction::Copy); - ui.close(); - } - if ui.add_enabled(perms.can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() { - *action = Some(PageMenuAction::Paste(page, at)); - ui.close(); - } - - ui.separator(); - - if ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))).clicked() { - *action = Some(PageMenuAction::AddComment(page, at)); - ui.close(); - } - if perms.has_annotations && ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))).clicked() { - *action = Some(PageMenuAction::FlattenComments); - ui.close(); - } - if ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))).clicked() { - *action = Some(PageMenuAction::AddBookmark(page)); - ui.close(); - } - if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))).clicked() { - *action = Some(PageMenuAction::TakeSnapshot); - ui.close(); - } - - ui.separator(); - - if ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() { - *action = Some(PageMenuAction::EditPdf); - ui.close(); - } - if ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))).clicked() { - *action = Some(PageMenuAction::AddText); - ui.close(); - } - if ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))).clicked() { - *action = Some(PageMenuAction::AddImage); - ui.close(); - } - if ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))).clicked() { - *action = Some(PageMenuAction::DeletePage(page)); - ui.close(); - } - if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))).clicked() { - *action = Some(PageMenuAction::ExportPdf); - ui.close(); - } - - ui.separator(); - - if ui.button(tl!("Find text or tools")).clicked() { - *action = Some(PageMenuAction::Find); - ui.close(); - } - if ui.button(tl!("Rotate view clockwise")).clicked() { - *action = Some(PageMenuAction::RotateCW(page)); - ui.close(); - } - if ui.button(tl!("Document properties…")).clicked() { - *action = Some(PageMenuAction::DocProperties); - ui.close(); - } -} - -impl DocView { - /// Whether a middle-button scrolling gesture is active (tests and automation). - pub fn auto_scrolling(&self) -> bool { - self.auto_scroll.active() - } - - /// Whether a held middle-button pan is active, outside Linux (tests and automation). - pub fn middle_panning(&self) -> bool { - self.auto_scroll.panning() - } - - /// Abandon only pointer previews: clearing egui's held button must not commit an app-owned - /// grab through a tool's `!primary_down` fallback. Keep selections, text drafts and edits - /// that were already completed before cancellation. - pub(crate) fn cancel_pointer_gestures(&mut self) { - self.marquee = None; - self.marquee_done = None; - self.comments.gesture = None; - self.links.drag = None; - self.sign.drag = None; - self.redact_drag = None; - self.crop_drag = None; - self.block_drag = None; - self.org_drag = None; - self.panel_drag = None; - self.selection_drag = false; - self.router.cancel(); - self.auto_scroll.cancel(); - self.prepare.cancel_pointer_gesture(); - self.content.cancel_pointer_gesture(); - self.objects.cancel_pointer_gesture(); - if let Some(image) = &mut self.image_selection { - image.cancel_pointer_gesture(); - } - } - - /// A view of a newly opened document, shown as `defaults` say. Opening a file then applies - /// any layout or zoom the PDF itself asks for. - pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self { - Self { - id, - zoom: defaults.zoom, - fit: defaults.fit, - layout: defaults.layout, - continuous: defaults.continuous, - show_page_gaps: defaults.show_page_gaps, - rotation: 0, - current: 0, - organize: false, - highlight_fields: defaults.highlight_fields, - page_input: "1".into(), - page_input_reselect: false, - notice_dismissed: false, - scripts_off_noted: false, - disk_changed: false, - close_requested: false, - snapshot: None, - cover: false, - back: Vec::new(), - forward: Vec::new(), - goto: None, - goto_point: None, - key_scroll: 0.0, - single_edges: None, - flash: None, - compare_marks: Vec::new(), - presentation: None, - pages: HashMap::new(), - errors: HashMap::new(), - waiting_since: HashMap::new(), - thumbs: HashMap::new(), - thumb_demand: HashMap::new(), - frame_queue: Vec::new(), - frame_geometry_changed: false, - frame_visible: HashSet::new(), - stale_thumbs: HashSet::new(), - stale_tiles: HashSet::new(), - current_scale_tag: 0, - print_pages: HashMap::new(), - grayscale: false, - tiles: HashMap::new(), - texts: HashMap::new(), - text_failed: HashSet::new(), - find: None, - selection: None, - all_text: false, - actions_dismissed: None, - selection_drag: false, - clicks: 0, - last_press_time: 0.0, - last_press_pos: None, - present_turn: None, - last_queue: Vec::new(), - hold_rasters: false, - viewport_w: 800.0, - viewport_h: 600.0, - page_count: info.pages.len(), - page_heights: info.pages.iter().map(|p| p.height).collect(), - screen_rects: Vec::new(), - screen_xforms: Vec::new(), - viewport_screen: Rect::NOTHING, - zoom_anchor: None, - shown: false, - auto_ocr_tried: Default::default(), - auto_ocr_skipped: Default::default(), - wheel: Default::default(), - precise_scroll_until: 0.0, - wheel_boost_x: Default::default(), - wheel_boost_y: Default::default(), - fling: Default::default(), - auto_scroll: Default::default(), - selected: BTreeSet::new(), - select_anchor: None, - organize_range: Default::default(), - pending_edit: None, - edit_lines: HashMap::new(), - line_editor: None, - applied_editor: None, - pending_text_open: None, - text_background: Default::default(), - move_background: Default::default(), - edit_images: HashMap::new(), - objects: Default::default(), - image_selection: None, - picked_image: None, - block_drag: None, - pending_action: None, - insert_at: None, - grid_gap: None, - pages_panel: None, - panel_gap: None, - panel_followed: None, - thumbnail_zoom: 1.0, - grid_zoom: 1.0, - grid_zoom_request: None, - grid_anchor: None, - grid_reveal: None, - grid_pages: HashMap::new(), - last_scale: 0.0, - sharp_hold_until: 0.0, - last_scroll: Vec2::ZERO, - sync_scroll: None, - page_widget: None, - pan_hold_until: 0.0, - slow_pages: HashSet::new(), - comments: Default::default(), - measure: Default::default(), - forms: Default::default(), - prepare: Default::default(), - calc_order: Default::default(), - content: Default::default(), - links: Default::default(), - redact_drag: None, - pending_redaction: None, - crop_drag: None, - camera: None, - camera_done: None, - sign: Default::default(), - org_drag: None, - panel_drag: None, - pages_focus_id: None, - marquee: None, - marquee_done: None, - fill_text: None, - fill_signature_page: None, - signature_drag: Default::default(), - cursor: Default::default(), - router: Default::default(), - press_cancelled: false, - } - } - - /// The document's content or page list changed (an edit, undo or redo): drop caches and - /// adopt the new page geometry, keeping the reader's place where possible. - pub fn document_changed(&mut self, info: &DocInfo) { - self.invalidate_content(); - self.frame_geometry_changed = true; - self.page_count = info.pages.len(); - self.page_heights = info.pages.iter().map(|p| p.height).collect(); - let last = self.page_count.saturating_sub(1); - self.current = self.current.min(last); - self.selected.retain(|p| *p <= last); - // Text being typed on a page that no longer exists goes with the page. - if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) { - self.content.draft = None; - } - if self.select_anchor.is_some_and(|a| a > last) { - self.select_anchor = None; - } - self.goto = None; - self.goto_point = None; - self.zoom_anchor = None; - self.flash = None; - // Thumbnail drags hold page indexes from before the change. - self.panel_drag = None; - self.org_drag = None; - self.reset_coarse_state(); - } - - /// The document was replaced by a newer version of its file (#431). The reader's place - /// stays: zoom, layout, rotation, page, scroll, full screen, view history and the find bar, - /// the snapshot marker and its pending close, and the page images on screen until their - /// replacements are drawn, so nothing flashes. Everything that pointed into the old version (a selected - /// comment, a field being typed in, text being edited, a drag, page selection) starts - /// afresh, so no stale index reaches the new one. - pub fn document_replaced(&mut self, info: &DocInfo) { - let mut fresh = Self::new(self.id, info, ViewDefaults::default()); - fresh.zoom = self.zoom; - fresh.fit = self.fit; - fresh.layout = self.layout; - fresh.continuous = self.continuous; - fresh.show_page_gaps = self.show_page_gaps; - fresh.rotation = self.rotation; - fresh.current = self.current; - fresh.organize = self.organize; - fresh.grid_zoom = self.grid_zoom; - fresh.thumbnail_zoom = self.thumbnail_zoom; - // Same document id, so the scroll offset egui keeps for it is still this document's. - fresh.shown = self.shown; - fresh.highlight_fields = self.highlight_fields; - fresh.notice_dismissed = self.notice_dismissed; - fresh.snapshot = self.snapshot.clone(); - fresh.close_requested = self.close_requested; - fresh.cover = self.cover; - // Still presenting, with the display to go back to on leaving. - fresh.presentation = self.presentation.take(); - fresh.back = std::mem::take(&mut self.back); - fresh.forward = std::mem::take(&mut self.forward); - fresh.find = self.find.take(); - fresh.pages = std::mem::take(&mut self.pages); - fresh.thumbs = std::mem::take(&mut self.thumbs); - fresh.viewport_w = self.viewport_w; - fresh.viewport_h = self.viewport_h; - fresh.viewport_screen = self.viewport_screen; - *self = fresh; - // Marks the kept images out of date and clamps the page to the new page count. - self.document_changed(info); - self.page_input = self.current_page_label(info.pages.get(self.current).map(|page| page.label.as_str())); - self.page_input_reselect = true; - } - - /// Select pages in the organize grid (tests, automation). Empty clears the selection. - pub(crate) fn page_count(&self) -> usize { - self.page_count - } - - /// Drops wheel state after a discontinuous jump so the destination cannot - /// inherit momentum from the previous viewport. - fn reset_coarse_state(&mut self) { - self.wheel.clear_motion(); - self.wheel_boost_x.reset(); - self.wheel_boost_y.reset(); - } -} - -/// The renders a frame wants (page, scale, tag, tile), and the pages visible. -type PageRenders = (Vec<(usize, f32, u64, Option)>, Vec); - -/// What the page-scroll closure reads: this frame's zoom, scrolling, tool and document state. -#[derive(Clone, Copy)] -struct PageEnv<'a> { - added: &'a Vec, - allowed: bool, - author: &'a String, - auto_delta: Vec2, - avail: Rect, - boost_extra: Vec2, - by_line: &'a String, - calc_order: bool, - can_copy: bool, - can_crop: bool, - can_fill: bool, - can_modify: bool, - comments_hidden: bool, - content_h: f32, - content_w: f32, - custom_stamp: &'a Option, - date_text: &'a Result, - doc: &'a pdfcraft_engine::Document, - doc_links: &'a Vec, - edge_delta: Vec2, - editing_content: bool, - form: &'a Arc>, - generation: u64, - glide: Vec2, - hand: bool, - info: &'a DocInfo, - initials: &'a Option, - key_scroll: f32, - middle_gesture: bool, - modal: bool, - navigation_enabled: bool, - paper: Color32, - ppp: f32, - precise_scroll: Vec2, - prefs: &'a comments::CommentPrefs, - preparing: bool, - rects: &'a Vec, - redact_mark: ([f64; 3], f64), - scale: f32, - selection_wheel: bool, - selects_text: bool, - signature: &'a Option, - single_paging: bool, - t: &'a Tokens, - table_preview: &'a Option, - tag: u64, - text_style: &'a pdfcraft_engine::AddedText, - tool: QuickTool, - units: crate::units::Unit, - unobstructed: bool, - visible_pages: &'a Vec, - y_shift: f32, - fill_text_size: f64, - fill_text_color: [f64; 3], - today: (i64, u32, u32), - can_paste_image: bool, -} - -/// What the page-scroll closure produces for the rest of the frame. -#[derive(Default)] -struct PageOut { - canvas_action: Option, - clicked_link: Option, - copy_selected: bool, - field_menu: Option, - field_placed: bool, - field_props: bool, - hover_text: Option<(Pos2, String)>, - image_action: Option, - open_boxes: bool, - open_initials: bool, - open_signature: bool, - open_table: Option<(usize, [f64; 4])>, - page_action: Option, - primary_down: bool, - refused: Option, - stamp_placed: bool, -} - -/// The page-scroll closure's body: lays out and draws the visible pages, their tools and overlays, and -/// returns the renders wanted and the pages visible. -/// The state of one pass over the visible pages: what is read, and what the pages add up to. -struct PageFrame<'a> { - env: PageEnv<'a>, - resp: &'a egui::Response, - ptr: &'a PagePointer, - origin: Pos2, - visible: Rect, - pressed: bool, - pointer: Option, - signature_preview: &'a mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, - out: PageOut, - wanted: Vec<(usize, f32, u64, Option)>, - visible_now: Vec, - current: usize, - best_overlap: f32, - current_overlap: f32, - current_height: f32, - pressed_page: Option, - calc_hover: Option, -} - -/// Visible heights (points) closer than this are equal: one page size can meet the viewport a -/// fraction of a point differently once the scroll offset is applied. -const PAGE_TIE: f32 = 0.5; - -/// Draws page `i` of the visible pages: its raster, the tools' input and overlays. -/// One visible page: its index, its rectangle on screen and the transform to and from the page. -#[derive(Clone, Copy)] -struct PageSlot { - i: usize, - r: Rect, - xf: PageXform, -} - -/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders -/// it wants. -#[allow(clippy::too_many_arguments)] -fn paint_image_suppressed( - xf: &PageXform, - painter: &egui::Painter, - tex: egui::TextureId, - u0: f32, - v0: f32, - u1: f32, - v1: f32, - suppress: Option<[f32; 4]>, - is_full_page_tex: bool, -) { - let Some([su0, sv0, su1, sv1]) = suppress else { - if is_full_page_tex { - xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1); - } else { - xf.paint_image(painter, tex, u0, v0, u1, v1); - } - return; - }; - let iu0 = u0.max(su0); - let iu1 = u1.min(su1); - let iv0 = v0.max(sv0); - let iv1 = v1.min(sv1); - if iu0 >= iu1 || iv0 >= iv1 { - if is_full_page_tex { - xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1); - } else { - xf.paint_image(painter, tex, u0, v0, u1, v1); - } - return; - } - let map_uv = |pu: f32, pv: f32| -> (f32, f32) { - if is_full_page_tex { - (pu, pv) - } else { - let du = (u1 - u0).max(1e-6); - let dv = (v1 - v0).max(1e-6); - ((pu - u0) / du, (pv - v0) / dv) - } - }; - let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| { - if pu1 <= pu0 || pv1 <= pv0 { - return; - } - let (tu0, tv0) = map_uv(pu0, pv0); - let (tu1, tv1) = map_uv(pu1, pv1); - xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1); - }; - - if iv0 > v0 { - draw_strip(u0, v0, u1, iv0); - } - if v1 > iv1 { - draw_strip(u0, iv1, u1, v1); - } - if iu0 > u0 { - draw_strip(u0, iv0, iu0, iv1); - } - if u1 > iu1 { - draw_strip(iu1, iv0, u1, iv1); - } -} - -fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) { - let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env; - let PageSlot { i, r, xf } = *slot; - let visible = frame.visible; - let info = frame.env.info; - let painter = ui.painter(); - // Shadows would darken the preceding page when pages touch. - if view.show_page_gaps { - painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); - } - painter.rect_filled(r, CornerRadius::ZERO, paper); - if let Some(err) = view.errors.get(&i) { - painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); - icons::paint( - ui, - Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), - "triangle-alert", - 26.0, - Color32::from_rgb(0xC8, 0x3A, 0x3A), - ); - let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); - let msg = ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); - painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); - } else { - let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); - // A GPU may take smaller textures than these (OpenGL drivers report as little as - // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. - let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; - let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); - // Whole-page raster: sharp when small, a low-res backdrop when tiled. - let (want_scale, want_tag) = if tiled { - let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); - (bs, scale_tag(bs)) - } else { - (scale, tag) - }; - // Under tiles, a whole-page raster sharper than the backdrop (from before the page - // grew past the tiling size) is a better backdrop: keep demanding it as it is. - match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { - Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)), - None => frame.wanted.push((i, want_scale, want_tag, None)), - } - let doc = frame.env.doc; - let active_drag_suppress = if let Some(sel) = view.image_selection.as_ref() - && sel.page == i - && sel.drag.is_some() - { - let images = doc.page_images(i); - if let Some(img) = images.get(sel.index) { - let obb = ImageObb::from_image(img, &xf, info, i); - let uvs = obb.corners().map(|c| xf.screen_to_norm(c)); - let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0); - let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0); - let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0); - let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0); - if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None } - } else { - None - } - } else { - None - }; - match view.pages.get(&i) { - Some(p) => { - if !tiled && p.tag == want_tag { - paint_image_suppressed( - &xf.texel_aligned(p.tex.size(), ppp), - painter, - p.tex.id(), - 0.0, - 0.0, - 1.0, - 1.0, - active_drag_suppress, - true, - ); - } else { - // A backdrop, or a raster at an older scale until the new one arrives. - paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true); - } - } - None => { - let now = ui.input(|inp| inp.time); - let since = *view.waiting_since.entry(i).or_insert(now); - let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; - painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); - } - } - if tiled && r.intersects(visible) { - paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress); - } - } -} - -/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, -/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing. -fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32), active_drag_suppress: Option<[f32; 4]>) { - let PageEnv { ppp, scale, tag, .. } = frame.env; - let PageSlot { i, r, xf } = *slot; - let visible = frame.visible; - let painter = ui.painter(); - // Device-pixel geometry of the scaled page, and the visible part of it - // (found by mapping the visible screen corners back into the page). Sides round - // up as whole-page rasters do, so the last partial row and column get tiles. - let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); - // Adaptive tile side for this zoom and page, within the GPU texture limit: - // a zoomed viewport or a giant sheet is covered by a few large tiles - // instead of many small ones, and each tile costs one full page interpret. - // Never zero (u32 division below). - let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); - let vis = r.intersect(visible); - let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); - let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); - let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); - let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); - let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); - // Tiles of earlier zooms first, stretched, the nearest scale last (on top): - // until the current tiles arrive, a zoom shows them rather than the backdrop. - let mut older: Vec<(f32, u32, u32, &TextureHandle)> = - view.tiles.iter().filter(|((p, t, _, _), _)| *p == i && *t != tag).map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)).collect(); - older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); - for (s, ox, oy, tex) in older { - let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); - let [w, h] = tex.size(); - let (x, y) = (ox as f32, oy as f32); - paint_image_suppressed( - &xf, - painter, - tex.id(), - x / ow, - y / oh, - (x + w as f32) / ow, - (y + h as f32) / oh, - active_drag_suppress, - false, - ); - } - // Visible tiles, nearest the viewport centre first: each tile costs a full - // page interpret, so the workers sharpen what is looked at before the edges. - let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); - let mut coords: Vec<(u32, u32)> = Vec::new(); - for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { - for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { - coords.push((tx, ty)); - } - } - coords.sort_by(|a, b| { - let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); - let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); - da.total_cmp(&db) - }); - for (tx, ty) in coords { - let (x, y) = (tx * ts, ty * ts); - let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); - if w == 0 || h == 0 { - continue; - } - frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); - if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { - let (fw, fh) = (dw as f32, dh as f32); - paint_image_suppressed( - &xf.texel_aligned([dw as usize, dh as usize], ppp), - painter, - tex.id(), - x as f32 / fw, - y as f32 / fh, - (x + w) as f32 / fw, - (y + h) as f32 / fh, - active_drag_suppress, - false, - ); - } - } -} - -/// What the page's tools did with the pointer this frame. -struct PageInput { - /// A tool, a field or a comment took the pointer: text selection must leave it alone. - consumed: bool, - /// Redact or Highlight draws boxes off text. - area_tool: bool, -} - -/// Common geometry and pointer state for a tool acting on one page. -#[derive(Clone, Copy)] -pub(crate) struct PageToolContext<'a> { - pub ui: &'a egui::Ui, - pub resp: &'a egui::Response, - pub xf: &'a PageXform, - pub ptr: &'a PagePointer, - pub page: usize, - pub doc: &'a pdfcraft_engine::Document, - pub info: &'a DocInfo, -} - -/// Comments: tools, selection, moving and resizing come before text selection. Form fields take -/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then -/// comments; then text selection. -fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { - let PageEnv { - added, - allowed, - author, - by_line, - can_crop, - can_fill, - can_modify, - comments_hidden, - custom_stamp, - date_text, - doc, - doc_links, - editing_content, - form, - info, - initials, - prefs, - preparing, - redact_mark, - signature, - table_preview, - text_style, - tool, - units, - fill_text_size, - fill_text_color, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - let (lines, images) = - if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let hits = PageHits { - page: i, - xf: &xf, - info, - tool, - pcx: &pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines: &lines, - images: &images, - clip: ui.clip_rect(), - }; - if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); - view.router.press(i, at, tool); - } - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - frame.out.stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - frame.out.stamp_placed = true; - } - } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). - if let QuickTool::Fill(ft) = tool - && allowed - && !comments::fill_grabs(&pcx, view) - { - match crate::fill_sign::page_input( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } - let on_field = if preparing { - let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); - frame.out.field_props |= o.properties; - frame.out.field_placed |= o.placed; - frame.out.open_table = frame.out.open_table.or(o.table); - o.consumed || field_tool.is_some() - } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) - }; - // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). - let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; - let boxing = area_tool && { - let text = view.page_text(i); - let over_text = |p: Pos2| { - let (vx, vy) = xf.screen_to_view(p); - text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) - }; - crate::redact_ui::page_input(cx, over_text, view) - }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - // Add text acts on the page's own paragraphs over them, and on the one its press was on until - // the button is up: a drag that leaves the box still ends, and no new box takes it over. - let over_source = tool == QuickTool::AddText - && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) - || pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) - })); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; - } - let on_content = !on_objects && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - cx, - added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - text_style, - view, - ) - || tool == QuickTool::AddText && !over_source - }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); - let image_cx = crate::edit_text_ui::ImageCx { - ui, - resp, - xf: &xf, - ptr, - page: i, - info, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; - let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); - !others_own_pointer && (text_used || image_used) - }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); - if tool == QuickTool::Select - && resp.secondary_clicked() - && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) - { - view.links.context = Some((i, l.index)); - } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); - // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the - // last claim on a layer stands). - if frame.env.unobstructed - && let Some(p) = pointer - { - for h in hits.at(view, p) { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip); - } - } - PageInput { consumed, area_tool } -} - -/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. -fn edit_inventory( - view: &mut DocView, - doc: &pdfcraft_engine::Document, - i: usize, -) -> (Vec, Vec) { - let generation = doc.edit_generation(); - let lines = match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - }; - let images = match view.edit_images.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.page_images(i); - view.edit_images.insert(i, (generation, l.clone())); - l - } - }; - (lines, images) -} - -/// What the page modules have under a point on one page, for the page router. -struct PageHits<'a> { - page: usize, - xf: &'a PageXform, - info: &'a DocInfo, - tool: QuickTool, - pcx: &'a comments::PageCx<'a>, - form: &'a [pdfcraft_engine::FormField], - can_fill: bool, - can_modify: bool, - /// Edit a PDF is open: added text and pictures can be picked up. - editing_content: bool, - preparing: bool, - field_tool: Option, - /// Prepare may draw and move fields (not while a table is being set up). - prepare_allowed: bool, - added: &'a [pdfcraft_engine::Added], - lines: &'a [pdfcraft_engine::TextBlock], - images: &'a [pdfcraft_engine::PageImage], - /// The pages' visible area (the picture's rotate knob stays inside it). - clip: Rect, -} - -impl PageHits<'_> { - /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text - /// on top of artwork takes the press), added content, the objects selected together and the - /// open paragraph's frame, the selected picture's grips over them all; then form fields - /// (Prepare's, being moved, over the comments; filled ones under added content and the - /// comments that move). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - let PageHits { - page, - xf, - info, - tool, - pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines, - images, - clip, - } = *self; - let fields = if preparing { - crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) - } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, can_fill, p) - } else { - None - }; - // With Add text, only the paragraphs and pictures not under added content. - let editing = - can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); - let (pictures, paragraphs, objects, grips) = if editing { - let open = view.line_editor.as_ref().filter(|e| e.page == page); - let frame = open - .filter(|e| !e.has_changes()) - .and_then(|e| lines.get(e.block)) - .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); - // Add text acts on the page's paragraphs only in their boxes, edges included (it - // finishes the box being typed there, `over_source`): not on pictures, nor beside a - // paragraph, where a click starts a new box. - let edit_text = tool == QuickTool::EditText; - let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); - ( - edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), - on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), - edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), - edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), - ) - } else { - (None, None, None, None) - }; - // Added text and pictures, over the page's own paragraphs and under the objects selected - // together. - let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); - let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() - } -} - -/// Text layer: find matches, selection, I-beam and drag-to-select. -fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { info, selects_text, tool, .. } = frame.env; - let PageSlot { i, r, xf } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - if let Some(text) = view.texts.get(&i).cloned() { - paint_text_marks(ui, view, &text, i, xf, resp.dragged()); - if selects_text - && !input.consumed - && let Some(p) = pointer.filter(|p| r.contains(*p)) - { - text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p }); - } - } -} - -fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { - let painter = ui.painter(); - let to_screen = |g: [f32; 4]| xf.view_rect(g); - if let Some(find) = &view.find { - for (index, (match_page, range)) in find.matches.iter().enumerate() { - if *match_page != page { - continue; - } - let fill = if find.current == Some(index) { - Color32::from_rgba_unmultiplied(255, 140, 0, 110) - } else { - Color32::from_rgba_unmultiplied(255, 214, 0, 90) - }; - for line_rect in text.line_rects(range.clone()) { - painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); - } - } - } - if view.all_text { - for line_rect in text.line_rects(0..text.glyphs.len()) { - painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - } - if let Some(selection) = view.selection.filter(|selection| selection.page == page) { - for line_rect in selection.rects(text) { - painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - if let Span::Column { rect } = selection.span - && dragging - { - let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); - painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); - } - } -} - -struct TextPointer<'a> { - page: usize, - xf: PageXform, - rect: Rect, - info: &'a DocInfo, - tool: QuickTool, - resp: &'a egui::Response, - ptr: PagePointer, - pointer: Pos2, -} - -struct TextGesture<'a> { - page: usize, - xf: PageXform, - rect: Rect, - tool: QuickTool, - ptr: PagePointer, - resp: &'a egui::Response, - pointer: Pos2, - vx: f32, - vy: f32, - over_text: bool, - over_link: bool, - column: bool, -} - -fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { - let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer; - let (vx, vy) = xf.screen_to_view(screen_pointer); - let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); - let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); - let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); - let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; - update_text_cursor(view, &gesture); - update_selection_on_press(ui, view, text, &gesture); - update_dragged_selection(view, text, &gesture); - finish_text_pointer_gesture(ui, view, text, &gesture); -} - -fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { - // A press on a link selects nothing (it is the link's), so no cursor here says it would. - if gesture.column && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); - } else if gesture.over_text && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Text); - } -} - -fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on - // text markup lets its drag select the text underneath. - let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { - let now = ui.input(|input| input.time); - let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); - let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); - view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; - view.last_press_time = now; - view.last_press_pos = Some(gesture.pointer); - let clicks = selection_click_count(view.clicks, gesture.tool); - if clicks >= 2 - && gesture.over_text - && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) - { - view.selection = Some(Selection::run(gesture.page, first, last)); - } - } - if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { - view.actions_dismissed = None; - view.selection_drag = true; - let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); - let (ox, oy) = gesture.xf.screen_to_view(origin); - if view.clicks < 2 { - view.selection = if gesture.column { - Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) - } else { - text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) - }; - } - } -} - -fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { - if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } -} - -fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - let clicks = selection_click_count(view.clicks, gesture.tool); - if gesture.resp.dragged() - && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) - { - match &mut selection.span { - Span::Run { anchor, head } if clicks >= 2 => { - if let Some(hit) = text.nearest(gesture.vx, gesture.vy) - && let Some((first, last)) = click_unit(text, clicks, *anchor) - { - let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); - (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; - } - } - Span::Run { head, .. } => { - if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { - *head = hit; - } - } - Span::Column { rect } => { - rect[2] = gesture.vx; - rect[3] = gesture.vy; - } - } - } - if gesture.resp.drag_stopped() - && view.selection.is_some_and(|selection| { - selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() - }) - { - view.selection = None; - } -} - -fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - let release_clicks = if gesture.resp.triple_clicked() { - view.clicks.max(2).saturating_add(1) - } else if gesture.resp.double_clicked() { - view.clicks.max(2) - } else { - 0 - }; - if release_clicks > 0 { - view.clicks = release_clicks; - } - let clicks = selection_click_count(view.clicks, gesture.tool); - if release_clicks > 0 - && clicks >= 2 - && gesture.over_text - && view.selection.is_none() - && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) - { - view.selection = Some(Selection::run(gesture.page, first, last)); - } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { - let shift = ui.input(|input| input.modifiers.shift); - match ( - view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), - text.nearest(gesture.vx, gesture.vy), - ) { - (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, - _ => view.selection = None, - } - } -} - -/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks -/// and badges. -fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { - added, - allowed, - calc_order, - can_modify, - comments_hidden, - doc, - doc_links, - editing_content, - form, - generation, - info, - prefs, - preparing, - redact_mark, - table_preview, - tool, - unobstructed, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let painter = ui.painter(); - let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if tool == QuickTool::Select && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); - } - crate::image_pick::drop_stale(doc, view); - crate::image_pick::paint(painter, &xf, i, info, view); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { - comments::page_after_text(resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(resp, i, info, view); - } - } - if input.area_tool { - crate::redact_ui::paint(painter, i, view, pointer); - } - comments::paint_page(ui, painter, &pcx, view); - if editing_content { - crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); - } - if tool == QuickTool::EditText { - crate::object_ui::paint(painter, &xf, i, info, &view.objects); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::paint(painter, &xf, i, view); - } - if tool == QuickTool::Link { - crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); - } - if preparing { - crate::prepare::paint_page(tool_cx, form, view); - if let Some(preview) = &table_preview { - crate::field_table_ui::paint_preview(painter, &xf, i, preview); - } - if calc_order { - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { - frame.calc_hover = Some(field); - } - } - } else { - crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); - } - - // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as - // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a - // radio button's own) is left as it is drawn. - paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); - // Links: the hand and the link's target, which count only for the tools that follow or - // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is - // open, even beside it. A click follows the link its press was on, released over it: one that - // slips off the link first follows nothing, as a button's does. - if unobstructed - && resp.clicked() - && tool == QuickTool::Select - && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) - { - frame.out.clicked_link = Some(l.target.clone()); - } - // Comments say what they are with any tool, beside its cursor, as Acrobat's do. - comments::describe(&pcx, view); - for (mp, mr, mc) in &view.compare_marks { - if *mp == i { - let sr = xf.user_rect(info, i, *mr).expand(1.5); - painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); - } - } - if let Some((fp, fr, t0)) = view.flash - && fp == i - { - let now = ui.input(|inp| inp.time); - let t0 = if t0 == 0.0 { now } else { t0 }; - view.flash = Some((fp, fr, t0)); - let age = (now - t0) as f32; - if age < 1.6 { - let a = ((1.6 - age) / 1.6 * 255.0) as u8; - let sr = xf.user_rect(info, i, fr).expand(4.0); - painter.rect_stroke( - sr, - CornerRadius::same(3), - Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } else { - view.flash = None; - } - } - // Validation is viewer chrome over the signer's original artwork, which - // may still say "Signature Not Verified". These badges never enter document - // streams, image/text exports, vector printing or Print as Image. - for badge in signature_badges(&doc.signatures, i) { - let [x0, y0, x1, y1] = badge.rect; - let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); - let (icon, color) = crate::sign_ui::signature_status_style(badge.status); - painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); - // A widget too small for the mark keeps its outline: the verdict still shows. - if sr.width() < 4.0 || sr.height() < 4.0 { - continue; - } - // Big enough to read at a glance, small enough to leave the signer's own - // text visible around it. - let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); - let mark = Rect::from_center_size(sr.center(), vec2(side, side)); - icons::paint(ui, mark, icon, side * 0.64, color); - // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with - // any tool; a link's or a comment's tip wins over any badge. - if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { - view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); - } - } -} - -fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { - if !enabled { - return; - } - for field in form.iter() { - let required = field.has(pdfcraft_engine::field_flags::REQUIRED); - for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { - let rect = widget.rect; - let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); - let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); - let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if widget.round { - let radius = screen_rect.width().min(screen_rect.height()) / 2.0; - painter.circle_filled(screen_rect.center(), radius, tint); - painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); - } else { - painter.rect_filled(screen_rect, CornerRadius::same(1), tint); - painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); - } - } - } -} - -fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { - let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; - let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; - let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); - if !r.intersects(visible.expand(400.0)) { - return; - } - if pressed && frame.pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { - frame.pressed_page = Some(i); - } - if r.intersects(visible) { - frame.visible_now.push(i); - view.screen_rects.push((i, r)); - view.screen_xforms.push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); - } - let overlap = r.intersect(visible).height(); - if i == view.current { - frame.current_overlap = overlap; - frame.current_height = r.height(); - } - // Pages shown equally (two rows wholly on screen) differ only by rounding: the - // topmost one counts. - if overlap > frame.best_overlap + PAGE_TIE { - frame.best_overlap = overlap; - frame.current = i; - } - let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; - paint_page_surface(ui, view, frame, &PageSlot { i, r, xf }); - let painter = ui.painter(); - view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); - if editing_content { - view.move_background.paint(painter, &xf, i, info, &view.content, pointer); - } - if view.show_page_gaps { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); - } - - let slot = PageSlot { i, r, xf }; - // A cancelled press takes nothing until the button is up. - let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; - let painter = ui.painter(); - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); - view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); - view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); - // Any other comment being moved: drawn where it goes, over the page that still shows it - // where it was. - let stale = view.signature_drag.moving_page().is_some_and(|p| view.page_out_of_date(p)); - view.signature_drag.prepare_move(ui.ctx(), doc, &view.comments, view.pending_edit.as_ref(), stale, scale); - view.signature_drag.paint_move(painter, &pcx, &view.comments, view.pending_edit.as_ref()); - - // Any press on the page ends Select All of the document: text read or not, any tool, - // on a link too. - // A press that lands on something drawn over the page (a menu, a dialog) is not one. - if view.all_text && frame.pressed_page == Some(i) { - view.all_text = false; - } - text_layer(ui, view, frame, &slot, &input); - page_overlays(ui, view, frame, &slot, &input); -} - -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - let label = match &l.target { - LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - -/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is -/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The -/// frame of the release still belongs to the cancelled press. -fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { - // Over once the button is up and its release seen, or when a new press comes (its release and - // the next press can share a frame). - let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); - if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { - view.press_cancelled = false; - } - if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - // A text selection being dragged goes with its drag: the markup, redaction or Replace - // Text that its end would make is not made. - if view.selection_drag { - view.clear_selection(); - } - view.cancel_pointer_gestures(); - view.press_cancelled = true; - } -} - +//! The document area: page layout, zoom, render scheduling, overlays (links, annotation hovers, +//! form-field highlights), and the organize-pages grid. +//! +//! Page images come from the engine's render pool as whole-page rasters at the current zoom; +//! stale rasters are shown stretched until the sharp one arrives (tiling comes in M3.3). + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::ops::Range; +use std::sync::Arc; + +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; +use pdfcraft_engine::{DocId, Edit}; +use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; + +use crate::page_cursor::{Layer, Subject}; +use crate::page_pointer::PagePointer; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; + +mod area; +pub(super) mod autoscroll; +mod bars; +mod cache; +pub(super) mod crop; +mod find; +pub(super) mod fling; +pub(super) mod image_pick; +mod navigation; +mod organize; +pub(super) mod page_cursor; +pub(super) mod page_pointer; +pub(super) mod page_router; +pub mod scroll_speed; +mod selection; +pub(super) mod signature_drag; +pub(super) mod wheel_boost; +pub(super) mod wheel_pager; +mod xform; +mod zoom; +pub(super) mod zoom_snap; + +pub(crate) use area::signature_badges; +use area::{ + SUPPRESS_SECS, SUPPRESS_TAIL_SECS, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor, +}; +#[cfg(test)] +use bars::in_size_hint_zone; +use bars::{Notice, notices, page_size_hint, quick_bar}; +pub use cache::RasterMemory; +pub(crate) use cache::{BASE_SIDE, TILE_THRESHOLD, texture_image, thumbnail_rows}; +#[cfg(test)] +use cache::{ + GRAYSCALE_TAG, GRID_TAG, MAX_CACHED_TEXT_PAGES, MAX_THUMB_DEMAND, PAGE_BYTES, THUMB_BYTES, THUMB_H, THUMB_TAG, THUMB_W, TILE, grayscale, + rgba_bytes, +}; +use cache::{OLD_TILES, PageTex, STALE_TAG, TEXT_TAG, TILE_BYTES, scale_tag, tag_scale, texture_bytes, tile_side}; +pub use find::Find; +use find::find_bar; +pub use navigation::shortcuts; +pub(crate) use organize::drop_gap; +#[cfg(test)] +pub(crate) use organize::grid_page_rect; +use organize::organize_grid; +#[cfg(test)] +use organize::sharp_enough; +use selection::{Selection, Span, click_unit, selection_actions}; +pub use xform::PageXform; +use xform::snap_to_pixels; +use zoom::anchored_scroll; +pub use zoom::{GRID_ZOOM_RANGE, THUMBNAIL_ZOOM_RANGE}; + +/// Logical pixels per PDF point at 100% (96 dpi, like browsers). +pub const PT: f32 = 96.0 / 72.0; +const GAP: f32 = 14.0; +const MARGIN: f32 = 28.0; +/// Horizontal gutter that keeps pages clear of the floating quick-action bar. +const SIDE: f32 = 70.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Fit { + Width, + Page, + Height, + None, +} + +use crate::edit_text_ui::ImageHandle; + +/// Extension trait on `egui::Painter` to draw polygons. +pub trait PainterExt { + fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx; +} + +impl PainterExt for egui::Painter { + fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx { + self.add(egui::Shape::convex_polygon(points, fill, stroke)) + } +} + +/// User-space selectable bounding box for an image object. +/// If the image object has an active clipping path applied to it (the crop box), +/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. +pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { + if let Some(clip) = im.clip_rect { + let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; + if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { + return r; + } + } + im.visible_rect.unwrap_or(im.rect) +} + +/// Screen-space selectable bounding box for an image object. +/// If the image object has an active clipping path applied to it (the crop box), +/// the final selectable `egui::Rect` is the mathematical intersection of the image's +/// full CTM-derived rect and the clipping path's rect. +pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { + let full = xf.user_rect_f64(info, page, im.rect); + if let Some(clip) = im.clip_rect { + let clip_r = xf.user_rect_f64(info, page, clip); + let inter = full.intersect(clip_r); + if inter.is_positive() { + return inter; + } + } + if let Some(vr) = im.visible_rect { + let r = xf.user_rect_f64(info, page, vr); + if r.is_positive() { + return r; + } + } + full +} + +/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, +/// with 8 resize handles and rotated local axes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImageObb { + pub s00: Pos2, + pub s10: Pos2, + pub s11: Pos2, + pub s01: Pos2, + pub s_bc: Pos2, + pub s_rc: Pos2, + pub s_tc: Pos2, + pub s_lc: Pos2, + pub s_rot: Pos2, + pub screen_center: Pos2, + pub width: f32, + pub height: f32, + pub theta: f32, + pub u_axis: (f64, f64), + pub v_axis: (f64, f64), + pub is_rotated: bool, +} + +impl ImageObb { + /// Calculates the 4 rotated corners manually using center, width, height, and rotation angle (θ). + /// Local axes: `u_axis = (cos θ, sin θ)` and `v_axis = (-sin θ, cos θ)`. + /// Places the 8 resize handles exactly at these rotated vertices and midpoints. + pub fn from_center_size_angle(center: Pos2, width: f32, height: f32, theta: f32) -> Self { + let cos_t = theta.cos(); + let sin_t = theta.sin(); + let u_axis = (cos_t as f64, sin_t as f64); + let v_axis = (-sin_t as f64, cos_t as f64); + let u_vec = vec2(cos_t, sin_t); + let v_vec = vec2(-sin_t, cos_t); + let hw = width * 0.5; + let hh = height * 0.5; + + let s00 = center - u_vec * hw - v_vec * hh; + let s10 = center + u_vec * hw - v_vec * hh; + let s11 = center + u_vec * hw + v_vec * hh; + let s01 = center - u_vec * hw + v_vec * hh; + + let s_bc = center - v_vec * hh; + let s_rc = center + u_vec * hw; + let s_tc = center + v_vec * hh; + let s_lc = center - u_vec * hw; + + let s_rot = s_tc + v_vec * 24.0; + let is_rotated = theta.abs() > 1e-4; + + Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center: center, width, height, theta, u_axis, v_axis, is_rotated } + } + + pub fn new(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self { + Self::from_matrix(matrix, xf, info, page) + } + + pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self { + let m = crate::edit_text_ui::Matrix(matrix); + let p00 = m.apply(0.0, 0.0); + let p10 = m.apply(1.0, 0.0); + let p11 = m.apply(1.0, 1.0); + let p01 = m.apply(0.0, 1.0); + + let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1); + let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1); + let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1); + let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1); + + let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5); + let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5); + let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5); + let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5); + + let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25); + let out_dir = s_tc - screen_center; + let out_len = out_dir.length().max(1e-4); + let out_norm = out_dir / out_len; + let s_rot = s_tc + out_norm * 24.0; + + let [a, b, c, d, ..] = matrix; + let w_u = (a * a + b * b).sqrt().max(1e-6); + let h_v = (c * c + d * d).sqrt().max(1e-6); + let u_axis = (a / w_u, b / w_u); + let v_axis = (c / h_v, d / h_v); + let theta = (b).atan2(a) as f32; + let is_rotated = b.abs() > 1e-4 || c.abs() > 1e-4; + + Self { + s00, + s10, + s11, + s01, + s_bc, + s_rc, + s_tc, + s_lc, + s_rot, + screen_center, + width: (s10 - s00).length(), + height: (s01 - s00).length(), + theta, + u_axis, + v_axis, + is_rotated, + } + } + + pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self { + let [_, b, c, ..] = im.matrix; + let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4; + let bounds = image_selectable_bounds(im); + let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] { + [bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]] + } else { + im.matrix + }; + Self::from_matrix(matrix, xf, info, page) + } + + pub fn handles(&self) -> [(ImageHandle, Pos2); 8] { + [ + (ImageHandle::TopLeft, self.s01), + (ImageHandle::TopCenter, self.s_tc), + (ImageHandle::TopRight, self.s11), + (ImageHandle::RightCenter, self.s_rc), + (ImageHandle::BottomRight, self.s10), + (ImageHandle::BottomCenter, self.s_bc), + (ImageHandle::BottomLeft, self.s00), + (ImageHandle::LeftCenter, self.s_lc), + ] + } + + pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 { + match handle { + ImageHandle::TopLeft => self.s01, + ImageHandle::TopCenter => self.s_tc, + ImageHandle::TopRight => self.s11, + ImageHandle::RightCenter => self.s_rc, + ImageHandle::BottomRight => self.s10, + ImageHandle::BottomCenter => self.s_bc, + ImageHandle::BottomLeft => self.s00, + ImageHandle::LeftCenter => self.s_lc, + } + } + + pub fn handle_id(&self, base_id: egui::Id, handle: ImageHandle) -> egui::Id { + base_id.with(match handle { + ImageHandle::TopLeft => "handle_tl", + ImageHandle::TopCenter => "handle_tc", + ImageHandle::TopRight => "handle_tr", + ImageHandle::RightCenter => "handle_rc", + ImageHandle::BottomRight => "handle_br", + ImageHandle::BottomCenter => "handle_bc", + ImageHandle::BottomLeft => "handle_bl", + ImageHandle::LeftCenter => "handle_lc", + }) + } + + pub fn handles_with_ids(&self, base_id: egui::Id) -> [(ImageHandle, Pos2, egui::Id); 8] { + [ + (ImageHandle::TopLeft, self.s01, self.handle_id(base_id, ImageHandle::TopLeft)), + (ImageHandle::TopCenter, self.s_tc, self.handle_id(base_id, ImageHandle::TopCenter)), + (ImageHandle::TopRight, self.s11, self.handle_id(base_id, ImageHandle::TopRight)), + (ImageHandle::RightCenter, self.s_rc, self.handle_id(base_id, ImageHandle::RightCenter)), + (ImageHandle::BottomRight, self.s10, self.handle_id(base_id, ImageHandle::BottomRight)), + (ImageHandle::BottomCenter, self.s_bc, self.handle_id(base_id, ImageHandle::BottomCenter)), + (ImageHandle::BottomLeft, self.s00, self.handle_id(base_id, ImageHandle::BottomLeft)), + (ImageHandle::LeftCenter, self.s_lc, self.handle_id(base_id, ImageHandle::LeftCenter)), + ] + } + + pub fn corners(&self) -> [Pos2; 4] { + [self.s00, self.s10, self.s11, self.s01] + } + + /// Resizes the OBB proportionally along its local axes using projected mouse delta, + /// locking the diagonally opposite corner as the fixed anchor point. + pub fn scale_proportional(&self, handle: ImageHandle, delta: Vec2, min_size: f32) -> Self { + let u_vec = vec2(self.u_axis.0 as f32, self.u_axis.1 as f32); + let v_vec = vec2(self.v_axis.0 as f32, self.v_axis.1 as f32); + let delta_u = delta.dot(u_vec); + let delta_v = delta.dot(v_vec); + let min_s = min_size.max(5.0); + + match handle { + ImageHandle::TopRight => { + let anchor = self.s00; + let s_u = (self.width + delta_u) / self.width.max(1e-4); + let s_v = (self.height + delta_v) / self.height.max(1e-4); + let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; + let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); + let nw = self.width * s; + let nh = self.height * s; + let new_center = anchor + u_vec * (nw * 0.5) + v_vec * (nh * 0.5); + Self::from_center_size_angle(new_center, nw, nh, self.theta) + } + ImageHandle::TopLeft => { + let anchor = self.s10; + let s_u = (self.width - delta_u) / self.width.max(1e-4); + let s_v = (self.height + delta_v) / self.height.max(1e-4); + let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; + let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); + let nw = self.width * s; + let nh = self.height * s; + let new_center = anchor - u_vec * (nw * 0.5) + v_vec * (nh * 0.5); + Self::from_center_size_angle(new_center, nw, nh, self.theta) + } + ImageHandle::BottomLeft => { + let anchor = self.s11; + let s_u = (self.width - delta_u) / self.width.max(1e-4); + let s_v = (self.height - delta_v) / self.height.max(1e-4); + let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; + let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); + let nw = self.width * s; + let nh = self.height * s; + let new_center = anchor - u_vec * (nw * 0.5) - v_vec * (nh * 0.5); + Self::from_center_size_angle(new_center, nw, nh, self.theta) + } + ImageHandle::BottomRight => { + let anchor = self.s01; + let s_u = (self.width + delta_u) / self.width.max(1e-4); + let s_v = (self.height - delta_v) / self.height.max(1e-4); + let s = if (s_u - 1.0).abs() >= (s_v - 1.0).abs() { s_u } else { s_v }; + let s = s.max(min_s / self.width.max(1e-4)).max(min_s / self.height.max(1e-4)); + let nw = self.width * s; + let nh = self.height * s; + let new_center = anchor + u_vec * (nw * 0.5) - v_vec * (nh * 0.5); + Self::from_center_size_angle(new_center, nw, nh, self.theta) + } + ImageHandle::RightCenter => { + let anchor = self.s_lc; + let nw = (self.width + delta_u).max(min_s); + let new_center = anchor + u_vec * (nw * 0.5); + Self::from_center_size_angle(new_center, nw, self.height, self.theta) + } + ImageHandle::LeftCenter => { + let anchor = self.s_rc; + let nw = (self.width - delta_u).max(min_s); + let new_center = anchor - u_vec * (nw * 0.5); + Self::from_center_size_angle(new_center, nw, self.height, self.theta) + } + ImageHandle::TopCenter => { + let anchor = self.s_bc; + let nh = (self.height + delta_v).max(min_s); + let new_center = anchor + v_vec * (nh * 0.5); + Self::from_center_size_angle(new_center, self.width, nh, self.theta) + } + ImageHandle::BottomCenter => { + let anchor = self.s_tc; + let nh = (self.height - delta_v).max(min_s); + let new_center = anchor - v_vec * (nh * 0.5); + Self::from_center_size_angle(new_center, self.width, nh, self.theta) + } + } + } +} + +/// Projects mouse delta onto local unit axes `u_axis` and `v_axis` using the dot product. +pub fn project_delta(delta: Vec2, u_axis: (f64, f64), v_axis: (f64, f64)) -> (f64, f64) { + let dx = delta.x as f64; + let dy = delta.y as f64; + (dx * u_axis.0 + dy * u_axis.1, dx * v_axis.0 + dy * v_axis.1) +} + +/// The page display as it was before full screen, put back on leaving it +/// ([`DocView::enter_presentation`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SavedDisplay { + pub layout: PageLayout, + pub continuous: bool, + pub cover: bool, + pub fit: Fit, + pub zoom: f32, +} + +/// The base page display: one page across, or a two-page spread. Whether it scrolls continuously +/// is a separate flag (`continuous`), as in Acrobat, so every combination is reachable (#752). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PageLayout { + Single, + TwoUp, +} + +impl PageLayout { + /// The base page displays, in the order the View menu, the rail's Page display button and + /// Preferences list them. + pub const ORDER: [Self; 2] = [Self::Single, Self::TwoUp]; + + /// The name settings and view options use for the base layout: `single` or `two-up`. + pub fn as_str(self) -> &'static str { + match self { + Self::Single => "single", + Self::TwoUp => "two-up", + } + } + + /// The base layout an [`as_str`](Self::as_str) name stands for, in any case. `None` for + /// anything else, so callers can reject a typo rather than switch layouts silently. + pub fn try_parse(value: &str) -> Option { + Self::ORDER.into_iter().find(|l| l.as_str().eq_ignore_ascii_case(value.trim())) + } + + /// Rail icon for this layout. + pub fn icon(self) -> &'static str { + match self { + Self::Single => "file-text", + Self::TwoUp => "columns-2", + } + } + + /// Registry command that switches to this base layout (`PdfCraftApp::execute`). + pub fn command(self) -> &'static str { + match self { + Self::Single => "view.layout.single", + Self::TwoUp => "view.layout.two_up", + } + } + + /// The base layout a [`command`](Self::command) id switches to. + pub fn from_command(id: &str) -> Option { + Self::ORDER.into_iter().find(|l| l.command() == id) + } + + /// Menu label: the label of [`command`](Self::command), which the catalogs translate. + pub fn label(self) -> &'static str { + match self { + Self::Single => "Single page", + Self::TwoUp => "Two-page view", + } + } +} + +/// The single page-display token settings and the control channel use, combining the base layout +/// with continuous scrolling. `single` and `two-page` show one page or one spread at a time; +/// `continuous` (Single, scrolling) and `two-up` (Two-page, scrolling) keep their earlier names +/// for back-compat. +pub fn display_token(layout: PageLayout, continuous: bool) -> &'static str { + match (layout, continuous) { + (PageLayout::Single, false) => "single", + (PageLayout::Single, true) => "continuous", + (PageLayout::TwoUp, true) => "two-up", + (PageLayout::TwoUp, false) => "two-page", + } +} + +/// The base layout and continuous flag a [`display_token`] names, in any case. `None` for a typo. +pub fn parse_display_token(value: &str) -> Option<(PageLayout, bool)> { + match value.trim().to_ascii_lowercase().as_str() { + "single" => Some((PageLayout::Single, false)), + "continuous" => Some((PageLayout::Single, true)), + "two-up" => Some((PageLayout::TwoUp, true)), + "two-page" | "two-up-single" => Some((PageLayout::TwoUp, false)), + _ => None, + } +} + +/// How a newly opened document is shown (Preferences ▸ Documents and view). A PDF that asks +/// for its own layout or zoom gets that instead. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewDefaults { + pub layout: PageLayout, + /// Whether the base layout scrolls continuously. + pub continuous: bool, + /// Separate pages with a gutter and shadow; off joins adjacent page edges. + pub show_page_gaps: bool, + /// [`Fit::Width`] or [`Fit::Page`], or [`Fit::None`] for a fixed `zoom`. + pub fit: Fit, + /// The zoom with [`Fit::None`] (1.0 = 100%). + pub zoom: f32, + /// Highlight existing fields: a preference in Acrobat, so it carries over to the next + /// document and the next session. On for new settings; a stored choice wins. + pub highlight_fields: bool, +} + +impl Default for ViewDefaults { + fn default() -> Self { + // Single page, scrolling continuously at fit width: the earlier "continuous" default. + Self { layout: PageLayout::Single, continuous: true, show_page_gaps: true, fit: Fit::Width, zoom: 1.0, highlight_fields: true } + } +} + +impl ViewDefaults { + /// The zoom as settings and view options name it: `fit-width`, `fit-page` or a percentage. + pub fn zoom_name(&self) -> String { + match self.fit { + Fit::Page => "fit-page".into(), + Fit::None => format!("{}%", (self.zoom * 100.0).round()), + Fit::Width | Fit::Height => "fit-width".into(), + } + } + + /// These defaults with the zoom a [`zoom_name`](Self::zoom_name) names, in any case. `None` + /// for anything else, including a percentage outside the 8–6400% the view can show. + pub fn with_zoom(self, name: &str) -> Option { + let name = name.trim(); + let (fit, zoom) = if name.eq_ignore_ascii_case("fit-width") { + (Fit::Width, self.zoom) + } else if name.eq_ignore_ascii_case("fit-page") { + (Fit::Page, self.zoom) + } else { + let percent = name.strip_suffix('%').unwrap_or(name).trim().parse::().ok().filter(|p| (8.0..=6400.0).contains(p))?; + (Fit::None, percent / 100.0) + }; + Some(Self { fit, zoom, ..self }) + } +} + +/// A historical snapshot opened from another document: View signed version opens the bytes +/// a signature covers, View revision an earlier incremental revision. Repeats reuse the tab, +/// and the tab and notice bar name what it is, instead of piling up identical tabs. +/// What a historical snapshot shows: the bytes a signature covers, or an earlier +/// incremental revision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SnapshotKind { + SignedVersion, + Revision, +} + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub kind: SnapshotKind, + /// The 1-based revision shown. + pub revision: usize, + /// The name of the document it was opened from. + pub from: String, +} + +/// The name shown for a document tab, the window title and the Combine picker. +/// A snapshot reads like its source document, plus the snapshot suffix that tells +/// the tabs apart. Without a title the file name (which already carries the suffix) +/// is enough. +pub(crate) fn tab_label(app: &PdfCraftApp, index: usize) -> Option { + let view = app.views.get(index)?; + let doc = app.session.get(view.id)?; + let Some(snap) = view.snapshot.as_ref() else { return Some(doc.display_name()) }; + Some(snapshot_tab_name(&doc.display_name(), &doc.name, snap)) +} + +/// A snapshot's tab name from its source title, file name and marker. Pure so the +/// tab strip, window title and picker cannot disagree with each other. +pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapshot) -> String { + if title == file_name { + return file_name.into(); + } + if snapshot.kind == SnapshotKind::SignedVersion { + format!("{title} (signed version)") + } else { + format!("{title} (revision {})", snapshot.revision) + } +} + +pub struct DocView { + pub id: DocId, + pub zoom: f32, + pub fit: Fit, + pub layout: PageLayout, + /// Whether the base layout scrolls continuously (independent of `layout`, as in Acrobat). + pub continuous: bool, + pub show_page_gaps: bool, + /// View rotation in degrees clockwise (0, 90, 180, 270); display only, never saved. + pub rotation: u16, + pub current: usize, + pub organize: bool, + pub highlight_fields: bool, + pub page_input: String, + /// Select the replacement page label once if its editor remains focused. + pub(crate) page_input_reselect: bool, + pub notice_dismissed: bool, + /// The user has been told that the form's scripts were turned off (it is said once). + pub scripts_off_noted: bool, + /// The file changed on disk while this document had unsaved changes: the notice bar asks + /// before replacing it (#431). + pub disk_changed: bool, + /// The snapshot notice bar's Close was clicked: the tab closes at the top of the next + /// frame, where no document borrow is held (the bar itself can't close it). + pub close_requested: bool, + /// A historical snapshot of another document (see [`Snapshot`]): `None` for real documents. + pub snapshot: Option, + /// Two-page view: show the first page alone, as a cover (View ▸ Page display). + pub cover: bool, + /// Previous view / Next view: pages visited before (and after, once going back). + pub back: Vec, + pub forward: Vec, + /// Pending navigation: page and fraction down the page to align with the viewport top. + pub goto: Option<(usize, f32)>, + /// Pending position within that page from a destination ([`Self::go_to_dest`]): the point + /// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points + /// from the viewport's top-left corner. + goto_point: Option<(usize, Option, Option, Vec2)>, + /// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down / + /// Page Up where the pages scroll. + pub key_scroll: f32, + /// Single-page view, as last drawn: the page shown and whether the view was at its top and + /// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273). + single_edges: Option<(usize, bool, bool)>, + /// Briefly outline an annotation after navigating to it from a panel. + pub flash: Option<(usize, [f32; 4], f64)>, + /// Compare files: differences shaded on this document's pages (page, user-space box, colour). + pub compare_marks: Vec<(usize, [f32; 4], Color32)>, + /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view + /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. + presentation: Option, + pages: HashMap, + /// Pages the renderer could not draw, with the reason (never re-requested). + errors: HashMap, + /// When each still-missing page was first shown, to flag unusually slow renders. + waiting_since: HashMap, + thumbs: HashMap, + /// This frame's consumers, with visible cells ahead of prefetch. + thumb_demand: HashMap, + frame_queue: Vec, + /// A page-list edit after layout invalidates this frame's scales and page indexes. + frame_geometry_changed: bool, + /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport + /// bounds them, and the byte and count limits apply to prefetch only. + frame_visible: HashSet, + /// Thumbnails that are out of date (still shown until their replacement arrives). + stale_thumbs: HashSet, + /// Tiles that are out of date (still shown until their replacement arrives). + stale_tiles: HashSet<(usize, u64, u32, u32)>, + /// Scale tag of the current zoom level, as last drawn. + current_scale_tag: u64, + /// Print dialog: the current sheet's pages at the preview pane's device resolution. + print_pages: HashMap, + /// Print dialog: preview rasters become luminance when Print ▸ Print in grayscale is on. + grayscale: bool, + /// Sharp tiles of large pages: (page, scale tag, tile x, tile y) → texture. Tiles of an + /// earlier zoom stay, drawn stretched, until those of the current one cover the page. + tiles: HashMap<(usize, u64, u32, u32), TextureHandle>, + /// Text layers, extracted in the background on demand (selection, find, copy). + pub(crate) texts: HashMap>, + pub(crate) text_failed: HashSet, + pub find: Option, + selection: Option, + /// Select All while scrolling continuously (#2639): the text of every page of the document, + /// highlighted on each page as its text arrives and copied whole ([`DocView::whole_text`]). + pub(crate) all_text: bool, + /// The selection whose action bar was closed by its Copy button: the bar stays hidden + /// until the selection changes, as it does once a markup button consumes the selection. + actions_dismissed: Option, + /// The drag in progress started a text selection: the wheel scrolls the page during it + /// (#721), which egui's scroll area refuses while anything is dragged. + selection_drag: bool, + /// Clicks in the current run on the text layer: two select a word, three the line, four the page. + clicks: u32, + last_press_time: f64, + last_press_pos: Option, + /// Full screen: a click's page turn, when it falls due (input time) and which way. It waits + /// out the double-click time, as the click may be the first of a double-click selecting a word. + present_turn: Option<(f64, bool)>, + last_queue: Vec, + /// Finished renders wait in the pool instead of reaching the view (tests): what the view + /// shows before a raster arrives can be looked at however fast the renderer is. + pub hold_rasters: bool, + viewport_w: f32, + viewport_h: f32, + page_count: usize, + /// Page heights in points (view space), for mapping text positions to scroll offsets. + page_heights: Vec, + /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). + screen_rects: Vec<(usize, Rect)>, + screen_xforms: Vec<(usize, PageXform)>, + /// The scroll viewport on screen last frame. + viewport_screen: Rect, + /// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset. + zoom_anchor: Option<(usize, f32, f32, Vec2)>, + /// The page view has been shown, so its scroll offset in egui's memory is this document's. + shown: bool, + /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). + pub(crate) auto_ocr_tried: std::collections::HashSet, + /// Pages Edit looked at and found not to be a scan, with the document's edit generation then: + /// they are looked at again only after the document changes (an image added, say). + pub(crate) auto_ocr_skipped: std::collections::HashMap, + /// Turns wheel input into page turns in single-page view. + wheel: crate::wheel_pager::WheelPager, + /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. + /// A precision touchpad sends pixel-precise `MouseWheel` events; on Windows they arrive with + /// no `TouchPhase::Start`, so egui eases deltas of 8 px or more and the page lags the finger + /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as + /// it decays, instead of adding on top of the 1:1 motion. + precise_scroll_until: f64, + /// Per-axis line-wheel state; horizontal input cannot extend a vertical burst. + wheel_boost_x: crate::wheel_boost::WheelBoost, + wheel_boost_y: crate::wheel_boost::WheelBoost, + /// Touchpad momentum after the fingers lift (#531). + fling: crate::fling::Fling, + pub(crate) auto_scroll: crate::autoscroll::AutoScroll, + /// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page". + pub selected: BTreeSet, + pub(crate) organize_range: crate::page_selection_ui::PageSelectionInput, + /// Anchor for ⇧-click range selection in the organize grid. + select_anchor: Option, + /// An edit requested by the view (organize toolbar, keys), applied by the app this frame. + pub pending_edit: Option, + /// Edit text: the lines per page (with the document generation they were read at), and the + /// line being edited. + pub(crate) edit_lines: HashMap)>, + pub line_editor: Option, + /// The paragraph editor whose edit was just queued. It opens again with its typing if the + /// edit fails, as a refused form field does. + pub(crate) applied_editor: Option, + /// The box a click switched to while another paragraph was being edited: it opens once the + /// queued edit has applied, so its index and geometry come from the document it now is (#2022). + /// It is held as the paragraph's text and box, not an index: the edit can regroup the page. + pub(crate) pending_text_open: Option<(usize, pdfcraft_engine::BlockKey)>, + pub(crate) text_background: crate::edit_text_ui::TextBackground, + pub(crate) move_background: crate::content_ui::MoveBackground, + /// Edit text & images: the images per page (by document generation), and the selected one. + pub(crate) edit_images: HashMap)>, + pub(crate) objects: crate::object_ui::ObjectSelection, + pub image_selection: Option, + /// Select tool: the picture clicked, to copy in its original format. + pub picked_image: Option, + /// A paragraph box being dragged (moved, or resized from its right edge) in Edit text. + pub block_drag: Option, + /// Commenting state: selected comment, gestures, composer. + pub comments: crate::comments::CommentView, + pub measure: crate::measure_ui::MeasureView, + /// Form filling state: the focused field. + pub forms: crate::forms_ui::FormView, + /// Prepare a form: the selected field and the gesture in progress. + pub prepare: crate::prepare::PrepareView, + /// Prepare a form ▸ Calculation order. + pub(crate) calc_order: crate::calc_order_ui::CalcOrderView, + /// Edit a PDF: the selected added item, the text being typed. + pub content: crate::content_ui::ContentView, + /// Edit a PDF ▸ Link tool state. + pub links: crate::link_ui::LinkView, + /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. + pub redact_drag: crate::redact_ui::AreaDrag, + pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, + /// A crop rectangle being dragged (Crop tool). + pub crop_drag: crate::crop::CropDrag, + /// Enhance ▸ Camera image: the quadrilateral being placed. + pub camera: Option, + /// Enhance ▸ Camera image: the page just enhanced, while the bar offers rotate, crop and + /// organize. + pub camera_done: Option, + /// Use a certificate: a signature rectangle being drawn, or an empty signature field clicked. + pub sign: crate::sign_ui::SignView, + /// Organize: the pages being dragged to a new place. + pub org_drag: Option>, + /// Pages panel: the pages being dragged to a new place. + pub panel_drag: Option>, + /// Transient keyboard owner for this document's Pages sidebar. + pub(crate) pages_focus_id: Option, + /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. + pub marquee: Option<(usize, Pos2)>, + pub marquee_done: Option, + /// Fill & Sign text being typed. + pub fill_text: Option, + /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. + pub(crate) fill_signature_page: Option, + pub(crate) signature_drag: crate::signature_drag::SignatureDrag, + /// What the page modules claim for the cursor this frame; `document_area` sets it once. + pub(crate) cursor: crate::page_cursor::PageCursor, + /// Who the press the pages hold belongs to. + pub(crate) router: crate::page_router::PageRouter, + /// Escape cancelled the press the pages hold: the page ignores it until the button is up, so + /// its release neither clicks nor ends a drag. + pub(crate) press_cancelled: bool, + /// A non-edit action requested by the organize toolbar, handled by the app. + pub pending_action: Option, + /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they + /// go after the selection. + pub insert_at: Option, + /// The page-grid gap under the pointer this frame (where dropped files go), when it can + /// take pages. + pub grid_gap: Option, + /// The visible part of the Pages panel as last drawn, when it can take dropped files + /// (cleared each frame before the panels draw). + pub pages_panel: Option, + /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. + pub panel_gap: Option, + /// The current page the Pages panel last followed: it scrolls to the current page's + /// thumbnail only when the page changes. + pub(crate) panel_followed: Option, + /// Scale of the thumbnails in the Pages inspector, independent of document and grid zoom. + thumbnail_zoom: f32, + /// How large the page grid draws its pages (1.0 = the usual thumbnails). + grid_zoom: f32, + /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid + /// out, so it can keep the same pages in view. + grid_zoom_request: Option, + /// The page to keep in place after a grid zoom, and how far below the top of the grid its + /// cell was. + grid_anchor: Option<(usize, f32)>, + /// The page the grid scrolls into view on its next frame (Organize just opened on it). + grid_reveal: Option, + /// Sharp renders of the pages the grid shows larger than a thumbnail: only those in view, + /// at the size drawn (device pixels wide). + grid_pages: HashMap, + /// Zoom-settle debounce for heavy pages: the last requested device scale, and the egui + /// time until which sharp whole-page/tile re-renders are held back. While the zoom is + /// still moving, every frame would otherwise cancel the in-flight full-page interpret + /// (seconds on image-heavy CAD plots) and start it over, so a sharp raster never + /// arrives. The stale raster stays on screen stretched meanwhile. + last_scale: f32, + sharp_hold_until: f64, + /// Pan-settle debounce for tiled pages: the scroll offset last frame, and the egui time + /// until which sharp tile re-renders are held back. Each tile costs a full page + /// interpret, so a drag would otherwise queue a storm of them (one per frame) while the + /// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay + /// stretched meanwhile; fresh tiles go out once the pan settles. + pub(crate) last_scroll: Vec2, + /// Side-by-side compare: scroll to here this frame, following the other half. + pub(crate) sync_scroll: Option, + /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. + page_widget: Option, + pan_hold_until: f64, + /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan + /// settle holds. Fast pages render straight through, with no added latency. + slow_pages: HashSet, +} + +/// Organize-toolbar actions that need the app (file pickers, new tabs, dialogs). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ViewAction { + InsertFromFile, + /// Insert files at a gap of the page grid (0 = before the first page). + InsertFromFileAt(usize), + /// Replace the selected pages with pages from another file. + ReplacePages, + /// Save the document as the grid shows it. + Save, + Extract, + Split, + /// Split before each selected page: opens Split on that mode. + SplitBeforeSelected, + /// Copy the selected pages (Cut also deletes them). + CopyPages { + cut: bool, + }, + /// Paste copied pages after the selection. + PastePages, + /// Enhance ▸ Camera image: straighten what the corners enclose. + EnhanceCamera, + /// Leave Enhance ▸ Camera image (Cancel before Enhance page, Done after it). + CancelCamera, + /// After Enhance page: turn the enhanced page a quarter. + CameraRotate { + clockwise: bool, + }, + /// After Enhance page: enhance again at the slider's clean-up level. + CameraLevel, + /// After Enhance page: leave the tool for this command (Crop pages, Organize pages). + CameraNext(&'static str), +} + +/// Default page right-click menu actions. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PageMenuAction { + RotateCCW(usize), + RotateCW(usize), + Print, + SelectTool, + HandTool, + MarqueeZoom, + Copy, + Paste(usize, [f64; 2]), + AddComment(usize, [f64; 2]), + AddBookmark(usize), + TakeSnapshot, + EditPdf, + AddText, + AddImage, + DeletePage(usize), + ExportPdf, + Find, + DocProperties, + FlattenComments, +} + +pub(crate) struct PageMenuPerms { + pub can_modify: bool, + pub can_assemble: bool, + pub can_annotate: bool, + pub can_print: bool, + pub can_copy: bool, + pub can_paste: bool, + pub has_annotations: bool, +} + +pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option) { + // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate view counterclockwise")).clicked() { + *action = Some(PageMenuAction::RotateCCW(page)); + ui.close(); + } + ui.add_enabled_ui(perms.can_print, |ui| { + if crate::icons::button(ui, "printer", 24.0, false, tl!("Print")).clicked() { + *action = Some(PageMenuAction::Print); + ui.close(); + } + }); + if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, tl!("Select Tool")).clicked() { + *action = Some(PageMenuAction::SelectTool); + ui.close(); + } + if crate::icons::button(ui, "hand", 24.0, false, tl!("Hand Tool")).clicked() { + *action = Some(PageMenuAction::HandTool); + ui.close(); + } + if crate::icons::button(ui, "zoom-in", 24.0, false, tl!("Marquee Zoom")).clicked() { + *action = Some(PageMenuAction::MarqueeZoom); + ui.close(); + } + }); + + ui.separator(); + + if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() { + *action = Some(PageMenuAction::Copy); + ui.close(); + } + if ui.add_enabled(perms.can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() { + *action = Some(PageMenuAction::Paste(page, at)); + ui.close(); + } + + ui.separator(); + + if ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))).clicked() { + *action = Some(PageMenuAction::AddComment(page, at)); + ui.close(); + } + if perms.has_annotations && ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))).clicked() { + *action = Some(PageMenuAction::FlattenComments); + ui.close(); + } + if ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))).clicked() { + *action = Some(PageMenuAction::AddBookmark(page)); + ui.close(); + } + if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))).clicked() { + *action = Some(PageMenuAction::TakeSnapshot); + ui.close(); + } + + ui.separator(); + + if ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() { + *action = Some(PageMenuAction::EditPdf); + ui.close(); + } + if ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))).clicked() { + *action = Some(PageMenuAction::AddText); + ui.close(); + } + if ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))).clicked() { + *action = Some(PageMenuAction::AddImage); + ui.close(); + } + if ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))).clicked() { + *action = Some(PageMenuAction::DeletePage(page)); + ui.close(); + } + if ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))).clicked() { + *action = Some(PageMenuAction::ExportPdf); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Find text or tools")).clicked() { + *action = Some(PageMenuAction::Find); + ui.close(); + } + if ui.button(tl!("Rotate view clockwise")).clicked() { + *action = Some(PageMenuAction::RotateCW(page)); + ui.close(); + } + if ui.button(tl!("Document properties…")).clicked() { + *action = Some(PageMenuAction::DocProperties); + ui.close(); + } +} + +impl DocView { + /// Whether a middle-button scrolling gesture is active (tests and automation). + pub fn auto_scrolling(&self) -> bool { + self.auto_scroll.active() + } + + /// Whether a held middle-button pan is active, outside Linux (tests and automation). + pub fn middle_panning(&self) -> bool { + self.auto_scroll.panning() + } + + /// Abandon only pointer previews: clearing egui's held button must not commit an app-owned + /// grab through a tool's `!primary_down` fallback. Keep selections, text drafts and edits + /// that were already completed before cancellation. + pub(crate) fn cancel_pointer_gestures(&mut self) { + self.marquee = None; + self.marquee_done = None; + self.comments.gesture = None; + self.links.drag = None; + self.sign.drag = None; + self.redact_drag = None; + self.crop_drag = None; + self.block_drag = None; + self.org_drag = None; + self.panel_drag = None; + self.selection_drag = false; + self.router.cancel(); + self.auto_scroll.cancel(); + self.prepare.cancel_pointer_gesture(); + self.content.cancel_pointer_gesture(); + self.objects.cancel_pointer_gesture(); + if let Some(image) = &mut self.image_selection { + image.cancel_pointer_gesture(); + } + } + + /// A view of a newly opened document, shown as `defaults` say. Opening a file then applies + /// any layout or zoom the PDF itself asks for. + pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self { + Self { + id, + zoom: defaults.zoom, + fit: defaults.fit, + layout: defaults.layout, + continuous: defaults.continuous, + show_page_gaps: defaults.show_page_gaps, + rotation: 0, + current: 0, + organize: false, + highlight_fields: defaults.highlight_fields, + page_input: "1".into(), + page_input_reselect: false, + notice_dismissed: false, + scripts_off_noted: false, + disk_changed: false, + close_requested: false, + snapshot: None, + cover: false, + back: Vec::new(), + forward: Vec::new(), + goto: None, + goto_point: None, + key_scroll: 0.0, + single_edges: None, + flash: None, + compare_marks: Vec::new(), + presentation: None, + pages: HashMap::new(), + errors: HashMap::new(), + waiting_since: HashMap::new(), + thumbs: HashMap::new(), + thumb_demand: HashMap::new(), + frame_queue: Vec::new(), + frame_geometry_changed: false, + frame_visible: HashSet::new(), + stale_thumbs: HashSet::new(), + stale_tiles: HashSet::new(), + current_scale_tag: 0, + print_pages: HashMap::new(), + grayscale: false, + tiles: HashMap::new(), + texts: HashMap::new(), + text_failed: HashSet::new(), + find: None, + selection: None, + all_text: false, + actions_dismissed: None, + selection_drag: false, + clicks: 0, + last_press_time: 0.0, + last_press_pos: None, + present_turn: None, + last_queue: Vec::new(), + hold_rasters: false, + viewport_w: 800.0, + viewport_h: 600.0, + page_count: info.pages.len(), + page_heights: info.pages.iter().map(|p| p.height).collect(), + screen_rects: Vec::new(), + screen_xforms: Vec::new(), + viewport_screen: Rect::NOTHING, + zoom_anchor: None, + shown: false, + auto_ocr_tried: Default::default(), + auto_ocr_skipped: Default::default(), + wheel: Default::default(), + precise_scroll_until: 0.0, + wheel_boost_x: Default::default(), + wheel_boost_y: Default::default(), + fling: Default::default(), + auto_scroll: Default::default(), + selected: BTreeSet::new(), + select_anchor: None, + organize_range: Default::default(), + pending_edit: None, + edit_lines: HashMap::new(), + line_editor: None, + applied_editor: None, + pending_text_open: None, + text_background: Default::default(), + move_background: Default::default(), + edit_images: HashMap::new(), + objects: Default::default(), + image_selection: None, + picked_image: None, + block_drag: None, + pending_action: None, + insert_at: None, + grid_gap: None, + pages_panel: None, + panel_gap: None, + panel_followed: None, + thumbnail_zoom: 1.0, + grid_zoom: 1.0, + grid_zoom_request: None, + grid_anchor: None, + grid_reveal: None, + grid_pages: HashMap::new(), + last_scale: 0.0, + sharp_hold_until: 0.0, + last_scroll: Vec2::ZERO, + sync_scroll: None, + page_widget: None, + pan_hold_until: 0.0, + slow_pages: HashSet::new(), + comments: Default::default(), + measure: Default::default(), + forms: Default::default(), + prepare: Default::default(), + calc_order: Default::default(), + content: Default::default(), + links: Default::default(), + redact_drag: None, + pending_redaction: None, + crop_drag: None, + camera: None, + camera_done: None, + sign: Default::default(), + org_drag: None, + panel_drag: None, + pages_focus_id: None, + marquee: None, + marquee_done: None, + fill_text: None, + fill_signature_page: None, + signature_drag: Default::default(), + cursor: Default::default(), + router: Default::default(), + press_cancelled: false, + } + } + + /// The document's content or page list changed (an edit, undo or redo): drop caches and + /// adopt the new page geometry, keeping the reader's place where possible. + pub fn document_changed(&mut self, info: &DocInfo) { + self.invalidate_content(); + self.frame_geometry_changed = true; + self.page_count = info.pages.len(); + self.page_heights = info.pages.iter().map(|p| p.height).collect(); + let last = self.page_count.saturating_sub(1); + self.current = self.current.min(last); + self.selected.retain(|p| *p <= last); + // Text being typed on a page that no longer exists goes with the page. + if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) { + self.content.draft = None; + } + if self.select_anchor.is_some_and(|a| a > last) { + self.select_anchor = None; + } + self.goto = None; + self.goto_point = None; + self.zoom_anchor = None; + self.flash = None; + // Thumbnail drags hold page indexes from before the change. + self.panel_drag = None; + self.org_drag = None; + self.reset_coarse_state(); + } + + /// The document was replaced by a newer version of its file (#431). The reader's place + /// stays: zoom, layout, rotation, page, scroll, full screen, view history and the find bar, + /// the snapshot marker and its pending close, and the page images on screen until their + /// replacements are drawn, so nothing flashes. Everything that pointed into the old version (a selected + /// comment, a field being typed in, text being edited, a drag, page selection) starts + /// afresh, so no stale index reaches the new one. + pub fn document_replaced(&mut self, info: &DocInfo) { + let mut fresh = Self::new(self.id, info, ViewDefaults::default()); + fresh.zoom = self.zoom; + fresh.fit = self.fit; + fresh.layout = self.layout; + fresh.continuous = self.continuous; + fresh.show_page_gaps = self.show_page_gaps; + fresh.rotation = self.rotation; + fresh.current = self.current; + fresh.organize = self.organize; + fresh.grid_zoom = self.grid_zoom; + fresh.thumbnail_zoom = self.thumbnail_zoom; + // Same document id, so the scroll offset egui keeps for it is still this document's. + fresh.shown = self.shown; + fresh.highlight_fields = self.highlight_fields; + fresh.notice_dismissed = self.notice_dismissed; + fresh.snapshot = self.snapshot.clone(); + fresh.close_requested = self.close_requested; + fresh.cover = self.cover; + // Still presenting, with the display to go back to on leaving. + fresh.presentation = self.presentation.take(); + fresh.back = std::mem::take(&mut self.back); + fresh.forward = std::mem::take(&mut self.forward); + fresh.find = self.find.take(); + fresh.pages = std::mem::take(&mut self.pages); + fresh.thumbs = std::mem::take(&mut self.thumbs); + fresh.viewport_w = self.viewport_w; + fresh.viewport_h = self.viewport_h; + fresh.viewport_screen = self.viewport_screen; + *self = fresh; + // Marks the kept images out of date and clamps the page to the new page count. + self.document_changed(info); + self.page_input = self.current_page_label(info.pages.get(self.current).map(|page| page.label.as_str())); + self.page_input_reselect = true; + } + + /// Select pages in the organize grid (tests, automation). Empty clears the selection. + pub(crate) fn page_count(&self) -> usize { + self.page_count + } + + /// Drops wheel state after a discontinuous jump so the destination cannot + /// inherit momentum from the previous viewport. + fn reset_coarse_state(&mut self) { + self.wheel.clear_motion(); + self.wheel_boost_x.reset(); + self.wheel_boost_y.reset(); + } +} + +/// The renders a frame wants (page, scale, tag, tile), and the pages visible. +type PageRenders = (Vec<(usize, f32, u64, Option)>, Vec); + +/// What the page-scroll closure reads: this frame's zoom, scrolling, tool and document state. +#[derive(Clone, Copy)] +struct PageEnv<'a> { + added: &'a Vec, + allowed: bool, + author: &'a String, + auto_delta: Vec2, + avail: Rect, + boost_extra: Vec2, + by_line: &'a String, + calc_order: bool, + can_copy: bool, + can_crop: bool, + can_fill: bool, + can_modify: bool, + comments_hidden: bool, + content_h: f32, + content_w: f32, + custom_stamp: &'a Option, + date_text: &'a Result, + doc: &'a pdfcraft_engine::Document, + doc_links: &'a Vec, + edge_delta: Vec2, + editing_content: bool, + form: &'a Arc>, + generation: u64, + glide: Vec2, + hand: bool, + info: &'a DocInfo, + initials: &'a Option, + key_scroll: f32, + middle_gesture: bool, + modal: bool, + navigation_enabled: bool, + paper: Color32, + ppp: f32, + precise_scroll: Vec2, + prefs: &'a comments::CommentPrefs, + preparing: bool, + rects: &'a Vec, + redact_mark: ([f64; 3], f64), + scale: f32, + selection_wheel: bool, + selects_text: bool, + signature: &'a Option, + single_paging: bool, + t: &'a Tokens, + table_preview: &'a Option, + tag: u64, + text_style: &'a pdfcraft_engine::AddedText, + tool: QuickTool, + units: crate::units::Unit, + unobstructed: bool, + visible_pages: &'a Vec, + y_shift: f32, + fill_text_size: f64, + fill_text_color: [f64; 3], + today: (i64, u32, u32), + can_paste_image: bool, +} + +/// What the page-scroll closure produces for the rest of the frame. +#[derive(Default)] +struct PageOut { + canvas_action: Option, + clicked_link: Option, + copy_selected: bool, + field_menu: Option, + field_placed: bool, + field_props: bool, + hover_text: Option<(Pos2, String)>, + image_action: Option, + open_boxes: bool, + open_initials: bool, + open_signature: bool, + open_table: Option<(usize, [f64; 4])>, + page_action: Option, + primary_down: bool, + refused: Option, + stamp_placed: bool, +} + +/// The page-scroll closure's body: lays out and draws the visible pages, their tools and overlays, and +/// returns the renders wanted and the pages visible. +/// The state of one pass over the visible pages: what is read, and what the pages add up to. +struct PageFrame<'a> { + env: PageEnv<'a>, + resp: &'a egui::Response, + ptr: &'a PagePointer, + origin: Pos2, + visible: Rect, + pressed: bool, + pointer: Option, + signature_preview: &'a mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, + out: PageOut, + wanted: Vec<(usize, f32, u64, Option)>, + visible_now: Vec, + current: usize, + best_overlap: f32, + current_overlap: f32, + current_height: f32, + pressed_page: Option, + calc_hover: Option, +} + +/// Visible heights (points) closer than this are equal: one page size can meet the viewport a +/// fraction of a point differently once the scroll offset is applied. +const PAGE_TIE: f32 = 0.5; + +/// Draws page `i` of the visible pages: its raster, the tools' input and overlays. +/// One visible page: its index, its rectangle on screen and the transform to and from the page. +#[derive(Clone, Copy)] +struct PageSlot { + i: usize, + r: Rect, + xf: PageXform, +} + +/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders +/// it wants. +#[allow(clippy::too_many_arguments)] +fn paint_image_suppressed( + xf: &PageXform, + painter: &egui::Painter, + tex: egui::TextureId, + u0: f32, + v0: f32, + u1: f32, + v1: f32, + suppress: Option<[f32; 4]>, + is_full_page_tex: bool, +) { + let Some([su0, sv0, su1, sv1]) = suppress else { + if is_full_page_tex { + xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1); + } else { + xf.paint_image(painter, tex, u0, v0, u1, v1); + } + return; + }; + let iu0 = u0.max(su0); + let iu1 = u1.min(su1); + let iv0 = v0.max(sv0); + let iv1 = v1.min(sv1); + if iu0 >= iu1 || iv0 >= iv1 { + if is_full_page_tex { + xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1); + } else { + xf.paint_image(painter, tex, u0, v0, u1, v1); + } + return; + } + let map_uv = |pu: f32, pv: f32| -> (f32, f32) { + if is_full_page_tex { + (pu, pv) + } else { + let du = (u1 - u0).max(1e-6); + let dv = (v1 - v0).max(1e-6); + ((pu - u0) / du, (pv - v0) / dv) + } + }; + let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| { + if pu1 <= pu0 || pv1 <= pv0 { + return; + } + let (tu0, tv0) = map_uv(pu0, pv0); + let (tu1, tv1) = map_uv(pu1, pv1); + xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1); + }; + + if iv0 > v0 { + draw_strip(u0, v0, u1, iv0); + } + if v1 > iv1 { + draw_strip(u0, iv1, u1, v1); + } + if iu0 > u0 { + draw_strip(u0, iv0, iu0, iv1); + } + if u1 > iu1 { + draw_strip(iu1, iv0, u1, iv1); + } +} + +fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) { + let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let info = frame.env.info; + let painter = ui.painter(); + // Shadows would darken the preceding page when pages touch. + if view.show_page_gaps { + painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); + } + painter.rect_filled(r, CornerRadius::ZERO, paper); + if let Some(err) = view.errors.get(&i) { + painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); + icons::paint( + ui, + Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), + "triangle-alert", + 26.0, + Color32::from_rgb(0xC8, 0x3A, 0x3A), + ); + let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); + let msg = ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); + painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); + } else { + let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); + // A GPU may take smaller textures than these (OpenGL drivers report as little as + // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); + // Whole-page raster: sharp when small, a low-res backdrop when tiled. + let (want_scale, want_tag) = if tiled { + let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); + (bs, scale_tag(bs)) + } else { + (scale, tag) + }; + // Under tiles, a whole-page raster sharper than the backdrop (from before the page + // grew past the tiling size) is a better backdrop: keep demanding it as it is. + match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { + Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)), + None => frame.wanted.push((i, want_scale, want_tag, None)), + } + let doc = frame.env.doc; + let active_drag_suppress = if let Some(sel) = view.image_selection.as_ref() + && sel.page == i + && sel.drag.is_some() + { + let images = doc.page_images(i); + if let Some(img) = images.get(sel.index) { + let obb = ImageObb::from_image(img, &xf, info, i); + let uvs = obb.corners().map(|c| xf.screen_to_norm(c)); + let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0); + let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0); + let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0); + let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0); + if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None } + } else { + None + } + } else { + None + }; + match view.pages.get(&i) { + Some(p) => { + if !tiled && p.tag == want_tag { + paint_image_suppressed(&xf.texel_aligned(p.tex.size(), ppp), painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true); + } else { + // A backdrop, or a raster at an older scale until the new one arrives. + paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true); + } + } + None => { + let now = ui.input(|inp| inp.time); + let since = *view.waiting_since.entry(i).or_insert(now); + let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; + painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); + } + } + if tiled && r.intersects(visible) { + paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress); + } + } +} + +/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, +/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing. +fn paint_page_tiles( + ui: &egui::Ui, + view: &mut DocView, + frame: &mut PageFrame<'_>, + slot: &PageSlot, + (pw_pt, ph_pt, max_side): (f32, f32, f32), + active_drag_suppress: Option<[f32; 4]>, +) { + let PageEnv { ppp, scale, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let painter = ui.painter(); + // Device-pixel geometry of the scaled page, and the visible part of it + // (found by mapping the visible screen corners back into the page). Sides round + // up as whole-page rasters do, so the last partial row and column get tiles. + let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); + // Adaptive tile side for this zoom and page, within the GPU texture limit: + // a zoomed viewport or a giant sheet is covered by a few large tiles + // instead of many small ones, and each tile costs one full page interpret. + // Never zero (u32 division below). + let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); + let vis = r.intersect(visible); + let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); + let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); + let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); + let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); + let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); + // Tiles of earlier zooms first, stretched, the nearest scale last (on top): + // until the current tiles arrive, a zoom shows them rather than the backdrop. + let mut older: Vec<(f32, u32, u32, &TextureHandle)> = + view.tiles.iter().filter(|((p, t, _, _), _)| *p == i && *t != tag).map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)).collect(); + older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); + for (s, ox, oy, tex) in older { + let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); + let [w, h] = tex.size(); + let (x, y) = (ox as f32, oy as f32); + paint_image_suppressed(&xf, painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh, active_drag_suppress, false); + } + // Visible tiles, nearest the viewport centre first: each tile costs a full + // page interpret, so the workers sharpen what is looked at before the edges. + let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); + let mut coords: Vec<(u32, u32)> = Vec::new(); + for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { + for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { + coords.push((tx, ty)); + } + } + coords.sort_by(|a, b| { + let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); + let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); + da.total_cmp(&db) + }); + for (tx, ty) in coords { + let (x, y) = (tx * ts, ty * ts); + let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); + if w == 0 || h == 0 { + continue; + } + frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); + if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { + let (fw, fh) = (dw as f32, dh as f32); + paint_image_suppressed( + &xf.texel_aligned([dw as usize, dh as usize], ppp), + painter, + tex.id(), + x as f32 / fw, + y as f32 / fh, + (x + w) as f32 / fw, + (y + h) as f32 / fh, + active_drag_suppress, + false, + ); + } + } +} + +/// What the page's tools did with the pointer this frame. +struct PageInput { + /// A tool, a field or a comment took the pointer: text selection must leave it alone. + consumed: bool, + /// Redact or Highlight draws boxes off text. + area_tool: bool, +} + +/// Common geometry and pointer state for a tool acting on one page. +#[derive(Clone, Copy)] +pub(crate) struct PageToolContext<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub doc: &'a pdfcraft_engine::Document, + pub info: &'a DocInfo, +} + +/// Comments: tools, selection, moving and resizing come before text selection. Form fields take +/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then +/// comments; then text selection. +fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { + let PageEnv { + added, + allowed, + author, + by_line, + can_crop, + can_fill, + can_modify, + comments_hidden, + custom_stamp, + date_text, + doc, + doc_links, + editing_content, + form, + info, + initials, + prefs, + preparing, + redact_mark, + signature, + table_preview, + text_style, + tool, + units, + fill_text_size, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let (lines, images) = + if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let hits = PageHits { + page: i, + xf: &xf, + info, + tool, + pcx: &pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines: &lines, + images: &images, + clip: ui.clip_rect(), + }; + if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { + let at = hits.at(view, o); + view.router.press(i, at, tool); + } + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); + } + + // Form fields take clicks first with the Select tool (as Acrobat fills fields in + // every viewing mode); then comments; then text selection. + if let QuickTool::Stamp(kind) = tool + && allowed + && let Some(p) = ptr.within(xf.rect) + { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { + // Centred on the click, upright as the page is shown. + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; + let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); + let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: i, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.clone(), + })); + frame.out.stamp_placed = true; + } + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && let Some(p) = ptr.within(xf.rect) + { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { + // Centred on the click at its natural size (the engine sizes it). + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[i].view_to_user(vx, vy); + let (x, y) = (u[0] as f64, u[1] as f64); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page: i, + rect: [x, y, x, y], + name: cs.name.clone(), + file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, + author: author.clone(), + }); + frame.out.stamp_placed = true; + } + } + // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + if let QuickTool::Fill(ft) = tool + && allowed + && !comments::fill_grabs(&pcx, view) + { + match crate::fill_sign::page_input( + cx, + crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }, + view, + ) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) + { + view.current = i; + frame.out.open_boxes = true; + } + let on_field = if preparing { + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); + frame.out.field_props |= o.properties; + frame.out.field_placed |= o.placed; + frame.out.open_table = frame.out.open_table.or(o.table); + o.consumed || field_tool.is_some() + } else { + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) + }; + // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). + let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; + let boxing = area_tool && { + let text = view.page_text(i); + let over_text = |p: Pos2| { + let (vx, vy) = xf.screen_to_view(p); + text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + }; + crate::redact_ui::page_input(cx, over_text, view) + }; + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + // Add text acts on the page's own paragraphs over them, and on the one its press was on until + // the button is up: a drag that leaves the box still ends, and no new box takes it over. + let over_source = tool == QuickTool::AddText + && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) + || pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) + })); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } + let on_content = !on_objects && editing_content && can_modify && { + source_blocked + || crate::content_ui::page_input( + cx, + added, + tool == QuickTool::AddText && !over_source, + matches!(tool, QuickTool::AddText | QuickTool::EditText), + text_style, + view, + ) + || tool == QuickTool::AddText && !over_source + }; + // Added content owns its gesture; its source text must not queue a second edit. + // Edit text & images boxes every paragraph and image whenever the tool is on. Only the + // input waits while selected objects or added content own the pointer, or while page + // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn + // instead of vanishing until the pointer comes back. + let others_own_pointer = on_objects || on_content; + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); + !others_own_pointer && (text_used || image_used) + }; + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); + if tool == QuickTool::Select + && resp.secondary_clicked() + && can_modify + && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) + && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + { + view.links.context = Some((i, l.index)); + } + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the + // last claim on a layer stands). + if frame.env.unobstructed + && let Some(p) = pointer + { + for h in hits.at(view, p) { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip); + } + } + PageInput { consumed, area_tool } +} + +/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. +fn edit_inventory( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + i: usize, +) -> (Vec, Vec) { + let generation = doc.edit_generation(); + let lines = match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + }; + let images = match view.edit_images.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.page_images(i); + view.edit_images.insert(i, (generation, l.clone())); + l + } + }; + (lines, images) +} + +/// What the page modules have under a point on one page, for the page router. +struct PageHits<'a> { + page: usize, + xf: &'a PageXform, + info: &'a DocInfo, + tool: QuickTool, + pcx: &'a comments::PageCx<'a>, + form: &'a [pdfcraft_engine::FormField], + can_fill: bool, + can_modify: bool, + /// Edit a PDF is open: added text and pictures can be picked up. + editing_content: bool, + preparing: bool, + field_tool: Option, + /// Prepare may draw and move fields (not while a table is being set up). + prepare_allowed: bool, + added: &'a [pdfcraft_engine::Added], + lines: &'a [pdfcraft_engine::TextBlock], + images: &'a [pdfcraft_engine::PageImage], + /// The pages' visible area (the picture's rotate knob stays inside it). + clip: Rect, +} + +impl PageHits<'_> { + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text + /// on top of artwork takes the press), added content, the objects selected together and the + /// open paragraph's frame, the selected picture's grips over them all; then form fields + /// (Prepare's, being moved, over the comments; filled ones under added content and the + /// comments that move). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + let PageHits { + page, + xf, + info, + tool, + pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines, + images, + clip, + } = *self; + let fields = if preparing { + crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) + } else if tool == QuickTool::Select { + crate::forms_ui::hit(xf, info, page, form, can_fill, p) + } else { + None + }; + // With Add text, only the paragraphs and pictures not under added content. + let editing = + can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); + let (pictures, paragraphs, objects, grips) = if editing { + let open = view.line_editor.as_ref().filter(|e| e.page == page); + let frame = open + .filter(|e| !e.has_changes()) + .and_then(|e| lines.get(e.block)) + .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); + // Add text acts on the page's paragraphs only in their boxes, edges included (it + // finishes the box being typed there, `over_source`): not on pictures, nor beside a + // paragraph, where a click starts a new box. + let edit_text = tool == QuickTool::EditText; + let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); + ( + edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), + on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), + edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), + edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), + ) + } else { + (None, None, None, None) + }; + // Added text and pictures, over the page's own paragraphs and under the objects selected + // together. + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] + .into_iter() + .flatten() + .collect() + } +} + +/// Text layer: find matches, selection, I-beam and drag-to-select. +fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + if let Some(text) = view.texts.get(&i).cloned() { + paint_text_marks(ui, view, &text, i, xf, resp.dragged()); + if selects_text + && !input.consumed + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { + text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p }); + } + } +} + +fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { + let painter = ui.painter(); + let to_screen = |g: [f32; 4]| xf.view_rect(g); + if let Some(find) = &view.find { + for (index, (match_page, range)) in find.matches.iter().enumerate() { + if *match_page != page { + continue; + } + let fill = if find.current == Some(index) { + Color32::from_rgba_unmultiplied(255, 140, 0, 110) + } else { + Color32::from_rgba_unmultiplied(255, 214, 0, 90) + }; + for line_rect in text.line_rects(range.clone()) { + painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); + } + } + } + if view.all_text { + for line_rect in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } + if let Some(selection) = view.selection.filter(|selection| selection.page == page) { + for line_rect in selection.rects(text) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + if let Span::Column { rect } = selection.span + && dragging + { + let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); + painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); + } + } +} + +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, +} + +struct TextGesture<'a> { + page: usize, + xf: PageXform, + rect: Rect, + tool: QuickTool, + ptr: PagePointer, + resp: &'a egui::Response, + pointer: Pos2, + vx: f32, + vy: f32, + over_text: bool, + over_link: bool, + column: bool, +} + +fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { + let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer; + let (vx, vy) = xf.screen_to_view(screen_pointer); + let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); + let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + update_text_cursor(view, &gesture); + update_selection_on_press(ui, view, text, &gesture); + update_dragged_selection(view, text, &gesture); + finish_text_pointer_gesture(ui, view, text, &gesture); +} + +fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { + // A press on a link selects nothing (it is the link's), so no cursor here says it would. + if gesture.column && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); + } else if gesture.over_text && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on + // text markup lets its drag select the text underneath. + let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); + if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { + let now = ui.input(|input| input.time); + let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); + let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); + view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(gesture.pointer); + let clicks = selection_click_count(view.clicks, gesture.tool); + if clicks >= 2 + && gesture.over_text + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } + } + if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { + view.actions_dismissed = None; + view.selection_drag = true; + let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); + let (ox, oy) = gesture.xf.screen_to_view(origin); + if view.clicks < 2 { + view.selection = if gesture.column { + Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) + } else { + text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) + }; + } + } +} + +fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { + if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } +} + +fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let clicks = selection_click_count(view.clicks, gesture.tool); + if gesture.resp.dragged() + && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) + { + match &mut selection.span { + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) + && let Some((first, last)) = click_unit(text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } + Span::Run { head, .. } => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { + *head = hit; + } + } + Span::Column { rect } => { + rect[2] = gesture.vx; + rect[3] = gesture.vy; + } + } + } + if gesture.resp.drag_stopped() + && view.selection.is_some_and(|selection| { + selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() + }) + { + view.selection = None; + } +} + +fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let release_clicks = if gesture.resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if gesture.resp.double_clicked() { + view.clicks.max(2) + } else { + 0 + }; + if release_clicks > 0 { + view.clicks = release_clicks; + } + let clicks = selection_click_count(view.clicks, gesture.tool); + if release_clicks > 0 + && clicks >= 2 + && gesture.over_text + && view.selection.is_none() + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { + let shift = ui.input(|input| input.modifiers.shift); + match ( + view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), + text.nearest(gesture.vx, gesture.vy), + ) { + (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, + _ => view.selection = None, + } + } +} + +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { + added, + allowed, + calc_order, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + generation, + info, + prefs, + preparing, + redact_mark, + table_preview, + tool, + unobstructed, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let painter = ui.painter(); + let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if tool == QuickTool::Select && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::drop_stale(doc, view); + crate::image_pick::paint(painter, &xf, i, info, view); + // A cancelled press ends no selection: no markup or redaction is made from it. + if !view.press_cancelled { + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } + } + if input.area_tool { + crate::redact_ui::paint(painter, i, view, pointer); + } + comments::paint_page(ui, painter, &pcx, view); + if editing_content { + crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); + } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::paint(painter, &xf, i, view); + } + if tool == QuickTool::Link { + crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); + } + if preparing { + crate::prepare::paint_page(tool_cx, form, view); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } + if calc_order { + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { + frame.calc_hover = Some(field); + } + } + } else { + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + } + + // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. + paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); + // Links: the hand and the link's target, which count only for the tools that follow or + // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is + // open, even beside it. A click follows the link its press was on, released over it: one that + // slips off the link first follows nothing, as a button's does. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.get(index) + { + frame.out.clicked_link = Some(l.target.clone()); + } + // Comments say what they are with any tool, beside its cursor, as Acrobat's do. + comments::describe(&pcx, view); + for (mp, mr, mc) in &view.compare_marks { + if *mp == i { + let sr = xf.user_rect(info, i, *mr).expand(1.5); + painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); + } + } + if let Some((fp, fr, t0)) = view.flash + && fp == i + { + let now = ui.input(|inp| inp.time); + let t0 = if t0 == 0.0 { now } else { t0 }; + view.flash = Some((fp, fr, t0)); + let age = (now - t0) as f32; + if age < 1.6 { + let a = ((1.6 - age) / 1.6 * 255.0) as u8; + let sr = xf.user_rect(info, i, fr).expand(4.0); + painter.rect_stroke( + sr, + CornerRadius::same(3), + Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } else { + view.flash = None; + } + } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); + let mark = Rect::from_center_size(sr.center(), vec2(side, side)); + icons::paint(ui, mark, icon, side * 0.64, color); + // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with + // any tool; a link's or a comment's tip wins over any badge. + if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { + view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); + } + } +} + +fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { + if !enabled { + return; + } + for field in form.iter() { + let required = field.has(pdfcraft_engine::field_flags::REQUIRED); + for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { + let rect = widget.rect; + let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); + let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; + if widget.round { + let radius = screen_rect.width().min(screen_rect.height()) / 2.0; + painter.circle_filled(screen_rect.center(), radius, tint); + painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); + } else { + painter.rect_filled(screen_rect, CornerRadius::same(1), tint); + painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); + } + } + } +} + +fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { + let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; + let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; + let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); + if !r.intersects(visible.expand(400.0)) { + return; + } + if pressed && frame.pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { + frame.pressed_page = Some(i); + } + if r.intersects(visible) { + frame.visible_now.push(i); + view.screen_rects.push((i, r)); + view.screen_xforms.push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); + } + let overlap = r.intersect(visible).height(); + if i == view.current { + frame.current_overlap = overlap; + frame.current_height = r.height(); + } + // Pages shown equally (two rows wholly on screen) differ only by rounding: the + // topmost one counts. + if overlap > frame.best_overlap + PAGE_TIE { + frame.best_overlap = overlap; + frame.current = i; + } + let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; + paint_page_surface(ui, view, frame, &PageSlot { i, r, xf }); + let painter = ui.painter(); + view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); + if editing_content { + view.move_background.paint(painter, &xf, i, info, &view.content, pointer); + } + if view.show_page_gaps { + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); + } + + let slot = PageSlot { i, r, xf }; + // A cancelled press takes nothing until the button is up. + let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); + view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); + view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + // Any other comment being moved: drawn where it goes, over the page that still shows it + // where it was. + let stale = view.signature_drag.moving_page().is_some_and(|p| view.page_out_of_date(p)); + view.signature_drag.prepare_move(ui.ctx(), doc, &view.comments, view.pending_edit.as_ref(), stale, scale); + view.signature_drag.paint_move(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + // A press that lands on something drawn over the page (a menu, a dialog) is not one. + if view.all_text && frame.pressed_page == Some(i) { + view.all_text = false; + } + text_layer(ui, view, frame, &slot, &input); + page_overlays(ui, view, frame, &slot, &input); +} + +/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows +/// links and the Link tool edits them (`Layer::reached_by`). +fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + let label = match &l.target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + let owner = crate::page_router::Owner::Link { index }; + Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is +/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The +/// frame of the release still belongs to the cancelled press. +fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { + // Over once the button is up and its release seen, or when a new press comes (its release and + // the next press can share a frame). + let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); + if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { + view.press_cancelled = false; + } + if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + // A text selection being dragged goes with its drag: the markup, redaction or Replace + // Text that its end would make is not made. + if view.selection_drag { + view.clear_selection(); + } + view.cancel_pointer_gestures(); + view.press_cancelled = true; + } +} + #[allow(clippy::cognitive_complexity)] -fn draw_pages( - ui: &mut egui::Ui, - viewport: Rect, - view: &mut DocView, - env: &PageEnv<'_>, - out: &mut PageOut, - signature_preview: &mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, -) -> PageRenders { - let PageEnv { - allowed, - auto_delta, - avail, - boost_extra, - calc_order, - can_copy, - can_modify, - content_h, - content_w, - doc, - edge_delta, - form, - generation, - glide, - hand, - info, - key_scroll, - middle_gesture, - modal, - navigation_enabled, - precise_scroll, - prefs, - preparing, - selection_wheel, - ppp, - rects, - single_paging, - tool, - unobstructed, - visible_pages, - y_shift, - .. - } = *env; - // egui's drag responses accept every pointer button. Keep a wheel gesture from also - // selecting text, drawing a mark, or panning with the Hand tool. - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - // Only interactions pause; the document must keep its original colours. - ui.set_opacity(opacity); - } - // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y - // moves the content down. All scroll without easing, unlike egui's wheel default. - // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution - // until after the text widgets when a selection owns that drag. - let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); - view.page_widget = Some(resp.id); - // A drag lasts only while egui reports it, so the mark can't outlive one. - if !resp.dragged_by(egui::PointerButton::Primary) { - view.selection_drag = false; - } - // The Hand tool pans by this widget's drag, before the pages are laid out (above). - if hand { - if resp.dragged() { - view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); - } else if resp.hovered() { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Grab); - } - } - let origin = resp_rect.min - vec2(0.0, y_shift); - let visible = viewport.translate(resp_rect.min.to_vec2()); - view.screen_rects.clear(); - view.screen_xforms.clear(); - // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), - // or a press they hold. The page modules read it rather than egui's raw pointer state. - let ptr = PagePointer::read(ui, &resp, modal); - cancel_on_escape(ui.ctx(), view, &ptr); - view.router.settle(ptr.any_down, !ptr.edges.is_empty()); - // A new press is no longer the last one's, wherever it lands (the page it is on gives it - // its owner; in the gap between pages it has none). - if ptr.pressed().is_some() { - view.router.cancel(); - // The page widget is not focusable. Explicitly return keyboard ownership from Pages - // when the user presses the document, without stealing focus through a modal. - if let Some(id) = view.pages_focus_id { - ui.memory_mut(|m| m.surrender_focus(id)); - } - } - let pointer = ptr.pos; - // A press on a page makes it the current one (as in Acrobat), so a page command with - // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page - // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ptr.pressed().is_some(); - if resp.secondary_clicked() { - // Set again below when the right-click lands on a link. - view.links.context = None; - let mut over_image = false; - if tool == QuickTool::EditText { - let pointer_pos = ptr.pos; - if let Some(p) = pointer_pos { - for &page_idx in visible_pages { - let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp); - if !r.intersects(visible) { - continue; - } - let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) }; - let images = doc.page_images(page_idx); - for (idx, img) in images.iter().enumerate().rev() { - let obb = ImageObb::from_image(img, &xf, info, page_idx); - let b = image_screen_rect(img, &xf, info, page_idx); - if (b.is_positive() && b.contains(p)) || crate::edit_text_ui::quad_contains(obb.corners(), p) { - over_image = true; - if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) { - view.image_selection = Some(crate::edit_text_ui::ImageSelection { - page: page_idx, - index: idx, - drag: None, - crop: None, - crop_mode: false, - crop_rect: None, - turning: false, - settled: None, - }); - } - break; - } - } - if over_image { - break; - } - } - } - } - if !over_image && tool == QuickTool::EditText { - view.image_selection = None; - } - } - let mut frame = PageFrame { - env: *env, - resp: &resp, - ptr: &ptr, - origin, - visible, - pressed, - pointer, - signature_preview, - out: std::mem::take(out), - wanted: Vec::new(), - visible_now: Vec::new(), - current: view.current, - best_overlap: -1.0, - current_overlap: -1.0, - current_height: f32::INFINITY, - pressed_page: None, - calc_hover: None, - }; - for &i in visible_pages { - draw_page(ui, view, &mut frame, i); - } - let PageFrame { - wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. - } = frame; - let PageOut { - mut canvas_action, - clicked_link, - copy_selected, - mut field_menu, - field_placed, - field_props, - hover_text: _, - mut image_action, - open_boxes, - open_initials, - open_signature, - open_table, - page_action: _, - primary_down: _, - refused, - stamp_placed, - } = page_out; - let mut page_action: Option = None; - let painter = ui.painter(); - if calc_order { - view.calc_order.set_hover_page(calc_hover); - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); - } - // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. - let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); - // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag - // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the - // pointer leaves the selection's page for the gap or the next page; over the whole - // document area, like #759. Combine direct and smoothed motion in one request so mixed - // Point and Line events retain their full contribution. - if selection_wheel { - let smooth = if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; - let delta = precise_scroll + boost_extra + smooth; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - } - // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view - // so it can reach text off screen (#1670); the selection follows the pointer on its page. - if view.selection_drag - && navigation_enabled - && unobstructed - && !single_paging - && let Some(p) = ptr.pos - { - let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); - if dy != 0.0 { - view.end_find_following(); - ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); - // Keep scrolling while the pointer rests there and sends no events. - ui.ctx().request_repaint(); - } - } - // The page navigated to stays current while no page shows more of itself, so the next - // page step starts from it rather than from a page further down the screen (#188). - // It also stays current while it is wholly on screen: a short page (a cheque) gone to - // from the Pages panel shows less of itself than the tall page below it, and must not - // hand the highlight to that page. - if current_overlap >= best_overlap - PAGE_TIE || current_overlap >= current_height - PAGE_TIE { - current = view.current; - } - if let Some(p) = pressed_page { - current = p; - } - if view.continuous { - view.current = current; - } - resp.context_menu(|ui| { - // Preparing a form: the selected field's menu. - if preparing && let Some((name, wi)) = view.prepare.selected.clone() { - // Several fields: Align, Center, Distribute, Set Fields to Same Size. - if !view.prepare.also.is_empty() { - use crate::prepare::Arrange as A; - let others = view.prepare.also.clone(); - let anchor = (name.clone(), wi); - let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { - if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { - view.pending_edit = crate::prepare::arrange(form, &anchor, &others, op); - ui.close(); - } - }; - ui.menu_button(tl!("Align"), |ui| { - pick(ui, A::AlignLeft, "Left"); - pick(ui, A::AlignRight, "Right"); - pick(ui, A::AlignTop, "Top"); - pick(ui, A::AlignBottom, "Bottom"); - pick(ui, A::AlignCenterV, "Center vertically"); - pick(ui, A::AlignCenterH, "Center horizontally"); - }); - ui.menu_button(tl!("Distribute"), |ui| { - pick(ui, A::DistributeH, "Horizontally"); - pick(ui, A::DistributeV, "Vertically"); - }); - ui.menu_button(tl!("Set Fields to Same Size"), |ui| { - pick(ui, A::SameHeight, "Height"); - pick(ui, A::SameWidth, "Width"); - pick(ui, A::SameSize, "Both"); - }); - ui.separator(); - } - if ui.button(tl!("Properties…")).clicked() { - field_menu = Some(FieldMenu::Properties); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { - field_menu = Some(FieldMenu::Duplicate(name.clone())); - ui.close(); - } - ui.separator(); - let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; - if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { - view.pending_edit = crate::prepare::delete_selected(view); - ui.close(); - } - return; - } - if let Some((page, index)) = view.links.context { - if ui.button(tl!("Properties…")).clicked() { - view.links.open_existing = Some((page, index)); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { - view.pending_edit = Some(Edit::DeleteLink { page, index }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - ui.separator(); - if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { - view.pending_edit = Some(Edit::RemoveLinks { pages: None }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - return; - } - let can_paste_image = env.can_paste_image; - let is_image_menu = tool == QuickTool::EditText - && (ui.ctx().data_mut(|d| d.get_persisted::(crate::edit_text_ui::image_menu_id())).unwrap_or(false) - || view.image_selection.as_ref().is_some_and(|sel| { - let pointer_pos = ptr.pos; - let Some(p) = pointer_pos else { return false }; - let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page) else { return false }; - let images = doc.page_images(sel.page); - let Some(img) = images.get(sel.index) else { return false }; - let obb = ImageObb::from_image(img, xf, info, sel.page); - let b = image_screen_rect(img, xf, info, sel.page); - (b.is_positive() && b.contains(p)) || crate::edit_text_ui::quad_contains(obb.corners(), p) - })); - if is_image_menu && let Some(sel) = view.image_selection.clone() { - let images = doc.page_images(sel.page); - let at = view.comments.context_at.map(|(_, at)| at); - crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at); - return; - } - if view.comments.selected.is_some() { - canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); - return; - } - // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. - let mut copy_image = None; - if let Some((page, at)) = view.comments.context_at { - // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::unique("copy-image-under-menu"); - let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); - let hit = match cached { - Some(c) => c.2, - None => { - let hit = doc.page_image_at(page, at); - ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); - hit - } - }; - if let Some(image) = hit { - if ui.button(tl!("Copy Image")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImage(page, image)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); - ui.close(); - } - ui.separator(); - } - } - let has_copy_image = copy_image.is_some(); - if copy_image.is_some() { - canvas_action = copy_image; - } - let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0])); - let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image); - let can_paste = can_paste_image && can_modify; - let perms = PageMenuPerms { - can_modify, - can_assemble: doc.allows_assembly(), - can_annotate: allowed, - can_print: doc.allows_printing(), - can_copy: can_copy_items, - can_paste, - has_annotations: !info.annotations.is_empty(), - }; - default_page_context_menu(ui, page, at, perms, &mut page_action); - }); - if !resp.context_menu_opened() { - ui.ctx().data_mut(|d| d.insert_persisted(crate::edit_text_ui::image_menu_id(), false)); - } - *out = PageOut { - canvas_action, - clicked_link, - copy_selected, - field_menu, - field_placed, - field_props, - hover_text, - image_action, - open_boxes, - open_initials, - open_signature, - open_table, - page_action, - primary_down: ptr.primary_down, - refused, - stamp_placed, - }; - (wanted, visible_now) -} - -/// A zoom the fit mode changed keeps the page at the top of the view in place. -fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { - if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { - let prev_viewport = view.viewport_screen; - let top_y = prev_viewport.min.y; - let anchor = view - .screen_rects - .iter() - .find(|(_, r)| r.min.y <= top_y && r.max.y > top_y) - .map(|(p, r)| { - let fy = if r.height() > 0.0 { ((top_y - r.min.y) / r.height()).clamp(0.0, 1.0) } else { 0.0 }; - (*p, fy, 0.0f32) - }) - .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) - .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); - if let Some((page, fy, rel_y)) = anchor { - view.zoom_anchor = Some((page, 0.5, fy, vec2(avail_width / 2.0, rel_y))); - } else { - view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail_width / 2.0, MARGIN))); - } - } -} - -/// What the pager needs to know about the page row on screen. -#[derive(Clone, Copy)] -struct PagingCx<'a> { - row: &'a [usize], - rects: &'a [Rect], - avail: Rect, - middle_gesture: bool, - unobstructed: bool, -} - -/// Not scrolling: when the whole page or spread fits, the wheel turns pages instead. Returns whether -/// paging owns the wheel, and whether it turned the page at an edge. -fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> (bool, bool) { - let (mut single_paging, mut edge_turned) = (false, false); - let PagingCx { row, rects, avail, middle_gesture, unobstructed } = *cx; - if !view.continuous { - // The row's bounding box on screen (one page, or a two-page spread). - let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union); - // Within a point, so layout rounding can't stop a fitting page from turning. - let fits = row_rect.is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0); - let can_turn = !middle_gesture && unobstructed && ui.rect_contains_pointer(avail); - single_paging = can_turn && fits; - // Every wheel event goes to the pager, so it follows each trackpad touch to its end - // even while the page can't turn. - ui.input(|i| { - for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { - // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. - let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; - if fits { - view.single_page_wheel(*unit, dy, *phase, i.time, can_turn); - } else { - edge_turned |= view.single_page_wheel_edge(*unit, dy, *phase, i.time, can_turn); - } - } - } - }); - if single_paging { - // Paging owns vertical wheel motion here. A diagonal gesture keeps its sideways - // part, as `ScrollArea` itself handles each axis. - ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); - } - } - (single_paging, edge_turned) -} - -/// This frame's wheel motion over the page view, after paging and point/line suppression. -struct Wheel { - precise_scroll: Vec2, - line_base: Vec2, - boost_extra: Vec2, - has_page: bool, - /// The pager owns the vertical axis (single-page view, or the rest of a gesture that turned the page). - paging_owns_vertical: bool, -} - -/// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, smoothed -/// base and adds only the accelerated surplus. While point suppression is active, line and page -/// input are reconstructed so current coarse events survive without exposing an older eased tail. -fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool) -> Wheel { - let now = ui.input(|i| i.time); - let input = if ui.rect_contains_pointer(avail) { - // Match the line distance used by egui's other scroll areas. - let live = ui.ctx().options(|o| o.input_options.line_scroll_speed); - let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS }; - ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging)) - } else { - crate::wheel_boost::FrameInput::default() - }; - let mut precise_scroll = input.precise; - let mut line_base = input.line; - let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now); - let has_page = input.page != Vec2::ZERO; - if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page { - view.precise_scroll_until = now + SUPPRESS_SECS; - } - if edge_turned { - // The turn leaves egui's eased copy of the notch to decay for a few frames; it must not - // scroll the page just shown away from its top (or bottom). - view.precise_scroll_until = now + 0.25; - view.fling.stop(); - } - // Paging owns the vertical axis in single-page view; leave it to the pager. - // The rest of a gesture that turned the page is swallowed too, or it would pan the new page - // away from the edge it was shown at. - let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); - if paging_owns_vertical { - precise_scroll.y = 0.0; - line_base.y = 0.0; - boost_extra.y = 0.0; - } - // egui smooths a Point event that moved at least 8 points, keeping part of it for later - // passes. Suppression must outlive that whole action: once it expired after a fixed 0.25 s, - // the last few eased points leaked through the ScrollArea after a precise 1:1 scroll (held - // point input measured 33 points for a 30-point event). Keep hiding the eased copy until - // egui says the action is over, then forget the suppression. - let scrolling = ui.input(|i| i.is_scrolling()); - if now < view.precise_scroll_until || (view.precise_scroll_until != 0.0 && scrolling) { - if scrolling { - // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace - // avoids a delayed duplicate without prolonging suppression after the tail is gone. - view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); - } - ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); - // The point path suppresses every eased wheel delta. Restore the line base through the - // same un-eased path as its surplus; paging-owned axes were cleared above. - boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base); - if has_page { - // Page units normally stay on egui's viewport-sized path. During suppression only, - // reproduce the current event directly so an older hidden tail stays hidden. - let page_scale = ui.input(|i| i.viewport_rect().height()); - let mut page_delta = - if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO }; - if paging_owns_vertical { - page_delta.y = 0.0; - } - boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta); - } - } else { - precise_scroll = Vec2::ZERO; - // Selection handling below uses zero to distinguish an inactive window. Clearing a past - // deadline also keeps a later line/page action from being mistaken for the old tail. - view.precise_scroll_until = 0.0; - } - if has_page && paging_owns_vertical { - // Page input belongs to egui on the free axis; the pager still owns vertical motion. - ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); - } - Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } -} - -/// What decides which renders a frame asks for. -#[derive(Clone, Copy)] -struct RenderPlan<'a> { - visible_pages: &'a [usize], - tag: u64, - scale: f32, - sharp_held: bool, - pan_held: bool, - hand: bool, -} - -/// Queues the renders for the pages and tiles `renders` wants (nearest the view first), prefetches the -/// rows either side, frees tiles of far pages and old zooms, and asks for the text layers. -fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: PageRenders, plan: &RenderPlan<'_>) { - let (mut wanted, visible_now) = renders; - let RenderPlan { visible_pages, tag, scale, sharp_held, pan_held, hand } = *plan; - // Single-page and two-page views draw only the current row, so the next one was first asked - // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, - // as continuous scrolling does for the pages just off screen. - if !view.continuous - && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) - { - let n = if view.layout == PageLayout::Single { 1 } else { 2 }; - let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; - for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { - let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; - // As drawn: the whole page at this scale, or the backdrop of a tiled page. - let side = p.width.max(p.height).max(1.0); - wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { - let bs = BASE_SIDE.min(max_side) / side; - (i, bs, scale_tag(bs), None) - } else { - (i, scale, tag, None) - }); - } - } - let cur = view.current; - wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); - // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page - // that is on screen, whether or not the page is tiled at this zoom (#2196): a zoom out below - // the tiling threshold used to drop the tiles of the zoom just left, so coming back to it - // was a cold miss and the page re-blurred through the backdrop while every tile was - // interpreted again. `OLD_TILES` still bounds how many older tiles are kept, nearest scale - // first, and admission keeps them inside `TILE_BYTES`. - let missing: HashSet = wanted.iter().filter(|w| w.3.is_some()).map(|w| w.0).collect(); - let on_screen: HashSet = visible_now.iter().copied().collect(); - view.tiles.retain(|(p, t, _, _), _| p.abs_diff(cur) <= 2 && (*t == tag || missing.contains(p) || on_screen.contains(p))); - let mut older: Vec<(f32, (usize, u64, u32, u32))> = - view.tiles.keys().filter(|k| k.1 != tag).map(|k| ((tag_scale(k.1) / scale).ln().abs(), *k)).collect(); - // Tiles of earlier zooms are kept nearest scale first, and bounded by count *and* bytes: a - // large scan renders a tile up to 4096² (about 67 MiB), so a count alone let a few tiles of - // a few zooms run far past the tile allowance. #2196 keeps a zoom already visited, which - // makes that reachable in ordinary zooming, so the byte bound has to be here too. - older.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut kept = 0usize; - let mut kept_bytes = 0usize; - let mut drop: Vec<(usize, u64, u32, u32)> = Vec::new(); - for (_dist, key) in older { - let bytes = view.tiles.get(&key).map_or(0, texture_bytes); - if kept >= OLD_TILES || kept_bytes.saturating_add(bytes) > TILE_BYTES { - drop.push(key); - } else { - kept = kept.saturating_add(1); - kept_bytes = kept_bytes.saturating_add(bytes); - } - } - for key in drop { - view.tiles.remove(&key); - } - let mut queue: Vec = wanted - .iter() - .map(|&(page, wanted_scale, wanted_tag, tile)| RenderRequest { - page, - kind: RequestKind::Pixels, - tile, - scale: wanted_scale, - tag: wanted_tag, - // The low-res backdrop under tiles is a preview: tiny images are skipped for it. - // It is the whole-page request whose scale is not the current one; sharp tiles - // and whole-page finals always draw everything. - preview: tile.is_none() && (wanted_scale != scale || wanted_tag != tag), - }) - .collect(); - // While the zoom or pan is settling, hold back sharp re-renders of pages that already - // show something (a stale whole-page raster or older tiles, drawn stretched): each new - // scale would cancel the running full-page interpret and restart it from scratch, and - // each pan frame would queue a storm of full-page tile interprets. - // Pages with nothing on screen yet still queue, so panning to a fresh page draws it. - if sharp_held || pan_held { - let main_tag = tag; - queue.retain(|req| { - // Fast pages render straight through: the hold only protects heavy pages - // whose full-page interpret costs seconds. - if !view.slow_pages.contains(&req.page) { - return true; - } - if req.tile.is_some() { - let has_current_tile = view.tiles.keys().any(|(p, t, _, _)| *p == req.page && *t == req.tag); - if has_current_tile { - return false; - } - let has_any_surface = view.pages.contains_key(&req.page) || view.tiles.keys().any(|(p, _, _, _)| *p == req.page); - !has_any_surface - } else if req.tag != main_tag { - // The low-res backdrop under tiles is zoom-independent: let it through, - // it is the stretched surface the hold relies on. - true - } else { - // The stale raster stays stretched until the zoom or pan settles; a page with - // nothing on screen yet still renders. - !view.pages.contains_key(&req.page) - } - }); - } - // Text layers: visible pages for selection, every page while a search is active. (Select All - // of the document needs no more: shown pages highlight as their text comes, and Copy reads - // the rest, as text far away is let go to bound memory.) - let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); - let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; - if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { - let n = info.pages.len(); - let rest: Vec = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect(); - text_pages.extend(rest); - } - queue.extend(text_pages.into_iter().map(|page| RenderRequest { - page, - kind: RequestKind::Text, - tile: None, - scale: 1.0, - tag: TEXT_TAG, - preview: false, - })); - view.frame_queue = queue; - view.frame_visible = visible_now.iter().copied().collect(); -} - -/// Acts on the notice bar button that was clicked. -fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { - match notice { - #[cfg(not(target_arch = "wasm32"))] - Some(Notice::Reload) => app.watch.reload_request = app.views.get(index).map(|view| view.id), - #[cfg(target_arch = "wasm32")] - Some(Notice::Reload) => {} - Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), - Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), - Some(Notice::Unlock) => { - if let Some(doc) = app.views.get(index).and_then(|view| app.session.get(view.id)) { - app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)); - } - } - // Flags the view: the tab closes at the top of the next frame, where no document - // borrow is held. - Some(Notice::Close) => { - if let Some(view) = app.views.get_mut(index) { - view.close_requested = true; - } - } - Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), - Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, - Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), - // Back on in Preferences ▸ General. - Some(Notice::HideSecured) => app.show_secured_notice = false, - None => {} - } -} - -/// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The -/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own -/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about). -fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { - let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); - let Some(i) = pending else { return false }; - app.views[i].close_requested = false; - if app.has_unsaved_work(i) { - app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); - } else { - app.close_tab(i); - } - true -} - -/// Where the pages are laid out, for placing the scroll area. -#[derive(Clone, Copy)] -struct ScrollGeometry<'a> { - rects: &'a [Rect], - y_shift: f32, - content_w: f32, - content_h: f32, - avail: Rect, - middle_gesture: bool, - hand: bool, -} - -/// The page view's scroll area, with the offset this frame's jumps, drags and zoom anchor ask for. -fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { - let ScrollGeometry { rects, y_shift, content_w, content_h, avail, middle_gesture, hand } = *geometry; - let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { - drag: if middle_gesture { - egui::scroll_area::DragScroll::Never - } else if hand { - egui::scroll_area::DragScroll::Always - } else { - egui::scroll_area::DragScroll::OnTouch - }, - ..Default::default() - }); - // Each document keeps its own scroll position when switching tabs (#189). - scroll = scroll.id_salt(("page-view", view.id)); - // Every scroll here asks for no animation. An animated area would still queue a zero-length - // offset target for the next frame, which overwrites the jump a page turn just set. - scroll = scroll.animated(false); - // A newly opened document starts at the top. eframe saves egui's memory with the settings - // and document ids start again at 1 every launch, so the offset stored under this id can be - // where an earlier session left another document. - if !std::mem::replace(&mut view.shown, true) { - scroll = scroll.scroll_offset(Vec2::ZERO); - } - // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer - // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the - // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a - // click, the page catches up with all the pointer has moved since the press. A jump asked for - // below still wins. - if hand - && !middle_gesture - && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) - { - let moved = if ui.ctx().drag_started_id() == Some(id) { - ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) - } else { - ui.input(|i| i.pointer.delta()) - }; - if moved != Vec2::ZERO { - view.end_find_following(); - } - // Within the content, as egui keeps it: past an end the page would show there for a - // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. - let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); - } - if let Some(v) = view.sync_scroll.take() { - scroll = scroll.scroll_offset(v); - } - if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { - let r = rects[page.min(rects.len() - 1)]; - let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); - scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); - } else if let Some((page, frac)) = view.goto.take() { - let page = page.min(rects.len() - 1); - let r = rects[page]; - // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() - // too high for a frame and then hopped into place. (`avail` is the viewport, as the - // scroll bars float.) - let max_y = (content_h - avail.height()).max(0.0); - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); - // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off - // to one side. A page already shown whole, or one filling the window's width, keeps the - // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a - // page that fits is centred, and a wider one scrolled just far enough to fill the window. - // The page counts with its side gutters, which keep its edge clear of the floating - // quick-action bar as the layout does. - let (x, w) = (view.last_scroll.x, avail.width()); - let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; - if (target - x).abs() > 0.5 { - scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); - } - } - scroll -} - -/// Notes how far the page view scrolled this frame: reader-owned scrolling ends search following, -/// a pan holds back fresh tile renders until it settles, and a glide keeps going or stops. Whether -/// a pan is still settling. -fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput, glide: Vec2) -> bool { - let scroll_offset = out.state.clamped_offset(); - // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas - // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag - // background. The output gives us the area id, and egui's pinned ScrollArea registers those - // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. - // Ask for their exact responses instead of treating any held drag as scrolling: a text - // selection, a panel splitter or another widget's drag must not excuse the programmatic jump - // a search flash makes, while a track click (which moves the offset with no pointer motion) - // must still count as the reader scrolling. - let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); - let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); - if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { - view.end_find_following(); - } - let now_settle = ui.input(|i| i.time); - if (scroll_offset - view.last_scroll).length() > 2.0 { - view.pan_hold_until = now_settle + 0.25; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); - } - if glide != Vec2::ZERO { - view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); - } - view.last_scroll = scroll_offset; - now_settle < view.pan_hold_until -} - -/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the -/// text editor was finished. -fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { - let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; - if let Some((pos, text)) = hover_text { - // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. - let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); - tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_max_width(320.0); - ui.label(text); - }); - }); - } - find_bar(view, info.pages.len(), avail, ui, t); - page_size_hint(view, info, units, avail, unobstructed, ui, t); - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); - } - if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { - view.pending_edit = Some(e); - } - if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { - view.pending_edit = Some(e); - } - let text_box = crate::forms_ui::text_box(doc, view, form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { - view.pending_edit = Some(e); - } - if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { - view.pending_edit = Some(e); - } - let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); - if typed.is_some() { - view.pending_edit = typed; - } - text_done -} - -/// Full screen: the click turns the page unless it did something else first. It waits out the -/// double-click time: turning on the first click's release sent a double-click's second click, -/// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a -/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. -/// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. -fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { - let now = ui.input(|i| i.time); - if let Some(back) = click - && !link_followed - && view.selection.is_none() - && view.clicks < 2 - { - let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); - view.present_turn = Some((now + delay, !back)); - } - if let Some((due, forward)) = view.present_turn { - if link_followed || view.selection.is_some() || view.presentation.is_none() { - view.present_turn = None; - } else if now >= due { - view.present_turn = None; - view.step_page(forward); - } else { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); - } - } -} - -/// Follows the link that was clicked. A link to another file comes back, to be opened once the -/// document is no longer borrowed. -fn follow_link( - app: &mut PdfCraftApp, - index: usize, - target: Option, -) -> Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> { - let view_id = app.views[index].id; - match target? { - LinkTarget::Page(p, dest) => { - let view = &mut app.views[index]; - if let Some(doc) = app.session.get(view.id) { - view.go_to_dest(p, dest, &doc.info); - } - } - LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link), - LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb), - LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)), - LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), - } - None -} - -/// A finished marquee zoom, a signature rectangle drawn, or an empty signature field clicked. -/// Returns the tool to go on with. -fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool) -> QuickTool { - if let Some(done) = app.views[index].marquee_done.take() { - app.finish_marquee(index, done); - } - // A signature rectangle was drawn, or an empty signature field clicked. - let drawn = app.views[index].sign.drawn.take(); - if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { - tool = QuickTool::Select; - app.quick_tool = tool; - app.start_signing(page, Some(rect), None, certify.then_some(2)); - } - if let Some(field) = app.views[index].sign.field.take() { - let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); - if signed { - app.right = Some(RightPanel::Signatures); - if !app.sig_expanded.contains(&field) { - app.sig_expanded.push(field); - } - } else { - let page = app.views[index].current; - app.start_signing(page, None, Some(field), None); - } - } - tool -} - -/// When not scrolling continuously, only one row is shown at a time: the current page, or the -/// current two-page spread (with the cover rule). These indices drive paging, which pages are -/// drawn, and the content height (#752). Empty for a document with no pages. -fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { - if rects.is_empty() { - return Vec::new(); - } - let last = rects.len() - 1; - let cur = view.current.min(last); - match view.layout { - PageLayout::Single => vec![cur], - PageLayout::TwoUp if view.cover && cur == 0 => vec![0], - PageLayout::TwoUp => { - let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; - if first < last { vec![first, first + 1] } else { vec![first] } - } - } -} - -/// Full screen: a click on the document goes forward a page and a shift-click back, as in -/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages -/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. -fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { - let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); - // Shift as held now, or as the click's own event carried it (a synthetic click). - clicked.then(|| { - ui.input(|i| { - i.modifiers.shift - || i.events.iter().any(|e| { - matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) - }) - }) - }) -} - -/// Pinch / ctrl+scroll zoom, anchored on the pointer or the current page. -fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rect: Rect, enabled: bool) { - if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && enabled && ui.rect_contains_pointer(navigation_rect) { - let z = view.zoom * zoom_delta; - match zoom_anchor(ui) { - Some(p) => view.zoom_at(z, p), - None => view.set_zoom(z), - } - } -} - -/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the -/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point -/// beyond what is shown can be reached without letting go of the tool. -fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { - let auto_delta = if unobstructed { - view.auto_scroll.update(ui, avail, false) - } else { - view.auto_scroll.cancel(); - Vec2::ZERO - }; - - // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view - // that way, so a point beyond what is shown can be reached without letting go of the tool. - let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = drawing_edge_pointer(ui); - crate::autoscroll::edge_scroll(pointer, avail, dt) - } else { - Vec2::ZERO - }; - if edge_delta != Vec2::ZERO { - ui.ctx().request_repaint(); - } - (auto_delta, edge_delta) -} - -/// What decides whether touchpad momentum goes on. -#[derive(Clone, Copy)] -struct GlideCx { - now: f64, - may_glide: bool, - key_scroll: f32, - auto_delta: Vec2, - zoom_delta: f32, - edge_delta: Vec2, - wheel_scrolled: bool, -} - -/// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a stop, -/// through the same un-eased path. Never while the pager owns the wheel, a gesture or dialog has -/// the input, or animations are off; any click, key, zoom or other scrolling ends it. Any -/// reader-owned motion ends automatic search following, even when it stays on the same page; the -/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for -/// a reader action. Returns this frame's glide. -fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { - let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; - let over_page = ui.rect_contains_pointer(avail); - ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { - view.fling.stop(); - } - let glide = view.fling.step(now); - if view.fling.gliding() { - ui.ctx().request_repaint(); - } - if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { - view.end_find_following(); - } - glide -} - -/// Text selection runs for the Select tool and for the markup tools (highlight…). -fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { - match tool { - QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, - QuickTool::Select => !preparing, - QuickTool::Redact | QuickTool::ColumnSelect => true, - QuickTool::Hand - | QuickTool::Measure(_) - | QuickTool::Crop - | QuickTool::EnhanceCamera - | QuickTool::Fill(_) - | QuickTool::Field(_) - | QuickTool::AddText - | QuickTool::EditText - | QuickTool::Stamp(_) - | QuickTool::CustomStamp(_) - | QuickTool::Link - | QuickTool::SignArea { .. } - | QuickTool::MarqueeZoom - | QuickTool::Snapshot => false, - } -} - -/// Holds sharp re-renders while the zoom is still moving: a new scale every frame would cancel the -/// in-flight full-page interpret and restart it, so on a heavy page (a CAD plot with ~1000 images -/// renders for seconds) the sharp raster never arrives and the view looks stuck on a soft image. -/// The stale raster stays stretched meanwhile; the sharp request goes out once the zoom settles. -fn hold_sharp_renders(ui: &egui::Ui, view: &mut DocView, scale: f32) -> bool { - let now_scale = ui.input(|i| i.time); - if (scale - view.last_scale).abs() > f32::EPSILON { - view.last_scale = scale; - view.sharp_hold_until = now_scale + 0.35; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); - } - now_scale < view.sharp_hold_until -} - -/// A destination's position (bookmarks, links) refines `goto`: its point at the window's top-left, -/// or the centre of a /FitR rectangle at the window's centre. -fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { - let ScrollGeometry { rects, y_shift, content_w, content_h, avail, .. } = *geometry; - if let Some((page, fx, fy, rel)) = view.goto_point.take() { - let r = rects[page.min(rects.len() - 1)]; - // Within the content, so an unreachable point never shows a frame scrolled past the end. - let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - if let Some(fx) = fx { - scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); - } - if let Some(fy) = fy { - scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); - } - } - scroll -} - -/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a -/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. -pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let can_paste_key = egui::Id::unique("can-paste-image"); - let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) { - let can = app.can_paste_image(); - ui.data_mut(|d| d.insert_temp(can_paste_key, can)); - can - } else { - ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false) - }; - // Keyboard scrolling applies to this frame's page view only, never later. - let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { - app.views[index].end_find_following(); - } - // The Search panel closed: its search moves to the find bar. - let search_open = app.right == Some(crate::RightPanel::Search); - if let Some(f) = app.views[index].find.as_mut() - && f.in_panel - && !search_open - { - f.in_panel = false; - } - if close_requested_tab(app, index) { - return; - } - let Some(doc) = app.session.get(app.views[index].id) else { return }; - let info = &doc.info; - let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { - let [r, g, b] = p.background; - Color32::from_rgb(r, g, b) - }); - if info.pages.is_empty() { - app.views[index].auto_scroll.cancel(); - ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); - return; - } - // The print dialog queues its own rasters later in the frame. Closing it drops them. - if app.dialog != Some(crate::Dialog::Print) { - app.views[index].print_pages.clear(); - } - // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. - let preparing = app.is_preparing(); - // Prepare a form ▸ Calculation order: the order's steps and links on the page. - let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; - // Edit a PDF: added text and images can be selected, moved and edited. - let editing_content = - (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); - let text_style = app.text_style.clone(); - let show_secured_notice = app.show_secured_notice; - let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. The notice says so unless - // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ - // Security, stay as they are. - let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); - let repaired = !doc.repair_log().is_empty(); - let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); - apply_notice(app, index, notice); - let Some(doc) = app.session.get(app.views[index].id) else { return }; - let info = &doc.info; - // No dialog, close prompt or palette over the page: only then does page input count. - let modal = app.modal_open(); - let unobstructed = !modal && !app.palette_open; - let organize_zoom = app.organize_zoom; - let view = &mut app.views[index]; - if view.organize { - // One remembered grid size for every document, kept in the settings. - view.set_grid_zoom(organize_zoom); - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); - app.organize_zoom = view.grid_zoom(); - return; - } - - let avail = ui.available_rect_before_wrap(); - view.viewport_w = avail.width(); - view.viewport_h = avail.height(); - let old_zoom = view.zoom; - view.fit_zoom(info); - anchor_zoom_change(view, avail.width(), old_zoom); - - // Pinch / ctrl+scroll zoom, anchored on the current page. - let zoom_delta = crate::motion::zoom_delta(ui.ctx()); - // Exclude the previous frame's scrollbar tracks from gesture activation. - let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); - let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); - pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); - view.viewport_screen = avail; - let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); - - let content_w = view.content_width(info, avail.width()); - let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); - // When not scrolling continuously, only one row is shown at a time: the current page, or the - // current two-page spread (with the cover rule). These indices drive paging, which pages are - // drawn, and the content height (#752). Empty for a document with no pages. - let row = shown_row(view, &rects); - let middle_gesture = view.auto_scroll.blocks_input(); - // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns - // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on - // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with - // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow - // keys. Runs before `visible_pages` so the frame that turns the page draws it. - let (single_paging, edge_turned) = single_page_paging(ui, view, &PagingCx { row: &row, rects: &rects, avail, middle_gesture, unobstructed }); - // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, - // smoothed base and adds only the accelerated surplus. While point suppression is active, - // reconstruct line and page input here so current coarse events survive without exposing an - // older eased tail. - let now = ui.input(|i| i.time); - let Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } = read_wheel(ui, view, avail, single_paging, edge_turned); - let animates = app.animation_effects.animates(app.motion.reduced); - let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; - let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; - let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; - let glide = step_glide(ui, view, avail, &motion); - // The wheel may have turned the page above: draw the row it turned to. Measured from the old - // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. - let row = shown_row(view, &rects); - let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; - let (y_shift, content_h) = if view.continuous { - (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) - } else { - // Only the current row scrolls into view; the content is its bounding box plus margins. - match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { - Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), - None => (0.0, 0.0), - } - }; - - let mut scroll = page_scroll_area( - ui, - view, - &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand: app.quick_tool == QuickTool::Hand }, - ); - let ppp = ui.ctx().pixels_per_point(); - let scale = view.render_scale(ppp); - let tag = scale_tag(scale); - // Hold sharp re-renders while the zoom is still moving: a new scale every frame would - // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD - // plot with ~1000 images renders for seconds) the sharp raster never arrives and the - // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the - // sharp request goes out once the zoom settles. - let sharp_held = hold_sharp_renders(ui, view, scale); - view.current_scale_tag = tag; - let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; - // Text selection runs for the Select tool and for the markup tools (highlight…). - let selects_text = tool_selects_text(tool, preparing); - let prefs = &app.comment_prefs; - let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); - let units = app.units; - let allowed = doc.allows_annotation(); - let can_copy = doc.allows_copying(); - let comments_hidden = doc.comments_hidden(); - let form = doc.form.clone(); - let generation = doc.edit_generation(); - let can_fill = doc.allows_form_filling(); - let can_crop = doc.allows_assembly(); - let signature = app.signature.clone(); - let initials = app.initials.clone(); - let author = app.comment_prefs.author.clone(); - let today = app.session.today(); - // In Preferences ▸ Date format, or why it can't be stamped into the PDF. - let date_text = crate::date_text_for_pdf(&app.session); - let by_line = app.session.stamp_by_line(&author); - let custom_stamp = match app.quick_tool { - QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), - _ => None, - }; - let mut open_props: Option<(usize, usize)> = None; - // The table being set up in its window, shown on its own document's page. - let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); - let can_modify = doc.allows_modification(); - if preparing { - crate::prepare::after_refresh(view, &form); - } else { - view.prepare.selected = None; - } - view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); - view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); - let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; - let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; - if editing_content { - crate::content_ui::after_refresh(view, &added); - } else { - view.content.selected = None; - } - view.move_background.prepare(ui.ctx(), doc, &view.content, scale); - - scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); - let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let env = PageEnv { - added: &added, - allowed, - author: &author, - auto_delta, - avail, - boost_extra, - by_line: &by_line, - calc_order, - can_copy, - can_crop, - can_fill, - can_modify, - comments_hidden, - content_h, - content_w, - custom_stamp: &custom_stamp, - date_text: &date_text, - doc, - doc_links: &doc_links, - edge_delta, - editing_content, - form: &form, - generation, - glide, - hand, - info, - initials: &initials, - key_scroll, - middle_gesture, - modal, - navigation_enabled, - paper, - ppp, - precise_scroll, - prefs, - preparing, - rects: &rects, - redact_mark, - scale, - selection_wheel, - selects_text, - signature: &signature, - single_paging, - t: &t, - table_preview: &table_preview, - tag, - text_style: &text_style, - tool, - units, - unobstructed, - visible_pages: &visible_pages, - y_shift, - fill_text_size: app.fill_text_size, - fill_text_color: app.fill_text_color, - today, - can_paste_image, - }; - let mut page_out = PageOut::default(); - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { - draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) - }); - let PageOut { - canvas_action, - clicked_link, - mut copy_selected, - field_menu, - field_placed, - mut field_props, - hover_text, - mut image_action, - open_boxes, - open_initials, - open_signature, - open_table, - page_action, - primary_down, - mut refused, - stamp_placed, - } = page_out; - view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { - // At the top once the page's top edge shows where going to a page puts it (a gap - // below the window's top). Within a point, as for wheel paging, so layout rounding - // can't hide an edge. - let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); - (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) - }); - - view.viewport_screen = out.inner_rect; - view.auto_scroll.paint(ui, avail); - if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { - copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); - } - - // Collect all main-page demand, including cached surfaces, for byte admission later. - // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding - // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched - // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that - // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; - // pages with nothing on screen yet still queue. - let pan_held = track_scroll(ui, view, &out, glide); - plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); - - let text_done = frame_overlays(ui, view, &env, primary_down, hover_text); - if let Some(why) = view.objects.notice.take() { - refused = Some(why); - } - let form_notice = view.forms.notice.take(); - // One crop, then back to selecting (as Acrobat does). - let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. })); - let mut tool = app.quick_tool; - comments::keys(ui.ctx(), view, &mut tool, allowed); - if preparing { - crate::prepare::keys(ui.ctx(), view); - } - if editing_content { - crate::content_ui::keys(ui.ctx(), view); - } - if tool == QuickTool::Link { - crate::link_ui::keys(ui.ctx(), view); - } else { - view.links.selected = None; - } - if tool != QuickTool::EditText { - view.image_selection = None; - } - if stamp_placed { - tool = QuickTool::Select; - } - // Text: each click on the page starts a box (#74); Done in the editor goes back to selecting. - if text_done && tool == QuickTool::AddText { - tool = QuickTool::Select; - } - // One field, then back to selecting (Acrobat's default without "Keep tools pinned"). - if field_placed { - tool = QuickTool::Select; - } - if matches!(field_menu, Some(FieldMenu::Properties)) { - field_props = true; - } - let field_props = field_props.then(|| view.prepare.selected.clone()).flatten(); - match canvas_action { - Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e), - // `choose_right_panel` would borrow all of `app` while `view` is held; same effect. - Some(comments::CanvasAction::OpenComments) => { - app.right = Some(RightPanel::Comments); - app.comments_panel_closed = false; - } - Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), - Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)), - Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), - None => {} - } - schedule_presentation_turn(ui, view, present, clicked_link.is_some()); - let remote_open = follow_link(app, index, clicked_link); - if tool == QuickTool::Crop && cropped { - tool = QuickTool::Select; - } - let tool = finish_sign_requests(app, index, tool); - app.quick_tool = tool; - let requests = FrameRequests { - open_props, - field_props, - field_menu, - image_action, - page_action, - refused, - open_signature, - open_initials, - open_boxes, - open_table, - form_notice, - copy_selected, - remote_open, - form, - generation, - avail, - }; - apply_frame_requests(app, index, ui, requests); -} - -/// What the frame's input asked the app to do, once the document is no longer borrowed. -struct FrameRequests { - open_props: Option<(usize, usize)>, - field_props: Option<(String, usize)>, - field_menu: Option, - image_action: Option, - page_action: Option, - refused: Option, - open_signature: bool, - open_initials: bool, - open_boxes: bool, - open_table: Option<(usize, [f64; 4])>, - form_notice: Option, - copy_selected: bool, - remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, - form: Arc>, - generation: u64, - avail: Rect, -} - -/// Opens the dialogs and runs the app commands the frame asked for. +fn draw_pages( + ui: &mut egui::Ui, + viewport: Rect, + view: &mut DocView, + env: &PageEnv<'_>, + out: &mut PageOut, + signature_preview: &mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, +) -> PageRenders { + let PageEnv { + allowed, + auto_delta, + avail, + boost_extra, + calc_order, + can_copy, + can_modify, + content_h, + content_w, + doc, + edge_delta, + form, + generation, + glide, + hand, + info, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + precise_scroll, + prefs, + preparing, + selection_wheel, + ppp, + rects, + single_paging, + tool, + unobstructed, + visible_pages, + y_shift, + .. + } = *env; + // egui's drag responses accept every pointer button. Keep a wheel gesture from also + // selecting text, drawing a mark, or panning with the Hand tool. + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + // Only interactions pause; the document must keep its original colours. + ui.set_opacity(opacity); + } + // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide + // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y + // moves the content down. All scroll without easing, unlike egui's wheel default. + // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution + // until after the text widgets when a selection owns that drag. + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } + // The Hand tool pans by this widget's drag, before the pages are laid out (above). + if hand { + if resp.dragged() { + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); + } else if resp.hovered() { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Grab); + } + } + let origin = resp_rect.min - vec2(0.0, y_shift); + let visible = viewport.translate(resp_rect.min.to_vec2()); + view.screen_rects.clear(); + view.screen_xforms.clear(); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); + cancel_on_escape(ui.ctx(), view, &ptr); + view.router.settle(ptr.any_down, !ptr.edges.is_empty()); + // A new press is no longer the last one's, wherever it lands (the page it is on gives it + // its owner; in the gap between pages it has none). + if ptr.pressed().is_some() { + view.router.cancel(); + // The page widget is not focusable. Explicitly return keyboard ownership from Pages + // when the user presses the document, without stealing focus through a modal. + if let Some(id) = view.pages_focus_id { + ui.memory_mut(|m| m.surrender_focus(id)); + } + } + let pointer = ptr.pos; + // A press on a page makes it the current one (as in Acrobat), so a page command with + // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page + // gone to earlier that still shows whole beside it. + let pressed = unobstructed && ptr.pressed().is_some(); + if resp.secondary_clicked() { + // Set again below when the right-click lands on a link. + view.links.context = None; + let mut over_image = false; + if tool == QuickTool::EditText { + let pointer_pos = ptr.pos; + if let Some(p) = pointer_pos { + for &page_idx in visible_pages { + let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp); + if !r.intersects(visible) { + continue; + } + let xf = + PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) }; + let images = doc.page_images(page_idx); + for (idx, img) in images.iter().enumerate().rev() { + let obb = ImageObb::from_image(img, &xf, info, page_idx); + let b = image_screen_rect(img, &xf, info, page_idx); + if (b.is_positive() && b.contains(p)) || crate::edit_text_ui::quad_contains(obb.corners(), p) { + over_image = true; + if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) { + view.image_selection = Some(crate::edit_text_ui::ImageSelection { + page: page_idx, + index: idx, + drag: None, + crop: None, + crop_mode: false, + crop_rect: None, + turning: false, + settled: None, + }); + } + break; + } + } + if over_image { + break; + } + } + } + } + if !over_image && tool == QuickTool::EditText { + view.image_selection = None; + } + } + let mut frame = PageFrame { + env: *env, + resp: &resp, + ptr: &ptr, + origin, + visible, + pressed, + pointer, + signature_preview, + out: std::mem::take(out), + wanted: Vec::new(), + visible_now: Vec::new(), + current: view.current, + best_overlap: -1.0, + current_overlap: -1.0, + current_height: f32::INFINITY, + pressed_page: None, + calc_hover: None, + }; + for &i in visible_pages { + draw_page(ui, view, &mut frame, i); + } + let PageFrame { + wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. + } = frame; + let PageOut { + mut canvas_action, + clicked_link, + copy_selected, + mut field_menu, + field_placed, + field_props, + hover_text: _, + mut image_action, + open_boxes, + open_initials, + open_signature, + open_table, + page_action: _, + primary_down: _, + refused, + stamp_placed, + } = page_out; + let mut page_action: Option = None; + let painter = ui.painter(); + if calc_order { + view.calc_order.set_hover_page(calc_hover); + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); + } + // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. + let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); + // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag + // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the + // pointer leaves the selection's page for the gap or the next page; over the whole + // document area, like #759. Combine direct and smoothed motion in one request so mixed + // Point and Line events retain their full contribution. + if selection_wheel { + let smooth = if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; + let delta = precise_scroll + boost_extra + smooth; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view + // so it can reach text off screen (#1670); the selection follows the pointer on its page. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && let Some(p) = ptr.pos + { + let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); + if dy != 0.0 { + view.end_find_following(); + ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); + // Keep scrolling while the pointer rests there and sends no events. + ui.ctx().request_repaint(); + } + } + // The page navigated to stays current while no page shows more of itself, so the next + // page step starts from it rather than from a page further down the screen (#188). + // It also stays current while it is wholly on screen: a short page (a cheque) gone to + // from the Pages panel shows less of itself than the tall page below it, and must not + // hand the highlight to that page. + if current_overlap >= best_overlap - PAGE_TIE || current_overlap >= current_height - PAGE_TIE { + current = view.current; + } + if let Some(p) = pressed_page { + current = p; + } + if view.continuous { + view.current = current; + } + resp.context_menu(|ui| { + // Preparing a form: the selected field's menu. + if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Several fields: Align, Center, Distribute, Set Fields to Same Size. + if !view.prepare.also.is_empty() { + use crate::prepare::Arrange as A; + let others = view.prepare.also.clone(); + let anchor = (name.clone(), wi); + let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { + if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { + view.pending_edit = crate::prepare::arrange(form, &anchor, &others, op); + ui.close(); + } + }; + ui.menu_button(tl!("Align"), |ui| { + pick(ui, A::AlignLeft, "Left"); + pick(ui, A::AlignRight, "Right"); + pick(ui, A::AlignTop, "Top"); + pick(ui, A::AlignBottom, "Bottom"); + pick(ui, A::AlignCenterV, "Center vertically"); + pick(ui, A::AlignCenterH, "Center horizontally"); + }); + ui.menu_button(tl!("Distribute"), |ui| { + pick(ui, A::DistributeH, "Horizontally"); + pick(ui, A::DistributeV, "Vertically"); + }); + ui.menu_button(tl!("Set Fields to Same Size"), |ui| { + pick(ui, A::SameHeight, "Height"); + pick(ui, A::SameWidth, "Width"); + pick(ui, A::SameSize, "Both"); + }); + ui.separator(); + } + if ui.button(tl!("Properties…")).clicked() { + field_menu = Some(FieldMenu::Properties); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { + field_menu = Some(FieldMenu::Duplicate(name.clone())); + ui.close(); + } + ui.separator(); + let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; + if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { + view.pending_edit = crate::prepare::delete_selected(view); + ui.close(); + } + return; + } + if let Some((page, index)) = view.links.context { + if ui.button(tl!("Properties…")).clicked() { + view.links.open_existing = Some((page, index)); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { + view.pending_edit = Some(Edit::DeleteLink { page, index }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { + view.pending_edit = Some(Edit::RemoveLinks { pages: None }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + return; + } + let can_paste_image = env.can_paste_image; + let is_image_menu = tool == QuickTool::EditText + && (ui.ctx().data_mut(|d| d.get_persisted::(crate::edit_text_ui::image_menu_id())).unwrap_or(false) + || view.image_selection.as_ref().is_some_and(|sel| { + let pointer_pos = ptr.pos; + let Some(p) = pointer_pos else { return false }; + let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page) else { return false }; + let images = doc.page_images(sel.page); + let Some(img) = images.get(sel.index) else { return false }; + let obb = ImageObb::from_image(img, xf, info, sel.page); + let b = image_screen_rect(img, xf, info, sel.page); + (b.is_positive() && b.contains(p)) || crate::edit_text_ui::quad_contains(obb.corners(), p) + })); + if is_image_menu && let Some(sel) = view.image_selection.clone() { + let images = doc.page_images(sel.page); + let at = view.comments.context_at.map(|(_, at)| at); + crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at); + return; + } + if view.comments.selected.is_some() { + canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); + return; + } + // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. + let mut copy_image = None; + if let Some((page, at)) = view.comments.context_at { + // The page's content is parsed once per right-click, not on every frame the menu is open. + let key = egui::Id::unique("copy-image-under-menu"); + let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); + let hit = match cached { + Some(c) => c.2, + None => { + let hit = doc.page_image_at(page, at); + ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); + hit + } + }; + if let Some(image) = hit { + if ui.button(tl!("Copy Image")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImage(page, image)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); + ui.close(); + } + ui.separator(); + } + } + let has_copy_image = copy_image.is_some(); + if copy_image.is_some() { + canvas_action = copy_image; + } + let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0])); + let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image); + let can_paste = can_paste_image && can_modify; + let perms = PageMenuPerms { + can_modify, + can_assemble: doc.allows_assembly(), + can_annotate: allowed, + can_print: doc.allows_printing(), + can_copy: can_copy_items, + can_paste, + has_annotations: !info.annotations.is_empty(), + }; + default_page_context_menu(ui, page, at, perms, &mut page_action); + }); + if !resp.context_menu_opened() { + ui.ctx().data_mut(|d| d.insert_persisted(crate::edit_text_ui::image_menu_id(), false)); + } + *out = PageOut { + canvas_action, + clicked_link, + copy_selected, + field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + page_action, + primary_down: ptr.primary_down, + refused, + stamp_placed, + }; + (wanted, visible_now) +} + +/// A zoom the fit mode changed keeps the page at the top of the view in place. +fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { + if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { + let prev_viewport = view.viewport_screen; + let top_y = prev_viewport.min.y; + let anchor = view + .screen_rects + .iter() + .find(|(_, r)| r.min.y <= top_y && r.max.y > top_y) + .map(|(p, r)| { + let fy = if r.height() > 0.0 { ((top_y - r.min.y) / r.height()).clamp(0.0, 1.0) } else { 0.0 }; + (*p, fy, 0.0f32) + }) + .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) + .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); + if let Some((page, fy, rel_y)) = anchor { + view.zoom_anchor = Some((page, 0.5, fy, vec2(avail_width / 2.0, rel_y))); + } else { + view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail_width / 2.0, MARGIN))); + } + } +} + +/// What the pager needs to know about the page row on screen. +#[derive(Clone, Copy)] +struct PagingCx<'a> { + row: &'a [usize], + rects: &'a [Rect], + avail: Rect, + middle_gesture: bool, + unobstructed: bool, +} + +/// Not scrolling: when the whole page or spread fits, the wheel turns pages instead. Returns whether +/// paging owns the wheel, and whether it turned the page at an edge. +fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> (bool, bool) { + let (mut single_paging, mut edge_turned) = (false, false); + let PagingCx { row, rects, avail, middle_gesture, unobstructed } = *cx; + if !view.continuous { + // The row's bounding box on screen (one page, or a two-page spread). + let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union); + // Within a point, so layout rounding can't stop a fitting page from turning. + let fits = row_rect.is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0); + let can_turn = !middle_gesture && unobstructed && ui.rect_contains_pointer(avail); + single_paging = can_turn && fits; + // Every wheel event goes to the pager, so it follows each trackpad touch to its end + // even while the page can't turn. + ui.input(|i| { + for e in &i.events { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { + // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. + let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; + if fits { + view.single_page_wheel(*unit, dy, *phase, i.time, can_turn); + } else { + edge_turned |= view.single_page_wheel_edge(*unit, dy, *phase, i.time, can_turn); + } + } + } + }); + if single_paging { + // Paging owns vertical wheel motion here. A diagonal gesture keeps its sideways + // part, as `ScrollArea` itself handles each axis. + ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); + } + } + (single_paging, edge_turned) +} + +/// This frame's wheel motion over the page view, after paging and point/line suppression. +struct Wheel { + precise_scroll: Vec2, + line_base: Vec2, + boost_extra: Vec2, + has_page: bool, + /// The pager owns the vertical axis (single-page view, or the rest of a gesture that turned the page). + paging_owns_vertical: bool, +} + +/// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, smoothed +/// base and adds only the accelerated surplus. While point suppression is active, line and page +/// input are reconstructed so current coarse events survive without exposing an older eased tail. +fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool) -> Wheel { + let now = ui.input(|i| i.time); + let input = if ui.rect_contains_pointer(avail) { + // Match the line distance used by egui's other scroll areas. + let live = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS }; + ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging)) + } else { + crate::wheel_boost::FrameInput::default() + }; + let mut precise_scroll = input.precise; + let mut line_base = input.line; + let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now); + let has_page = input.page != Vec2::ZERO; + if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page { + view.precise_scroll_until = now + SUPPRESS_SECS; + } + if edge_turned { + // The turn leaves egui's eased copy of the notch to decay for a few frames; it must not + // scroll the page just shown away from its top (or bottom). + view.precise_scroll_until = now + 0.25; + view.fling.stop(); + } + // Paging owns the vertical axis in single-page view; leave it to the pager. + // The rest of a gesture that turned the page is swallowed too, or it would pan the new page + // away from the edge it was shown at. + let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); + if paging_owns_vertical { + precise_scroll.y = 0.0; + line_base.y = 0.0; + boost_extra.y = 0.0; + } + // egui smooths a Point event that moved at least 8 points, keeping part of it for later + // passes. Suppression must outlive that whole action: once it expired after a fixed 0.25 s, + // the last few eased points leaked through the ScrollArea after a precise 1:1 scroll (held + // point input measured 33 points for a 30-point event). Keep hiding the eased copy until + // egui says the action is over, then forget the suppression. + let scrolling = ui.input(|i| i.is_scrolling()); + if now < view.precise_scroll_until || (view.precise_scroll_until != 0.0 && scrolling) { + if scrolling { + // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace + // avoids a delayed duplicate without prolonging suppression after the tail is gone. + view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); + } + ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + // The point path suppresses every eased wheel delta. Restore the line base through the + // same un-eased path as its surplus; paging-owned axes were cleared above. + boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base); + if has_page { + // Page units normally stay on egui's viewport-sized path. During suppression only, + // reproduce the current event directly so an older hidden tail stays hidden. + let page_scale = ui.input(|i| i.viewport_rect().height()); + let mut page_delta = + if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO }; + if paging_owns_vertical { + page_delta.y = 0.0; + } + boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta); + } + } else { + precise_scroll = Vec2::ZERO; + // Selection handling below uses zero to distinguish an inactive window. Clearing a past + // deadline also keeps a later line/page action from being mistaken for the old tail. + view.precise_scroll_until = 0.0; + } + if has_page && paging_owns_vertical { + // Page input belongs to egui on the free axis; the pager still owns vertical motion. + ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); + } + Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } +} + +/// What decides which renders a frame asks for. +#[derive(Clone, Copy)] +struct RenderPlan<'a> { + visible_pages: &'a [usize], + tag: u64, + scale: f32, + sharp_held: bool, + pan_held: bool, + hand: bool, +} + +/// Queues the renders for the pages and tiles `renders` wants (nearest the view first), prefetches the +/// rows either side, frees tiles of far pages and old zooms, and asks for the text layers. +fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: PageRenders, plan: &RenderPlan<'_>) { + let (mut wanted, visible_now) = renders; + let RenderPlan { visible_pages, tag, scale, sharp_held, pan_held, hand } = *plan; + // Single-page and two-page views draw only the current row, so the next one was first asked + // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, + // as continuous scrolling does for the pages just off screen. + if !view.continuous + && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) + { + let n = if view.layout == PageLayout::Single { 1 } else { 2 }; + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { + let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; + // As drawn: the whole page at this scale, or the backdrop of a tiled page. + let side = p.width.max(p.height).max(1.0); + wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { + let bs = BASE_SIDE.min(max_side) / side; + (i, bs, scale_tag(bs), None) + } else { + (i, scale, tag, None) + }); + } + } + let cur = view.current; + wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); + // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page + // that is on screen, whether or not the page is tiled at this zoom (#2196): a zoom out below + // the tiling threshold used to drop the tiles of the zoom just left, so coming back to it + // was a cold miss and the page re-blurred through the backdrop while every tile was + // interpreted again. `OLD_TILES` still bounds how many older tiles are kept, nearest scale + // first, and admission keeps them inside `TILE_BYTES`. + let missing: HashSet = wanted.iter().filter(|w| w.3.is_some()).map(|w| w.0).collect(); + let on_screen: HashSet = visible_now.iter().copied().collect(); + view.tiles.retain(|(p, t, _, _), _| p.abs_diff(cur) <= 2 && (*t == tag || missing.contains(p) || on_screen.contains(p))); + let mut older: Vec<(f32, (usize, u64, u32, u32))> = + view.tiles.keys().filter(|k| k.1 != tag).map(|k| ((tag_scale(k.1) / scale).ln().abs(), *k)).collect(); + // Tiles of earlier zooms are kept nearest scale first, and bounded by count *and* bytes: a + // large scan renders a tile up to 4096² (about 67 MiB), so a count alone let a few tiles of + // a few zooms run far past the tile allowance. #2196 keeps a zoom already visited, which + // makes that reachable in ordinary zooming, so the byte bound has to be here too. + older.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut kept = 0usize; + let mut kept_bytes = 0usize; + let mut drop: Vec<(usize, u64, u32, u32)> = Vec::new(); + for (_dist, key) in older { + let bytes = view.tiles.get(&key).map_or(0, texture_bytes); + if kept >= OLD_TILES || kept_bytes.saturating_add(bytes) > TILE_BYTES { + drop.push(key); + } else { + kept = kept.saturating_add(1); + kept_bytes = kept_bytes.saturating_add(bytes); + } + } + for key in drop { + view.tiles.remove(&key); + } + let mut queue: Vec = wanted + .iter() + .map(|&(page, wanted_scale, wanted_tag, tile)| RenderRequest { + page, + kind: RequestKind::Pixels, + tile, + scale: wanted_scale, + tag: wanted_tag, + // The low-res backdrop under tiles is a preview: tiny images are skipped for it. + // It is the whole-page request whose scale is not the current one; sharp tiles + // and whole-page finals always draw everything. + preview: tile.is_none() && (wanted_scale != scale || wanted_tag != tag), + }) + .collect(); + // While the zoom or pan is settling, hold back sharp re-renders of pages that already + // show something (a stale whole-page raster or older tiles, drawn stretched): each new + // scale would cancel the running full-page interpret and restart it from scratch, and + // each pan frame would queue a storm of full-page tile interprets. + // Pages with nothing on screen yet still queue, so panning to a fresh page draws it. + if sharp_held || pan_held { + let main_tag = tag; + queue.retain(|req| { + // Fast pages render straight through: the hold only protects heavy pages + // whose full-page interpret costs seconds. + if !view.slow_pages.contains(&req.page) { + return true; + } + if req.tile.is_some() { + let has_current_tile = view.tiles.keys().any(|(p, t, _, _)| *p == req.page && *t == req.tag); + if has_current_tile { + return false; + } + let has_any_surface = view.pages.contains_key(&req.page) || view.tiles.keys().any(|(p, _, _, _)| *p == req.page); + !has_any_surface + } else if req.tag != main_tag { + // The low-res backdrop under tiles is zoom-independent: let it through, + // it is the stretched surface the hold relies on. + true + } else { + // The stale raster stays stretched until the zoom or pan settles; a page with + // nothing on screen yet still renders. + !view.pages.contains_key(&req.page) + } + }); + } + // Text layers: visible pages for selection, every page while a search is active. (Select All + // of the document needs no more: shown pages highlight as their text comes, and Copy reads + // the rest, as text far away is let go to bound memory.) + let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); + let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; + if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { + let n = info.pages.len(); + let rest: Vec = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect(); + text_pages.extend(rest); + } + queue.extend(text_pages.into_iter().map(|page| RenderRequest { + page, + kind: RequestKind::Text, + tile: None, + scale: 1.0, + tag: TEXT_TAG, + preview: false, + })); + view.frame_queue = queue; + view.frame_visible = visible_now.iter().copied().collect(); +} + +/// Acts on the notice bar button that was clicked. +fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { + match notice { + #[cfg(not(target_arch = "wasm32"))] + Some(Notice::Reload) => app.watch.reload_request = app.views.get(index).map(|view| view.id), + #[cfg(target_arch = "wasm32")] + Some(Notice::Reload) => {} + Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), + Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), + Some(Notice::Unlock) => { + if let Some(doc) = app.views.get(index).and_then(|view| app.session.get(view.id)) { + app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)); + } + } + // Flags the view: the tab closes at the top of the next frame, where no document + // borrow is held. + Some(Notice::Close) => { + if let Some(view) = app.views.get_mut(index) { + view.close_requested = true; + } + } + Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), + Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, + Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, + None => {} + } +} + +/// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The +/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own +/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about). +fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { + let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); + let Some(i) = pending else { return false }; + app.views[i].close_requested = false; + if app.has_unsaved_work(i) { + app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + } else { + app.close_tab(i); + } + true +} + +/// Where the pages are laid out, for placing the scroll area. +#[derive(Clone, Copy)] +struct ScrollGeometry<'a> { + rects: &'a [Rect], + y_shift: f32, + content_w: f32, + content_h: f32, + avail: Rect, + middle_gesture: bool, + hand: bool, +} + +/// The page view's scroll area, with the offset this frame's jumps, drags and zoom anchor ask for. +fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, middle_gesture, hand } = *geometry; + let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { + drag: if middle_gesture { + egui::scroll_area::DragScroll::Never + } else if hand { + egui::scroll_area::DragScroll::Always + } else { + egui::scroll_area::DragScroll::OnTouch + }, + ..Default::default() + }); + // Each document keeps its own scroll position when switching tabs (#189). + scroll = scroll.id_salt(("page-view", view.id)); + // Every scroll here asks for no animation. An animated area would still queue a zero-length + // offset target for the next frame, which overwrites the jump a page turn just set. + scroll = scroll.animated(false); + // A newly opened document starts at the top. eframe saves egui's memory with the settings + // and document ids start again at 1 every launch, so the offset stored under this id can be + // where an earlier session left another document. + if !std::mem::replace(&mut view.shown, true) { + scroll = scroll.scroll_offset(Vec2::ZERO); + } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + if moved != Vec2::ZERO { + view.end_find_following(); + } + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } + if let Some(v) = view.sync_scroll.take() { + scroll = scroll.scroll_offset(v); + } + if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { + let r = rects[page.min(rects.len() - 1)]; + let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); + scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); + } else if let Some((page, frac)) = view.goto.take() { + let page = page.min(rects.len() - 1); + let r = rects[page]; + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. + let (x, w) = (view.last_scroll.x, avail.width()); + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } + } + scroll +} + +/// Notes how far the page view scrolled this frame: reader-owned scrolling ends search following, +/// a pan holds back fresh tile renders until it settles, and a glide keeps going or stops. Whether +/// a pan is still settling. +fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput, glide: Vec2) -> bool { + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } + let now_settle = ui.input(|i| i.time); + if (scroll_offset - view.last_scroll).length() > 2.0 { + view.pan_hold_until = now_settle + 0.25; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); + } + if glide != Vec2::ZERO { + view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); + } + view.last_scroll = scroll_offset; + now_settle < view.pan_hold_until +} + +/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the +/// text editor was finished. +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { + let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; + if let Some((pos, text)) = hover_text { + // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. + let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_max_width(320.0); + ui.label(text); + }); + }); + } + find_bar(view, info.pages.len(), avail, ui, t); + page_size_hint(view, info, units, avail, unobstructed, ui, t); + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); + } + if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { + view.pending_edit = Some(e); + } + if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { + view.pending_edit = Some(e); + } + let text_box = crate::forms_ui::text_box(doc, view, form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { + view.pending_edit = Some(e); + } + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { + view.pending_edit = Some(e); + } + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); + if typed.is_some() { + view.pending_edit = typed; + } + text_done +} + +/// Full screen: the click turns the page unless it did something else first. It waits out the +/// double-click time: turning on the first click's release sent a double-click's second click, +/// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a +/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { + let now = ui.input(|i| i.time); + if let Some(back) = click + && !link_followed + && view.selection.is_none() + && view.clicks < 2 + { + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + view.present_turn = Some((now + delay, !back)); + } + if let Some((due, forward)) = view.present_turn { + if link_followed || view.selection.is_some() || view.presentation.is_none() { + view.present_turn = None; + } else if now >= due { + view.present_turn = None; + view.step_page(forward); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); + } + } +} + +/// Follows the link that was clicked. A link to another file comes back, to be opened once the +/// document is no longer borrowed. +fn follow_link( + app: &mut PdfCraftApp, + index: usize, + target: Option, +) -> Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> { + let view_id = app.views[index].id; + match target? { + LinkTarget::Page(p, dest) => { + let view = &mut app.views[index]; + if let Some(doc) = app.session.get(view.id) { + view.go_to_dest(p, dest, &doc.info); + } + } + LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link), + LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb), + LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)), + LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), + } + None +} + +/// A finished marquee zoom, a signature rectangle drawn, or an empty signature field clicked. +/// Returns the tool to go on with. +fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool) -> QuickTool { + if let Some(done) = app.views[index].marquee_done.take() { + app.finish_marquee(index, done); + } + // A signature rectangle was drawn, or an empty signature field clicked. + let drawn = app.views[index].sign.drawn.take(); + if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { + tool = QuickTool::Select; + app.quick_tool = tool; + app.start_signing(page, Some(rect), None, certify.then_some(2)); + } + if let Some(field) = app.views[index].sign.field.take() { + let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); + if signed { + app.right = Some(RightPanel::Signatures); + if !app.sig_expanded.contains(&field) { + app.sig_expanded.push(field); + } + } else { + let page = app.views[index].current; + app.start_signing(page, None, Some(field), None); + } + } + tool +} + +/// When not scrolling continuously, only one row is shown at a time: the current page, or the +/// current two-page spread (with the cover rule). These indices drive paging, which pages are +/// drawn, and the content height (#752). Empty for a document with no pages. +fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { + if rects.is_empty() { + return Vec::new(); + } + let last = rects.len() - 1; + let cur = view.current.min(last); + match view.layout { + PageLayout::Single => vec![cur], + PageLayout::TwoUp if view.cover && cur == 0 => vec![0], + PageLayout::TwoUp => { + let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; + if first < last { vec![first, first + 1] } else { vec![first] } + } + } +} + +/// Full screen: a click on the document goes forward a page and a shift-click back, as in +/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages +/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { + let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + // Shift as held now, or as the click's own event carried it (a synthetic click). + clicked.then(|| { + ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| { + matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) + }) + }) + }) +} + +/// Pinch / ctrl+scroll zoom, anchored on the pointer or the current page. +fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rect: Rect, enabled: bool) { + if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && enabled && ui.rect_contains_pointer(navigation_rect) { + let z = view.zoom * zoom_delta; + match zoom_anchor(ui) { + Some(p) => view.zoom_at(z, p), + None => view.set_zoom(z), + } + } +} + +/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the +/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point +/// beyond what is shown can be reached without letting go of the tool. +fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { + let auto_delta = if unobstructed { + view.auto_scroll.update(ui, avail, false) + } else { + view.auto_scroll.cancel(); + Vec2::ZERO + }; + + // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view + // that way, so a point beyond what is shown can be reached without letting go of the tool. + let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { + let (pointer, dt) = drawing_edge_pointer(ui); + crate::autoscroll::edge_scroll(pointer, avail, dt) + } else { + Vec2::ZERO + }; + if edge_delta != Vec2::ZERO { + ui.ctx().request_repaint(); + } + (auto_delta, edge_delta) +} + +/// What decides whether touchpad momentum goes on. +#[derive(Clone, Copy)] +struct GlideCx { + now: f64, + may_glide: bool, + key_scroll: f32, + auto_delta: Vec2, + zoom_delta: f32, + edge_delta: Vec2, + wheel_scrolled: bool, +} + +/// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a stop, +/// through the same un-eased path. Never while the pager owns the wheel, a gesture or dialog has +/// the input, or animations are off; any click, key, zoom or other scrolling ends it. Any +/// reader-owned motion ends automatic search following, even when it stays on the same page; the +/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for +/// a reader action. Returns this frame's glide. +fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { + let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let over_page = ui.rect_contains_pointer(avail); + ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + let interrupted = glide_interrupted(ui); + if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + view.fling.stop(); + } + let glide = view.fling.step(now); + if view.fling.gliding() { + ui.ctx().request_repaint(); + } + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } + glide +} + +/// Text selection runs for the Select tool and for the markup tools (highlight…). +fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { + match tool { + QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, + QuickTool::Select => !preparing, + QuickTool::Redact | QuickTool::ColumnSelect => true, + QuickTool::Hand + | QuickTool::Measure(_) + | QuickTool::Crop + | QuickTool::EnhanceCamera + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::AddText + | QuickTool::EditText + | QuickTool::Stamp(_) + | QuickTool::CustomStamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot => false, + } +} + +/// Holds sharp re-renders while the zoom is still moving: a new scale every frame would cancel the +/// in-flight full-page interpret and restart it, so on a heavy page (a CAD plot with ~1000 images +/// renders for seconds) the sharp raster never arrives and the view looks stuck on a soft image. +/// The stale raster stays stretched meanwhile; the sharp request goes out once the zoom settles. +fn hold_sharp_renders(ui: &egui::Ui, view: &mut DocView, scale: f32) -> bool { + let now_scale = ui.input(|i| i.time); + if (scale - view.last_scale).abs() > f32::EPSILON { + view.last_scale = scale; + view.sharp_hold_until = now_scale + 0.35; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); + } + now_scale < view.sharp_hold_until +} + +/// A destination's position (bookmarks, links) refines `goto`: its point at the window's top-left, +/// or the centre of a /FitR rectangle at the window's centre. +fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, .. } = *geometry; + if let Some((page, fx, fy, rel)) = view.goto_point.take() { + let r = rects[page.min(rects.len() - 1)]; + // Within the content, so an unreachable point never shows a frame scrolled past the end. + let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + if let Some(fx) = fx { + scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); + } + if let Some(fy) = fy { + scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); + } + } + scroll +} + +/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a +/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. +pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let can_paste_key = egui::Id::unique("can-paste-image"); + let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) { + let can = app.can_paste_image(); + ui.data_mut(|d| d.insert_temp(can_paste_key, can)); + can + } else { + ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false) + }; + // Keyboard scrolling applies to this frame's page view only, never later. + let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } + // The Search panel closed: its search moves to the find bar. + let search_open = app.right == Some(crate::RightPanel::Search); + if let Some(f) = app.views[index].find.as_mut() + && f.in_panel + && !search_open + { + f.in_panel = false; + } + if close_requested_tab(app, index) { + return; + } + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { + let [r, g, b] = p.background; + Color32::from_rgb(r, g, b) + }); + if info.pages.is_empty() { + app.views[index].auto_scroll.cancel(); + ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); + return; + } + // The print dialog queues its own rasters later in the frame. Closing it drops them. + if app.dialog != Some(crate::Dialog::Print) { + app.views[index].print_pages.clear(); + } + // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. + let preparing = app.is_preparing(); + // Prepare a form ▸ Calculation order: the order's steps and links on the page. + let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; + // Edit a PDF: added text and images can be selected, moved and edited. + let editing_content = + (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); + let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; + let view = &mut app.views[index]; + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + let repaired = !doc.repair_log().is_empty(); + let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); + apply_notice(app, index, notice); + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + // No dialog, close prompt or palette over the page: only then does page input count. + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; + let organize_zoom = app.organize_zoom; + let view = &mut app.views[index]; + if view.organize { + // One remembered grid size for every document, kept in the settings. + view.set_grid_zoom(organize_zoom); + organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + app.organize_zoom = view.grid_zoom(); + return; + } + + let avail = ui.available_rect_before_wrap(); + view.viewport_w = avail.width(); + view.viewport_h = avail.height(); + let old_zoom = view.zoom; + view.fit_zoom(info); + anchor_zoom_change(view, avail.width(), old_zoom); + + // Pinch / ctrl+scroll zoom, anchored on the current page. + let zoom_delta = crate::motion::zoom_delta(ui.ctx()); + // Exclude the previous frame's scrollbar tracks from gesture activation. + let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; + let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); + pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); + view.viewport_screen = avail; + let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); + + let content_w = view.content_width(info, avail.width()); + let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); + // When not scrolling continuously, only one row is shown at a time: the current page, or the + // current two-page spread (with the cover rule). These indices drive paging, which pages are + // drawn, and the content height (#752). Empty for a document with no pages. + let row = shown_row(view, &rects); + let middle_gesture = view.auto_scroll.blocks_input(); + // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns + // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on + // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with + // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow + // keys. Runs before `visible_pages` so the frame that turns the page draws it. + let (single_paging, edge_turned) = single_page_paging(ui, view, &PagingCx { row: &row, rects: &rects, avail, middle_gesture, unobstructed }); + // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, + // smoothed base and adds only the accelerated surplus. While point suppression is active, + // reconstruct line and page input here so current coarse events survive without exposing an + // older eased tail. + let now = ui.input(|i| i.time); + let Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } = read_wheel(ui, view, avail, single_paging, edge_turned); + let animates = app.animation_effects.animates(app.motion.reduced); + let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let glide = step_glide(ui, view, avail, &motion); + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view, &rects); + let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; + let (y_shift, content_h) = if view.continuous { + (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) + } else { + // Only the current row scrolls into view; the content is its bounding box plus margins. + match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { + Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), + None => (0.0, 0.0), + } + }; + + let mut scroll = page_scroll_area( + ui, + view, + &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand: app.quick_tool == QuickTool::Hand }, + ); + let ppp = ui.ctx().pixels_per_point(); + let scale = view.render_scale(ppp); + let tag = scale_tag(scale); + // Hold sharp re-renders while the zoom is still moving: a new scale every frame would + // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD + // plot with ~1000 images renders for seconds) the sharp raster never arrives and the + // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the + // sharp request goes out once the zoom settles. + let sharp_held = hold_sharp_renders(ui, view, scale); + view.current_scale_tag = tag; + let hand = app.quick_tool == QuickTool::Hand; + let tool = app.quick_tool; + // Text selection runs for the Select tool and for the markup tools (highlight…). + let selects_text = tool_selects_text(tool, preparing); + let prefs = &app.comment_prefs; + let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); + let units = app.units; + let allowed = doc.allows_annotation(); + let can_copy = doc.allows_copying(); + let comments_hidden = doc.comments_hidden(); + let form = doc.form.clone(); + let generation = doc.edit_generation(); + let can_fill = doc.allows_form_filling(); + let can_crop = doc.allows_assembly(); + let signature = app.signature.clone(); + let initials = app.initials.clone(); + let author = app.comment_prefs.author.clone(); + let today = app.session.today(); + // In Preferences ▸ Date format, or why it can't be stamped into the PDF. + let date_text = crate::date_text_for_pdf(&app.session); + let by_line = app.session.stamp_by_line(&author); + let custom_stamp = match app.quick_tool { + QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), + _ => None, + }; + let mut open_props: Option<(usize, usize)> = None; + // The table being set up in its window, shown on its own document's page. + let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); + let can_modify = doc.allows_modification(); + if preparing { + crate::prepare::after_refresh(view, &form); + } else { + view.prepare.selected = None; + } + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); + let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; + let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; + if editing_content { + crate::content_ui::after_refresh(view, &added); + } else { + view.content.selected = None; + } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); + + scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); + let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let env = PageEnv { + added: &added, + allowed, + author: &author, + auto_delta, + avail, + boost_extra, + by_line: &by_line, + calc_order, + can_copy, + can_crop, + can_fill, + can_modify, + comments_hidden, + content_h, + content_w, + custom_stamp: &custom_stamp, + date_text: &date_text, + doc, + doc_links: &doc_links, + edge_delta, + editing_content, + form: &form, + generation, + glide, + hand, + info, + initials: &initials, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + paper, + ppp, + precise_scroll, + prefs, + preparing, + rects: &rects, + redact_mark, + scale, + selection_wheel, + selects_text, + signature: &signature, + single_paging, + t: &t, + table_preview: &table_preview, + tag, + text_style: &text_style, + tool, + units, + unobstructed, + visible_pages: &visible_pages, + y_shift, + fill_text_size: app.fill_text_size, + fill_text_color: app.fill_text_color, + today, + can_paste_image, + }; + let mut page_out = PageOut::default(); + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { + draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) + }); + let PageOut { + canvas_action, + clicked_link, + mut copy_selected, + field_menu, + field_placed, + mut field_props, + hover_text, + mut image_action, + open_boxes, + open_initials, + open_signature, + open_table, + page_action, + primary_down, + mut refused, + stamp_placed, + } = page_out; + view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { + // At the top once the page's top edge shows where going to a page puts it (a gap + // below the window's top). Within a point, as for wheel paging, so layout rounding + // can't hide an edge. + let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); + (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) + }); + + view.viewport_screen = out.inner_rect; + view.auto_scroll.paint(ui, avail); + if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { + copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); + } + + // Collect all main-page demand, including cached surfaces, for byte admission later. + // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding + // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched + // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that + // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; + // pages with nothing on screen yet still queue. + let pan_held = track_scroll(ui, view, &out, glide); + plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); + + let text_done = frame_overlays(ui, view, &env, primary_down, hover_text); + if let Some(why) = view.objects.notice.take() { + refused = Some(why); + } + let form_notice = view.forms.notice.take(); + // One crop, then back to selecting (as Acrobat does). + let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. })); + let mut tool = app.quick_tool; + comments::keys(ui.ctx(), view, &mut tool, allowed); + if preparing { + crate::prepare::keys(ui.ctx(), view); + } + if editing_content { + crate::content_ui::keys(ui.ctx(), view); + } + if tool == QuickTool::Link { + crate::link_ui::keys(ui.ctx(), view); + } else { + view.links.selected = None; + } + if tool != QuickTool::EditText { + view.image_selection = None; + } + if stamp_placed { + tool = QuickTool::Select; + } + // Text: each click on the page starts a box (#74); Done in the editor goes back to selecting. + if text_done && tool == QuickTool::AddText { + tool = QuickTool::Select; + } + // One field, then back to selecting (Acrobat's default without "Keep tools pinned"). + if field_placed { + tool = QuickTool::Select; + } + if matches!(field_menu, Some(FieldMenu::Properties)) { + field_props = true; + } + let field_props = field_props.then(|| view.prepare.selected.clone()).flatten(); + match canvas_action { + Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e), + // `choose_right_panel` would borrow all of `app` while `view` is held; same effect. + Some(comments::CanvasAction::OpenComments) => { + app.right = Some(RightPanel::Comments); + app.comments_panel_closed = false; + } + Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), + Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)), + Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), + None => {} + } + schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + let remote_open = follow_link(app, index, clicked_link); + if tool == QuickTool::Crop && cropped { + tool = QuickTool::Select; + } + let tool = finish_sign_requests(app, index, tool); + app.quick_tool = tool; + let requests = FrameRequests { + open_props, + field_props, + field_menu, + image_action, + page_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + }; + apply_frame_requests(app, index, ui, requests); +} + +/// What the frame's input asked the app to do, once the document is no longer borrowed. +struct FrameRequests { + open_props: Option<(usize, usize)>, + field_props: Option<(String, usize)>, + field_menu: Option, + image_action: Option, + page_action: Option, + refused: Option, + open_signature: bool, + open_initials: bool, + open_boxes: bool, + open_table: Option<(usize, [f64; 4])>, + form_notice: Option, + copy_selected: bool, + remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, + form: Arc>, + generation: u64, + avail: Rect, +} + +/// Opens the dialogs and runs the app commands the frame asked for. #[allow(clippy::cognitive_complexity)] -fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) { - let FrameRequests { - open_props, - field_props, - field_menu, - image_action, - page_action, - refused, - open_signature, - open_initials, - open_boxes, - open_table, - form_notice, - copy_selected, - remote_open, - form, - generation, - avail, - } = requests; - if let Some((page, at)) = app.views[index].comments.attach_at.take() { - app.attach_file_comment(page, at); - } - if let Some((p, i)) = open_props { - app.open_comment_props(p, i); - } - if let Some((p, i)) = app.views[index].comments.default_request.take() { - app.make_comment_default(p, i); - } - if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { - let doc = app.views[index].id; - app.comment_attachment_action(doc, p, i, open); - } - if let Some((name, w)) = field_props { - app.open_field_props(&name, w); - } - if let Some(FieldMenu::Duplicate(name)) = field_menu { - let pages = app.session.get(app.views[index].id).map_or(1, |d| d.info.pages.len()); - app.duplicate_draft = Some(crate::DuplicateDraft { name, all: true, from: 1, to: pages }); - app.dialog = Some(crate::Dialog::DuplicateField); - } - if let Some((page, rect)) = app.views[index].links.open_new.take() { - app.open_link_props(page, Some(rect), None); - } - if let Some((page, i)) = app.views[index].links.open_existing.take() { - app.open_link_props(page, None, Some(i)); - } - if let Some((page, quads)) = app.views[index].pending_redaction.take() { - let author = app.comment_prefs.author.clone(); - app.views[index].pending_edit = Some(if app.quick_tool == QuickTool::Comment(comments::CommentTool::Highlight) { - // An area highlight: a highlight over the box. - let style = app.comment_prefs.style(comments::CommentTool::Highlight); - pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page, - shape: pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, quads }, - style, - contents: String::new(), - author, - }) - } else { - app.redact_prefs.mark(page, quads, &author) - }); - } - match image_action { - Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), - Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => { - app.copy_page_image(page, index); - } - Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), - Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => { - app.views[index].image_selection = None; - app.cut_page_image(page, image); - } - Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => { - if let Some(pt) = at { - app.paste_image_at(page, pt); - } else { - app.paste_image(page, None); - } - } - Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { - app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); - } - Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page), - Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => { - if let Some(d) = app.session.get(app.views[index].id) { - let images = d.page_images(page); - if let Some(img) = images.get(i) { - let r = img.visible_rect.unwrap_or(img.rect); - let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]; - app.views[index].comments.context_at = Some((page, center)); - app.views[index].comments.composer = Some(crate::comments::Composer { - page, - at: center, - kind: crate::comments::ComposerKind::Note, - text: String::new(), - focus: true, - }); - } - } - } - Some(crate::edit_text_ui::ImageAction::Crop) => { - let doc_id = app.views[index].id; - if let Some(s) = app.views[index].image_selection.as_mut() - && let Some(d) = app.session.get(doc_id) - { - let images = d.page_images(s.page); - if let Some(im) = images.get(s.index) { - s.crop = Some(crate::edit_text_ui::CropMode::new(im.visible_rect.unwrap_or(im.rect))); - s.crop_mode = true; - s.crop_rect = None; - } - } - } - None => {} - } - match page_action { - Some(PageMenuAction::Copy) => { - if let Some(sel) = app.views[index].image_selection.clone() { - app.copy_page_image(sel.page, sel.index); - } else if app.views[index].selected_text().is_some() { - app.execute("edit.copy"); - } else if let Some((page, at)) = app.views[index].comments.context_at - && let Some(doc) = app.session.get(app.views[index].id) - && let Some(image) = doc.page_image_at(page, at) - { - app.copy_page_image(page, image); - } - } - Some(PageMenuAction::Paste(page, at)) => { - app.paste_image_at(page, at); - } - Some(PageMenuAction::RotateCCW(page)) => { - let _ = page; - app.views[index].rotate_view(false); - } - Some(PageMenuAction::RotateCW(page)) => { - let _ = page; - app.views[index].rotate_view(true); - } - Some(PageMenuAction::Print) => { - app.open_print(); - } - Some(PageMenuAction::SelectTool) => { - app.quick_tool = QuickTool::Select; - } - Some(PageMenuAction::HandTool) => { - app.quick_tool = QuickTool::Hand; - } - Some(PageMenuAction::MarqueeZoom) => { - app.quick_tool = QuickTool::MarqueeZoom; - } - Some(PageMenuAction::AddComment(page, at)) => { - app.views[index].comments.composer = - Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true }); - } - Some(PageMenuAction::FlattenComments) => { - app.views[index].comments.selected = None; - app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false }); - } - Some(PageMenuAction::AddBookmark(_page)) => { - app.bookmark_action(crate::panels::BmAction::New); - } - Some(PageMenuAction::TakeSnapshot) => { - app.quick_tool = QuickTool::Snapshot; - } - Some(PageMenuAction::EditPdf) => { - app.quick_tool = QuickTool::EditText; - } - Some(PageMenuAction::AddText) => { - app.quick_tool = QuickTool::AddText; - } - Some(PageMenuAction::AddImage) => { - app.add_image_dialog(); - } - Some(PageMenuAction::DeletePage(page)) => { - app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] }); - } - Some(PageMenuAction::ExportPdf) => { - app.select_tool("export"); - } - Some(PageMenuAction::Find) => { - app.views[index].open_find(); - } - Some(PageMenuAction::DocProperties) => { - app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description)); - } - None => {} - } - if let Some(why) = refused { - app.notify_error(why); - } - if open_signature || open_initials { - app.signature_draft = crate::fill_sign::SigDraft::new(open_initials, &app.comment_prefs.author); - app.dialog = Some(crate::Dialog::Signature); - } - if open_boxes { - app.boxes_draft.range = crate::pageboxes::Range::Current; - app.boxes_draft.seeded = None; - app.dialog = Some(crate::Dialog::PageBoxes); - } - if let Some((page, rect)) = open_table { - app.field_table_draft = Some(crate::field_table_ui::FieldTableDraft::new(app.views[index].id, page, rect, &form, generation)); - app.dialog = Some(crate::Dialog::FieldTable); - // The window lasts while the form is prepared. Drawn with the tool picked but its panel - // closed, the tool goes back to Select above, so the panel opens to keep it prepared. - app.left = crate::LeftPanel::Tool("form"); - app.reveal_tools(); - } - if let Some(n) = form_notice { - match n { - crate::forms_ui::FormNotice::Security => app.notify_tr("The document's security settings don't allow filling in form fields"), - crate::forms_ui::FormNotice::ReadOnly(name) => app.notify_fmt("{name} is read-only", &[("name", &name)]), - crate::forms_ui::FormNotice::NoAction(name) => app.notify_fmt("{name} has no action", &[("name", &name)]), - } - } - if let Some((name, action)) = app.views[index].forms.button.take() { - run_button(app, index, &name, action); - } - if copy_selected { - app.execute("edit.copy"); - } - // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); - // the quick tools come back with the window. - if app.mode != crate::Mode::Read && !app.full_screen { - quick_bar(app, avail, ui); - } - if let Some((source, file, destination)) = remote_open { - app.open_remote_link(source, file, destination); - } -} - -/// The prepare-mode field menu's choices. -enum FieldMenu { - Properties, - Duplicate(String), -} - -pub use crate::image_engine::{HandleId, MIN_RECT_SIZE, apply_crop, apply_resize, apply_resize_proportional, reset_crop}; -#[cfg(test)] -mod tests; +fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) { + let FrameRequests { + open_props, + field_props, + field_menu, + image_action, + page_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + } = requests; + if let Some((page, at)) = app.views[index].comments.attach_at.take() { + app.attach_file_comment(page, at); + } + if let Some((p, i)) = open_props { + app.open_comment_props(p, i); + } + if let Some((p, i)) = app.views[index].comments.default_request.take() { + app.make_comment_default(p, i); + } + if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { + let doc = app.views[index].id; + app.comment_attachment_action(doc, p, i, open); + } + if let Some((name, w)) = field_props { + app.open_field_props(&name, w); + } + if let Some(FieldMenu::Duplicate(name)) = field_menu { + let pages = app.session.get(app.views[index].id).map_or(1, |d| d.info.pages.len()); + app.duplicate_draft = Some(crate::DuplicateDraft { name, all: true, from: 1, to: pages }); + app.dialog = Some(crate::Dialog::DuplicateField); + } + if let Some((page, rect)) = app.views[index].links.open_new.take() { + app.open_link_props(page, Some(rect), None); + } + if let Some((page, i)) = app.views[index].links.open_existing.take() { + app.open_link_props(page, None, Some(i)); + } + if let Some((page, quads)) = app.views[index].pending_redaction.take() { + let author = app.comment_prefs.author.clone(); + app.views[index].pending_edit = Some(if app.quick_tool == QuickTool::Comment(comments::CommentTool::Highlight) { + // An area highlight: a highlight over the box. + let style = app.comment_prefs.style(comments::CommentTool::Highlight); + pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page, + shape: pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, quads }, + style, + contents: String::new(), + author, + }) + } else { + app.redact_prefs.mark(page, quads, &author) + }); + } + match image_action { + Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), + Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => { + app.copy_page_image(page, index); + } + Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), + Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => { + app.views[index].image_selection = None; + app.cut_page_image(page, image); + } + Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => { + if let Some(pt) = at { + app.paste_image_at(page, pt); + } else { + app.paste_image(page, None); + } + } + Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { + app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); + } + Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page), + Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]; + app.views[index].comments.context_at = Some((page, center)); + app.views[index].comments.composer = Some(crate::comments::Composer { + page, + at: center, + kind: crate::comments::ComposerKind::Note, + text: String::new(), + focus: true, + }); + } + } + } + Some(crate::edit_text_ui::ImageAction::Crop) => { + let doc_id = app.views[index].id; + if let Some(s) = app.views[index].image_selection.as_mut() + && let Some(d) = app.session.get(doc_id) + { + let images = d.page_images(s.page); + if let Some(im) = images.get(s.index) { + s.crop = Some(crate::edit_text_ui::CropMode::new(im.visible_rect.unwrap_or(im.rect))); + s.crop_mode = true; + s.crop_rect = None; + } + } + } + None => {} + } + match page_action { + Some(PageMenuAction::Copy) => { + if let Some(sel) = app.views[index].image_selection.clone() { + app.copy_page_image(sel.page, sel.index); + } else if app.views[index].selected_text().is_some() { + app.execute("edit.copy"); + } else if let Some((page, at)) = app.views[index].comments.context_at + && let Some(doc) = app.session.get(app.views[index].id) + && let Some(image) = doc.page_image_at(page, at) + { + app.copy_page_image(page, image); + } + } + Some(PageMenuAction::Paste(page, at)) => { + app.paste_image_at(page, at); + } + Some(PageMenuAction::RotateCCW(page)) => { + let _ = page; + app.views[index].rotate_view(false); + } + Some(PageMenuAction::RotateCW(page)) => { + let _ = page; + app.views[index].rotate_view(true); + } + Some(PageMenuAction::Print) => { + app.open_print(); + } + Some(PageMenuAction::SelectTool) => { + app.quick_tool = QuickTool::Select; + } + Some(PageMenuAction::HandTool) => { + app.quick_tool = QuickTool::Hand; + } + Some(PageMenuAction::MarqueeZoom) => { + app.quick_tool = QuickTool::MarqueeZoom; + } + Some(PageMenuAction::AddComment(page, at)) => { + app.views[index].comments.composer = + Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true }); + } + Some(PageMenuAction::FlattenComments) => { + app.views[index].comments.selected = None; + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false }); + } + Some(PageMenuAction::AddBookmark(_page)) => { + app.bookmark_action(crate::panels::BmAction::New); + } + Some(PageMenuAction::TakeSnapshot) => { + app.quick_tool = QuickTool::Snapshot; + } + Some(PageMenuAction::EditPdf) => { + app.quick_tool = QuickTool::EditText; + } + Some(PageMenuAction::AddText) => { + app.quick_tool = QuickTool::AddText; + } + Some(PageMenuAction::AddImage) => { + app.add_image_dialog(); + } + Some(PageMenuAction::DeletePage(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] }); + } + Some(PageMenuAction::ExportPdf) => { + app.select_tool("export"); + } + Some(PageMenuAction::Find) => { + app.views[index].open_find(); + } + Some(PageMenuAction::DocProperties) => { + app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description)); + } + None => {} + } + if let Some(why) = refused { + app.notify_error(why); + } + if open_signature || open_initials { + app.signature_draft = crate::fill_sign::SigDraft::new(open_initials, &app.comment_prefs.author); + app.dialog = Some(crate::Dialog::Signature); + } + if open_boxes { + app.boxes_draft.range = crate::pageboxes::Range::Current; + app.boxes_draft.seeded = None; + app.dialog = Some(crate::Dialog::PageBoxes); + } + if let Some((page, rect)) = open_table { + app.field_table_draft = Some(crate::field_table_ui::FieldTableDraft::new(app.views[index].id, page, rect, &form, generation)); + app.dialog = Some(crate::Dialog::FieldTable); + // The window lasts while the form is prepared. Drawn with the tool picked but its panel + // closed, the tool goes back to Select above, so the panel opens to keep it prepared. + app.left = crate::LeftPanel::Tool("form"); + app.reveal_tools(); + } + if let Some(n) = form_notice { + match n { + crate::forms_ui::FormNotice::Security => app.notify_tr("The document's security settings don't allow filling in form fields"), + crate::forms_ui::FormNotice::ReadOnly(name) => app.notify_fmt("{name} is read-only", &[("name", &name)]), + crate::forms_ui::FormNotice::NoAction(name) => app.notify_fmt("{name} has no action", &[("name", &name)]), + } + } + if let Some((name, action)) = app.views[index].forms.button.take() { + run_button(app, index, &name, action); + } + if copy_selected { + app.execute("edit.copy"); + } + // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); + // the quick tools come back with the window. + if app.mode != crate::Mode::Read && !app.full_screen { + quick_bar(app, avail, ui); + } + if let Some((source, file, destination)) = remote_open { + app.open_remote_link(source, file, destination); + } +} + +/// The prepare-mode field menu's choices. +enum FieldMenu { + Properties, + Duplicate(String), +} + +pub use crate::image_engine::{HandleId, MIN_RECT_SIZE, apply_crop, apply_resize, apply_resize_proportional, reset_crop}; +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc6f0d4928..6786ac6b0c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1576,11 +1576,8 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) ui.horizontal(|ui| { let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted); - let search_resp = ui.add( - egui::TextEdit::singleline(&mut app.pin_tool_search) - .hint_text(tl!("Search tools…")) - .desired_width(ui.available_width() - 32.0), - ); + let search_resp = + ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0)); search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…"))); if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() { app.pin_tool_search.clear(); @@ -1595,11 +1592,7 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) let mut toggle = None; for g in pdfcraft_engine::catalog::TOOL_GROUPS { let label = tl!(g.label); - let blurb = g - .sections - .first() - .map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::>().join(" • ")) - .unwrap_or_default(); + let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::>().join(" • ")).unwrap_or_default(); if !query.is_empty() && !label.to_lowercase().contains(&query) && !blurb.to_lowercase().contains(&query) @@ -1616,17 +1609,10 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) let body = rect.translate(press.offset()); let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]); - crate::icons::paint( - ui, - egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), - g.icon, - 20.0, - color, - ); + crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color); let btn_w = 78.0; - let btn_rect = - egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0)); + let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0)); let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click()); let btn_hovered = btn_resp.hovered(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22a31064f5..a29d2c87b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -549,7 +549,8 @@ pub struct PdfCraftApp { pub clipboard_override: Option, /// V is down as a plain key press (see [`Self::paste_key_released`]). v_pressed: bool, - last_command_time: Option, + frame_count: u64, + last_command_frame: Option<(u64, f64)>, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 3c09e4ee7e..dafbe238a1 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -272,7 +272,14 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let mixed_size = fmt.size.is_none(); // A selection aligns the rows it touches; the caret alone, the whole paragraph. let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false }; - let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown, mixed_size }, Some(paragraphs)); + let out = crate::format_text_ui::panel( + ui, + t, + &mut ed.look, + Some(&mut ed.extras), + Fonts { sources: Some(&sources), shown, mixed_size }, + Some(paragraphs), + ); let mut changed = out.changed; match out.font { Some(FontChoice::KeepSource) => { diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 5dfb0fee43..cfce824941 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -445,11 +445,7 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) { ui.spacing_mut().item_spacing.x = 2.0; let id = ui.make_persistent_id("font-size-input"); let initial_text = || { - if mixed || *size <= 0.0 { - tl!("Mixed").to_string() - } else { - size.to_string() - } + if mixed || *size <= 0.0 { tl!("Mixed").to_string() } else { size.to_string() } }; let (previous, mut text) = ui.data(|d| d.get_temp::<(u64, String)>(id)).unwrap_or_else(|| (size.to_bits(), initial_text())); if previous != size.to_bits() { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index f3d93f6851..3fdfab1b53 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -1035,23 +1035,23 @@ impl crate::PdfCraftApp { /// release of V with no press of V since the last one is the shortcut, even when ⌘ or Ctrl /// was let go first. Read every frame, dialogs or not, so a press is never missed. pub(crate) fn paste_key_released(&mut self, ctx: &egui::Context) -> bool { + self.frame_count = self.frame_count.wrapping_add(1); let mut released = false; ctx.input(|inp| { if inp.modifiers.command { - self.last_command_time = Some(inp.time); + self.last_command_frame = Some((self.frame_count, inp.time)); } for e in &inp.events { if let egui::Event::Key { key: egui::Key::V, pressed, modifiers, .. } = e { if modifiers.command { - self.last_command_time = Some(inp.time); + self.last_command_frame = Some((self.frame_count, inp.time)); } - let command_recent = self.last_command_time.is_some_and(|t| (inp.time - t).abs() < 0.35); + let command_recent = self.last_command_frame.is_some_and(|(f, t)| { + self.frame_count.saturating_sub(f) <= 10 || (inp.time > 0.0 && (inp.time - t).abs() < 0.35) + }); let command_active = modifiers.command || command_recent; - if !*pressed { - if !self.v_pressed && command_active { - released = true; - } - self.last_command_time = None; + if !*pressed && !self.v_pressed && command_active { + released = true; } self.v_pressed = *pressed; } @@ -1177,10 +1177,7 @@ impl crate::PdfCraftApp { .as_ref() .map(|(w, h, _)| (*w as f64, *h as f64)) .or_else(|| { - (bytes.len() <= 64_000_000 * 4) - .then(|| pdfcraft_engine::image_dimensions(&bytes)) - .flatten() - .map(|(w, h)| (w as f64, h as f64)) + (bytes.len() <= 64_000_000 * 4).then(|| pdfcraft_engine::image_dimensions(&bytes)).flatten().map(|(w, h)| (w as f64, h as f64)) }) .unwrap_or((100.0, 100.0)); let (pw, ph) = @@ -1496,46 +1493,4 @@ mod tests { assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too"); assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits"); } - - #[test] - fn ctrl_c_followed_by_plain_v_release_does_not_paste() { - let mut app = crate::PdfCraftApp::default(); - let ctx = egui::Context::default(); - - // 1. User presses Ctrl+C at time 0.0 - let mut out = ctx.run_ui( - egui::RawInput { - time: Some(0.0), - events: vec![egui::Event::Key { - key: egui::Key::C, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::COMMAND, - }], - ..Default::default() - }, - |_| {}, - ); - out.textures_delta.clear(); - assert!(!app.paste_key_released(&ctx)); - - // 2. Later at time 1.0, user releases V with Modifiers::NONE - let mut out = ctx.run_ui( - egui::RawInput { - time: Some(1.0), - events: vec![egui::Event::Key { - key: egui::Key::V, - physical_key: None, - pressed: false, - repeat: false, - modifiers: egui::Modifiers::NONE, - }], - ..Default::default() - }, - |_| {}, - ); - out.textures_delta.clear(); - assert!(!app.paste_key_released(&ctx), "Ctrl+C followed by plain V release must not trigger paste"); - } } diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 4f696dbd58..ee259fd2c3 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -1,1807 +1,1808 @@ -//! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page. -//! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the -//! box, Esc cancels); drag it to move it, or drag the handle on its left or right edge to rewrap -//! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping -//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. - -use std::collections::BTreeMap; -use std::sync::Arc; - -use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke, pos2}; -use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; -use pdfcraft_render::DocInfo; - -use crate::canvas::{DocView, PageToolContext, PageXform}; -use crate::page_cursor::Layer; -use crate::page_pointer::PagePointer; -use crate::page_router::{Hit, Owner, ParagraphPart}; - -mod background; -mod image; -mod layout; - -pub(crate) use background::TextBackground; -pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; -pub(crate) use image::{ - ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, - quad_contains, user_to_screen, -}; -#[cfg(test)] -use image::{rotate_knob, turn_degrees}; -#[cfg(test)] -use layout::{DARK_EDITOR_FILL, contrast}; -pub(crate) use layout::{caret_color_on, editor_fill, editor_font, install_source_fonts}; -use layout::{color32, editor_galley, editor_job, look_of, luminance, source_look, unseen}; - -const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); - -#[derive(Clone, Copy, PartialEq)] -enum TextTarget { - Block(usize), - Added(usize), -} - -/// How many faint Edit-text outlines are drawn per page per frame. CAD plots can draw -/// ~1000 tiny images on one page; stroking all of them every frame (plus a hover query -/// per box) stalls the UI. Past this, only the selected and hovered boxes are outlined: -/// hit-testing still covers every image, so nothing becomes unselectable. -pub(crate) const MAX_OUTLINES: usize = 250; - -pub fn is_bulleted(text: &str) -> bool { - let mut lines = text.lines(); - let Some(first) = lines.next() else { return false }; - first.trim_start().starts_with("• ") || first.trim_start().starts_with("- ") -} - -pub fn is_numbered(text: &str) -> bool { - let mut lines = text.lines(); - let Some(first) = lines.next() else { return false }; - let t = first.trim_start(); - let num_end = t.find('.').unwrap_or(0); - num_end > 0 && t[..num_end].chars().all(|c| c.is_ascii_digit()) && t[num_end..].starts_with(". ") -} - -pub fn toggle_bullet_list(text: &mut String) -> bool { - let has_trailing_crlf = text.ends_with("\r\n"); - let has_trailing_newline = text.ends_with('\n'); - let lines: Vec<&str> = text.lines().collect(); - if lines.is_empty() { - return false; - } - let all_bulleted = lines.iter().all(|l| { - let t = l.trim_start(); - t.is_empty() || t.starts_with("• ") || t.starts_with("- ") - }); - let mut new_text = String::with_capacity(text.len() + 16); - for (i, line) in lines.iter().enumerate() { - if i > 0 { - new_text.push('\n'); - } - let trimmed = line.trim_start(); - if all_bulleted { - if let Some(rest) = trimmed.strip_prefix("• ").or_else(|| trimmed.strip_prefix("- ")) { - new_text.push_str(rest); - } else { - new_text.push_str(line); - } - } else if trimmed.is_empty() || trimmed.starts_with("• ") { - new_text.push_str(line); - } else { - new_text.push_str("• "); - new_text.push_str(line); - } - } - if has_trailing_crlf { - new_text.push_str("\r\n"); - } else if has_trailing_newline { - new_text.push('\n'); - } - if new_text != *text { - *text = new_text; - true - } else { - false - } -} - -pub fn toggle_numbered_list(text: &mut String) -> bool { - let has_trailing_crlf = text.ends_with("\r\n"); - let has_trailing_newline = text.ends_with('\n'); - let lines: Vec<&str> = text.lines().collect(); - if lines.is_empty() { - return false; - } - let is_num_line = |l: &str| { - let t = l.trim_start(); - if t.is_empty() { - return true; - } - let dot_idx = t.find('.').unwrap_or(0); - dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") - }; - let all_numbered = lines.iter().all(|l| is_num_line(l)); - let mut new_text = String::with_capacity(text.len() + 16); - let mut num = 1; - for (i, line) in lines.iter().enumerate() { - if i > 0 { - new_text.push('\n'); - } - let t = line.trim_start(); - if all_numbered { - if let Some(dot_idx) = t.find('.') - && dot_idx > 0 - && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) - && t[dot_idx..].starts_with(". ") - { - new_text.push_str(t.get(dot_idx + 2..).unwrap_or("")); - continue; - } - new_text.push_str(line); - } else if t.is_empty() { - new_text.push_str(line); - } else { - use std::fmt::Write as _; - let _ = write!(new_text, "{num}. "); - new_text.push_str(t); - num += 1; - } - } - if has_trailing_crlf { - new_text.push_str("\r\n"); - } else if has_trailing_newline { - new_text.push('\n'); - } - if new_text != *text { - *text = new_text; - true - } else { - false - } -} - -pub fn char_to_superscript(c: char) -> char { - match c { - '0' => '⁰', - '1' => '¹', - '2' => '²', - '3' => '³', - '4' => '⁴', - '5' => '⁵', - '6' => '⁶', - '7' => '⁷', - '8' => '⁸', - '9' => '⁹', - '+' => '⁺', - '-' | '−' => '⁻', - '=' => '⁼', - '(' => '⁽', - ')' => '⁾', - 'a' => 'ᵃ', - 'b' => 'ᵇ', - 'c' => 'ᶜ', - 'd' => 'ᵈ', - 'e' => 'ᵉ', - 'f' => 'ᶠ', - 'g' => 'ᵍ', - 'h' => 'ʰ', - 'i' => 'ⁱ', - 'j' => 'ʲ', - 'k' => 'ᵏ', - 'l' => 'ˡ', - 'm' => 'ᵐ', - 'n' => 'ⁿ', - 'o' => 'ᵒ', - 'p' => 'ᵖ', - 'r' => 'ʳ', - 's' => 'ˢ', - 't' => 'ᵗ', - 'u' => 'ᵘ', - 'v' => 'ᵛ', - 'w' => 'ʷ', - 'x' => 'ˣ', - 'y' => 'ʸ', - 'z' => 'ᶻ', - 'A' => 'ᴬ', - 'B' => 'ᴮ', - 'D' => 'ᴰ', - 'E' => 'ᴱ', - 'G' => 'ᴳ', - 'H' => 'ᴴ', - 'I' => 'ᴵ', - 'J' => 'ᴶ', - 'K' => 'ᴷ', - 'L' => 'ᴸ', - 'M' => 'ᴹ', - 'N' => 'ᴺ', - 'O' => 'ᴼ', - 'P' => 'ᴾ', - 'R' => 'ᴿ', - 'T' => 'ᵀ', - 'U' => 'ᵁ', - 'V' => 'ⱽ', - 'W' => 'ᵂ', - other => other, - } -} - -pub fn char_from_superscript(c: char) -> char { - match c { - '⁰' => '0', - '¹' => '1', - '²' => '2', - '³' => '3', - '⁴' => '4', - '⁵' => '5', - '⁶' => '6', - '⁷' => '7', - '⁸' => '8', - '⁹' => '9', - '⁺' => '+', - '⁻' => '-', - '⁼' => '=', - '⁽' => '(', - '⁾' => ')', - 'ᵃ' => 'a', - 'ᵇ' => 'b', - 'ᶜ' => 'c', - 'ᵈ' => 'd', - 'ᵉ' => 'e', - 'ᶠ' => 'f', - 'ᵍ' => 'g', - 'ʰ' => 'h', - 'ⁱ' => 'i', - 'ʲ' => 'j', - 'ᵏ' => 'k', - 'ˡ' => 'l', - 'ᵐ' => 'm', - 'ⁿ' => 'n', - 'ᵒ' => 'o', - 'ᵖ' => 'p', - 'ʳ' => 'r', - 'ˢ' => 's', - 'ᵗ' => 't', - 'ᵘ' => 'u', - 'ᵛ' => 'v', - 'ʷ' => 'w', - 'ˣ' => 'x', - 'ʸ' => 'y', - 'ᶻ' => 'z', - 'ᴬ' => 'A', - 'ᴮ' => 'B', - 'ᴰ' => 'D', - 'ᴱ' => 'E', - 'ᴳ' => 'G', - 'ᴴ' => 'H', - 'ᴵ' => 'I', - 'ᴶ' => 'J', - 'ᴷ' => 'K', - 'ᴸ' => 'L', - 'ᴹ' => 'M', - 'ᴺ' => 'N', - 'ᴼ' => 'O', - 'ᴾ' => 'P', - 'ᴿ' => 'R', - 'ᵀ' => 'T', - 'ᵁ' => 'U', - 'ⱽ' => 'V', - 'ᵂ' => 'W', - other => other, - } -} - -pub fn char_to_subscript(c: char) -> char { - match c { - '0' => '₀', - '1' => '₁', - '2' => '₂', - '3' => '₃', - '4' => '₄', - '5' => '₅', - '6' => '₆', - '7' => '₇', - '8' => '₈', - '9' => '₉', - '+' => '₊', - '-' | '−' => '₋', - '=' => '₌', - '(' => '₍', - ')' => '₎', - 'a' => 'ₐ', - 'e' => 'ₑ', - 'h' => 'ₕ', - 'i' => 'ᵢ', - 'j' => 'ⱼ', - 'k' => 'ₖ', - 'l' => 'ₗ', - 'm' => 'ₘ', - 'n' => 'ₙ', - 'o' => 'ₒ', - 'p' => 'ₚ', - 'r' => 'ᵣ', - 's' => 'ₛ', - 't' => 'ₜ', - 'u' => 'ᵤ', - 'v' => 'ᵥ', - 'x' => 'ₓ', - other => other, - } -} - -pub fn char_from_subscript(c: char) -> char { - match c { - '₀' => '0', - '₁' => '1', - '₂' => '2', - '₃' => '3', - '₄' => '4', - '₅' => '5', - '₆' => '6', - '₇' => '7', - '₈' => '8', - '₉' => '9', - '₊' => '+', - '₋' => '-', - '₌' => '=', - '₍' => '(', - '₎' => ')', - 'ₐ' => 'a', - 'ₑ' => 'e', - 'ₕ' => 'h', - 'ᵢ' => 'i', - 'ⱼ' => 'j', - 'ₖ' => 'k', - 'ₗ' => 'l', - 'ₘ' => 'm', - 'ₙ' => 'n', - 'ₒ' => 'o', - 'ₚ' => 'p', - 'ᵣ' => 'r', - 'ₛ' => 's', - 'ₜ' => 't', - 'ᵤ' => 'u', - 'ᵥ' => 'v', - 'ₓ' => 'x', - other => other, - } -} - -fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) { - let mut s_idx = start.min(s.len()); - while s_idx > 0 && !s.is_char_boundary(s_idx) { - s_idx -= 1; - } - let mut e_idx = end.min(s.len()); - while e_idx < s.len() && !s.is_char_boundary(e_idx) { - e_idx += 1; - } - (s_idx, e_idx) -} - -pub fn toggle_superscript(ed: &mut LineEditor) -> bool { - let (s_pos, e_pos) = if let Some(r) = &ed.selection { (r.start.min(ed.text.len()), r.end.min(ed.text.len())) } else { (0, ed.text.len()) }; - let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); - if start >= end { - return false; - } - let target = &ed.text[start..end]; - let all_sup = target.chars().all(|c| char_from_superscript(c) != c); - let converted: String = - if all_sup { target.chars().map(char_from_superscript).collect() } else { target.chars().map(char_to_superscript).collect() }; - if converted != target { - let conv_len = converted.len(); - ed.text.replace_range(start..end, &converted); - if ed.selection.is_some() { - ed.selection = Some(start..start + conv_len); - } - true - } else { - false - } -} - -pub fn toggle_subscript(ed: &mut LineEditor) -> bool { - let (s_pos, e_pos) = if let Some(r) = &ed.selection { (r.start.min(ed.text.len()), r.end.min(ed.text.len())) } else { (0, ed.text.len()) }; - let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); - if start >= end { - return false; - } - let target = &ed.text[start..end]; - let all_sub = target.chars().all(|c| char_from_subscript(c) != c); - let converted: String = if all_sub { target.chars().map(char_from_subscript).collect() } else { target.chars().map(char_to_subscript).collect() }; - if converted != target { - let conv_len = converted.len(); - ed.text.replace_range(start..end, &converted); - if ed.selection.is_some() { - ed.selection = Some(start..start + conv_len); - } - true - } else { - false - } -} - -/// A paragraph being edited. -#[derive(Clone, Debug, PartialEq)] -pub struct LineEditor { - pub selection: Option>, - pub font_resource: Option, - pub page: usize, - pub block: usize, - pub text: String, - original: String, - rect: Rect, - /// The original PDF rectangle, in user space. It is converted again each frame so the editor - /// stays attached while the page is zoomed, scrolled, or rotated. - source_rect: [f32; 4], - multiline: bool, - source: Option<(pdfcraft_engine::TextBlock, Vec)>, - pub(crate) fonts: BTreeMap, - /// The draft extends outside the source container; its frame follows the live draft bounds. - pub overflow: bool, - /// The initial source container width in screen pixels. - max_width: f32, - /// Current screen-space size. Recomputed from the current page transform before painting. - size: f32, - /// Take the keyboard focus on the next frame. - pub(crate) focus: bool, - /// Whether the text box had the keyboard at the end of the editor's last frame - /// ([`lost_focus_once`]). - had_focus: bool, - /// The Format text panel's values, and what the paragraph had (to send only changes). - pub look: pdfcraft_engine::AddedText, - look0: pdfcraft_engine::AddedText, - /// A standard family was picked from the font list. The family guessed from a PDF font name - /// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone - /// would miss the choice. - family_chosen: bool, - /// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and - /// horizontal scale (%), and what they were. - pub extras: Extras, - extras0: Extras, - /// The text the box's own Undo history starts from; see [`forget_typing_before`]. - typing_from: Option, -} - -/// The paragraph editor's text box. -pub(crate) fn input_id() -> egui::Id { - egui::Id::unique("edit-text-line-input") -} - -/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown -/// in every tab (they share the id). Once the page has `text` (the box opened on it, or formatting -/// applied it), that history is cleared: Undo in the box takes back only what was typed since, and -/// then reaches the document. Kept, it put back text from another paragraph, or the whole paragraph -/// as it was before a format change, into a box showing the page as it is now. `from` is the box's -/// own record of that; which document `doc` last used the history is kept beside it, since a box -/// in another tab on the same text would otherwise take that document's typing with its Redo. -/// When another document's box takes the text box, the box it leaves keeps its history (and -/// caret) aside, and gets it back on its return, so its typing can still be undone there. Each -/// box claims the text box when it is drawn, and Undo and Redo from the menu claim it first too, -/// since they can run before the box is drawn again after a tab switch. -pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcraft_engine::DocId, from: &mut Option, text: &str) { - let owner = id.with("typing-doc"); - let last = ctx.data(|d| d.get_temp::(owner)); - if from.as_deref() == Some(text) && last == Some(doc) { - return; - } - let aside = |doc: pdfcraft_engine::DocId| id.with(("typing-aside", doc)); - let aligns = id.with(ALIGN_HISTORY); - if let Some(left) = last.filter(|l| *l != doc) - && let Some(state) = egui::TextEdit::load_state(ctx, id) - { - // A copy of its own: a state's history is shared between its clones, and the next box - // clears it in place. The rows' alignments by text go with it. - let mut kept = egui::text_edit::TextEditState::default(); - kept.cursor = state.cursor; - kept.set_undoer(state.undoer()); - ctx.data_mut(|d| { - let rows = d.get_temp::(aligns).unwrap_or_default(); - d.insert_temp(aside(left), (kept, rows)); - }); - } - // Only the same box, still on the text it opened on, takes its history back. - let back = - ctx.data_mut(|d| d.remove_temp::<(egui::text_edit::TextEditState, AlignHistory)>(aside(doc))).filter(|_| from.as_deref() == Some(text)); - // A box new to the text box starts with nothing of the last one's: no history, caret, - // selection or rows' alignments by text. - let (state, rows) = back.unwrap_or_default(); - egui::TextEdit::store_state(ctx, id, state); - ctx.data_mut(|d| { - d.insert_temp(owner, doc); - d.insert_temp(aligns, rows); - }); - *from = Some(text.to_owned()); -} - -/// Each text a page box has shown through its own typing, with its rows' alignments then, oldest -/// last: egui's history keeps only the text and the caret. -type AlignHistory = Vec<(String, Vec>>)>; -const ALIGN_HISTORY: &str = "aligns-by-text"; -/// As many texts as egui's own history keeps. -const ALIGN_HISTORY_LEN: usize = 100; - -/// The page text box `id` went from `before` to `after` through its own typing (typing, Undo or -/// Redo), and each list of row alignments in `aligns` follows. A text the box showed before gets -/// back the alignments it had then, so Undo bringing back a deleted row brings back its -/// alignment; a new text follows the edit ([`pdfcraft_engine::paragraph_align::remap`]). An -/// alignment picked since makes the older texts' alignments stale, and they are dropped. -pub(crate) fn follow_typing( - ctx: &egui::Context, - id: egui::Id, - before: &str, - after: &str, - aligns: &mut [&mut Vec>], -) { - let now: Vec> = aligns.iter().map(|a| a.to_vec()).collect(); - // Where the edit left the caret, as a byte of `after`: it says which of two same rows changed. - let caret = egui::TextEdit::load_state(ctx, id) - .and_then(|s| s.cursor.char_range()) - .map(|r| after.char_indices().nth(r.primary.index.into()).map_or(after.len(), |(at, _)| at)); - let next = ctx.data_mut(|d| { - let seen = d.get_temp_mut_or_default::(id.with(ALIGN_HISTORY)); - if seen.iter().any(|(text, was)| text == before && *was != now) { - seen.clear(); - } - remember(seen, before, now.clone()); - let next = match seen.iter().rev().find(|(text, _)| text == after) { - Some((_, was)) if was.len() == now.len() => was.clone(), - _ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap_at(before, after, a, caret)).collect(), - }; - remember(seen, after, next.clone()); - next - }); - for (slot, a) in aligns.iter_mut().zip(next) { - **slot = a; - } -} - -fn remember(seen: &mut AlignHistory, text: &str, aligns: Vec>>) { - seen.retain(|(t, _)| t != text); - seen.push((text.to_owned(), aligns)); - if seen.len() > ALIGN_HISTORY_LEN { - seen.remove(0); - } -} - -/// Select `range` (bytes of `text`) in the page text box `id` again. egui keeps a box's selection -/// only while it has the focus, so a click on Format text drops it; given back as formatting -/// returns the focus, it stays shown, and a second choice reaches the same text. -pub(crate) fn reselect(ctx: &egui::Context, id: egui::Id, text: &str, range: &std::ops::Range) { - let chars = |at: usize| text.get(..at).map(|s| s.chars().count()); - let (Some(start), Some(end)) = (chars(range.start), chars(range.end)) else { return }; - if start < end { - let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(start), egui::text::CCursor::new(end)))); - egui::TextEdit::store_state(ctx, id, state); - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Extras { - pub underline: bool, - pub line_spacing: f64, - pub char_spacing: f64, - pub scale: f64, -} - -impl Default for Extras { - fn default() -> Self { - Extras { underline: false, line_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } - } -} - -impl LineEditor { - /// What Format text's font list shows: the chosen page font, a standard family picked - /// instead, or the paragraph's own font (its `/BaseFont` without the subset tag). - pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String { - if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) { - return face.clone(); - } - if self.look.family != self.look0.family || self.family_chosen { - return self.look.family.label().to_string(); - } - let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default(); - // "ABCDEF+Calibri" is a subset of Calibri. - let name = match base.split_once('+') { - Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, - _ => base, - }; - if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } - } - - /// Back to the paragraph's own font: no page font chosen and the family it started with. - pub(crate) fn keep_source_font(&mut self) { - self.font_resource = None; - self.look.family = self.look0.family; - self.family_chosen = false; - } - - /// A standard family picked from the font list, replacing the paragraph's font even when it - /// is the family the paragraph's font name reads as. - pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) { - self.font_resource = None; - self.look.family = family; - self.family_chosen = true; - } - - pub(crate) fn has_changes(&self) -> bool { - self.changes_with(&self.text) - } - - /// Whether the paragraph with `text` in it differs from what was opened. - fn changes_with(&self, text: &str) -> bool { - text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen - } - - /// Extra width permitted by the editor. Source containers now remain fixed. - pub fn growth(&self) -> Option { - (self.max_width > self.rect.width()).then_some(self.max_width) - } - - /// Whether the edited text differs from the original (unsaved changes). - pub fn has_unsaved_text(&self) -> bool { - self.text != self.original - } - - /// The formatting the panel changed. - pub fn style(&self) -> pdfcraft_engine::BlockStyle { - let (l, o) = (&self.look, &self.look0); - let mut style = pdfcraft_engine::BlockStyle { - resource: self.font_resource.clone(), - family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), - size: (l.size != o.size).then_some(l.size), - color: (l.color != o.color).then_some(l.color), - // The whole paragraph's alignment when it was picked, or when the rows' own were - // dropped for it (the rows are where those put them, so leaving it out keeps them there). - align: (l.align != o.align || l.aligns.is_empty() && !o.aligns.is_empty()).then_some(l.align), - underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline), - line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0) - .then_some(self.extras.line_spacing), - char_spacing: (self.extras.char_spacing != self.extras0.char_spacing).then_some(self.extras.char_spacing), - scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale), - ..Default::default() - }; - // Rows aligned on their own, sent with every rewrite of the paragraph while it is open - // (a colour or size change too): nothing in the page records them, so a rewrite without - // them would undo them. Alone, they are sent only when they changed. - if l.aligns != o.aligns || self.text != self.original || style != pdfcraft_engine::BlockStyle::default() { - style.aligns = l.aligns.clone(); - } - if let Some(range) = &self.selection - && (style.family.is_some() - || style.resource.is_some() - || style.size.is_some() - || style.color.is_some() - || style.underline.is_some() - || style.char_spacing.is_some() - || style.scale.is_some()) - { - style.ranges.push(pdfcraft_engine::RangeStyle { - range: range.clone(), - family: style.family.take(), - resource: style.resource.take(), - size: style.size.take(), - color: style.color.take(), - underline: style.underline.take(), - char_spacing: style.char_spacing.take(), - scale: style.scale.take(), - }); - } - style - } - - /// After applying formatting: the paragraph now has it. The selection stays, as the box still - /// shows it, so a second alignment or format picked for it applies to the same rows. - pub fn applied(&mut self) { - self.font_resource = None; - self.family_chosen = false; - self.look0 = self.look.clone(); - self.extras0 = self.extras; - self.original = self.text.clone(); - self.selection = None; - // The page now has this text: the box's history restarts, even when the format left the - // text as it was, so a Redo from before can't put undone typing back on top of it. - self.typing_from = None; - self.focus = true; - } - - /// Take the shared text box for this paragraph ([`forget_typing_before`]): its own history, - /// or none once the page has its text. - pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId) { - forget_typing_before(ctx, input_id(), doc, &mut self.typing_from, &self.original); - } - - /// Adopt the rewritten paragraph's current geometry before the next overlay frame. - pub(crate) fn refresh_source(&mut self, block: &pdfcraft_engine::TextBlock, lines: Vec) { - self.source_rect = block.rect.map(|v| v as f32); - self.multiline = block.rows.len() > 1; - self.source = Some((block.clone(), lines)); - let before = std::mem::replace(&mut self.text, block.editor_text.clone()); - if self.text != before { - // A byte range of the old text: it may name other characters, or none, in the new. - self.selection = None; - } - self.follow_rows(&before); - self.original = self.text.clone(); - } - - pub fn selection_formatting(&self, sources: &[(String, String)]) -> SelectionFormatting { - let Some((block, lines)) = &self.source else { - return SelectionFormatting { - font_shown: self.font_shown(sources), - size: Some(self.look.size), - family: Some(self.look.family), - bold: Some(self.look.bold), - italic: Some(self.look.italic), - color: Some(self.look.color), - }; - }; - - let spans = block.edited_spans(&self.text); - let matching_lines: Vec<&pdfcraft_engine::TextLine> = match &self.selection { - Some(range) if range.start < range.end => { - spans.iter().filter(|(span, _)| span.start < range.end && span.end > range.start).filter_map(|(_, idx)| lines.get(*idx)).collect() - } - _ => spans.iter().filter_map(|(_, idx)| lines.get(*idx)).collect(), - }; - - if matching_lines.is_empty() { - return SelectionFormatting { - font_shown: self.font_shown(sources), - size: Some(self.look.size), - family: Some(self.look.family), - bold: Some(self.look.bold), - italic: Some(self.look.italic), - color: Some(self.look.color), - }; - } - - let line_font = |l: &pdfcraft_engine::TextLine| -> String { - if let Some((_, face)) = sources.iter().find(|(res, _)| res == &l.font) { - return face.clone(); - } - let base = l.base_font.as_str(); - let name = match base.split_once('+') { - Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, - _ => base, - }; - if let Some((_, face)) = sources.iter().find(|(_, face)| face.as_str() == name) { - return face.clone(); - } - let look = look_of_line(l); - let lower = name.to_ascii_lowercase(); - if ["times", "serif", "roman", "courier", "helvetica", "arial"].iter().any(|s| lower.contains(s)) { - return look.family.label().to_string(); - } - if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } - }; - - let first_font = line_font(matching_lines[0]); - let same_font = matching_lines.iter().skip(1).all(|l| line_font(l) == first_font); - let font_shown = if same_font { - if self.font_resource.is_some() || self.look.family != self.look0.family || self.family_chosen { - self.font_shown(sources) - } else { - first_font - } - } else { - tl!("Mixed").to_string() - }; - - let first_size = (matching_lines[0].size * 10.0).round() / 10.0; - let same_size = matching_lines.iter().skip(1).all(|l| ((l.size * 10.0).round() / 10.0 - first_size).abs() < 0.05); - let size = if same_size { Some(first_size) } else { None }; - - let first_look = look_of_line(matching_lines[0]); - let same_family = matching_lines.iter().skip(1).all(|l| look_of_line(l).family == first_look.family); - let family = if same_family { Some(first_look.family) } else { None }; - let same_bold = matching_lines.iter().skip(1).all(|l| l.bold == matching_lines[0].bold); - let bold = if same_bold { Some(matching_lines[0].bold) } else { None }; - let same_italic = matching_lines.iter().skip(1).all(|l| l.italic == matching_lines[0].italic); - let italic = if same_italic { Some(matching_lines[0].italic) } else { None }; - let same_color = matching_lines.iter().skip(1).all(|l| l.color == matching_lines[0].color); - let color = if same_color { Some(matching_lines[0].color) } else { None }; - - SelectionFormatting { font_shown, size, family, bold, italic, color } - } - - pub(crate) fn sync_selection_look(&mut self, fmt: &SelectionFormatting) { - if let Some(sz) = fmt.size { - self.look.size = sz; - self.look0.size = sz; - } else { - if self.look.size <= 0.0 { - self.look.size = 12.0; - } - self.look0.size = self.look.size; - } - if let Some(f) = fmt.family { - self.look.family = f; - self.look0.family = f; - } - match fmt.bold { - Some(b) => { - self.look.bold = b; - self.look0.bold = b; - } - None => { - self.look.bold = false; - self.look0.bold = false; - } - } - match fmt.italic { - Some(it) => { - self.look.italic = it; - self.look0.italic = it; - } - None => { - self.look.italic = false; - self.look0.italic = false; - } - } - if let Some(c) = fmt.color { - self.look.color = c; - self.look0.color = c; - } - } - - /// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows - /// aligned on their own keep their alignment, and a row Undo brings back gets its own back. - pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) { - follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]); - } - - /// The text changed from `before`: rows aligned on their own keep their alignment. - pub(crate) fn follow_rows(&mut self, before: &str) { - for look in [&mut self.look, &mut self.look0] { - look.aligns = pdfcraft_engine::paragraph_align::remap(before, &self.text, &look.aligns); - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct SelectionFormatting { - pub font_shown: String, - pub size: Option, - pub family: Option, - pub bold: Option, - pub italic: Option, - pub color: Option<[f64; 3]>, -} - -fn look_of_line(l: &pdfcraft_engine::TextLine) -> pdfcraft_engine::AddedText { - source_look(&l.base_font, l.size, l.color, l.bold, l.italic) -} - -/// What a drag on a paragraph box takes hold of. -#[derive(Clone, Copy, Debug, PartialEq)] -enum Grip { - Move, - /// An edge (screen left or right), dragged to rewrap the paragraph. - Left, - Right, -} - -/// A paragraph box being dragged: moved, or (from a handle on its left or right edge) resized. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct BlockDrag { - page: usize, - block: usize, - /// A paragraph index belongs to the document generation where the press was resolved. - generation: u64, - /// Where the drag started (screen). - start: Pos2, - grip: Grip, -} - -/// How far an edge grip reaches either side of a box's edge (screen pixels). -const EDGE_REACH: f32 = 6.0; -/// How far outside an open paragraph's frame the pointer still means the frame (screen pixels): -/// the frame's Move cursor shows, and a press there takes hold of the box (`object_ui`). -pub(crate) const FRAME_REACH: f32 = 6.0; - -/// The rewrap handles drawn on a paragraph box's left and right edges (screen). -fn width_handles(b: Rect) -> [Rect; 2] { - let h = (b.height() * 0.5).clamp(14.0, 28.0); - [b.left(), b.right()].map(|x| Rect::from_center_size(Pos2::new(x, b.center().y), egui::vec2(7.0, h))) -} - -/// The edge of `b` that `p` grabs: anywhere along its height, within reach of the line. A narrow -/// box keeps its middle for moving. -fn edge_at(b: Rect, p: Pos2) -> Option { - if p.y < b.top() - EDGE_REACH || p.y > b.bottom() + EDGE_REACH { - return None; - } - let inside = EDGE_REACH.min(b.width() / 4.0); - let (dl, dr) = (p.x - b.left(), b.right() - p.x); - match (dl >= -EDGE_REACH && dl <= inside, dr >= -EDGE_REACH && dr <= inside) { - (true, true) if dl <= dr => Some(Grip::Left), - (_, true) => Some(Grip::Right), - (true, false) => Some(Grip::Left), - _ => None, - } -} - -/// The topmost box under `p` and what it grabs there; edges only on an upright page. -fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { - boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() { - Some(g) => Some((i, g)), - None => b.contains(p).then_some((i, Grip::Move)), - }) -} - -/// A paragraph's box on screen: its text rect (user space), with a little room around it. -fn paragraph_box(xf: &PageXform, info: &DocInfo, page: usize, rect: [f64; 4]) -> Rect { - xf.user_rect(info, page, rect.map(|v| v as f32)).expand(2.0) -} - -/// Whether `p` lies in the box of one of the paragraphs `lines` on `page`, its edge grips there -/// included, not beside it: where Add text acts on the page's own paragraphs. -pub(crate) fn over_paragraph(xf: &PageXform, info: &DocInfo, page: usize, lines: &[pdfcraft_engine::TextBlock], p: Pos2) -> bool { - lines.iter().any(|l| paragraph_box(xf, info, page, l.rect).contains(p)) -} - -/// The width handles need the page upright (or upside down): on a quarter-turned page the -/// screen's horizontal is the paragraph's vertical. -fn has_edges(info: &DocInfo, page: usize) -> bool { - info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0) -} - -/// What of the paragraphs `lines` lies under `p` on `page`: the one hit test of Edit text's -/// paragraphs, for the cursor and the press. A closed paragraph moves when dragged (the four-way -/// arrow says so; a click still opens it for typing) and its edge rewraps it, from a little way -/// outside the box (the handle is drawn half outside it, #2276); the open one takes the text -/// cursor. `active` is the paragraph open for typing on this page. -pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, lines: &[pdfcraft_engine::TextBlock], active: Option, p: Pos2) -> Option { - if !xf.rect.contains(p) { - return None; - } - let boxes: Vec = lines.iter().map(|l| paragraph_box(xf, info, page, l.rect)).collect(); - // Edges rewrap closed paragraphs only: around the open one lies its frame (`object_ui`). - let (block, grip) = grab_at(&boxes, p, has_edges(info, page) && active.is_none())?; - let paragraph = |part| Owner::Paragraph { block, part }; - Some(match (active, grip) { - (None, Grip::Left) => Hit::new(paragraph(ParagraphPart::Left), Layer::Handle, egui::CursorIcon::ResizeHorizontal), - (None, Grip::Right) => Hit::new(paragraph(ParagraphPart::Right), Layer::Handle, egui::CursorIcon::ResizeHorizontal), - (None, Grip::Move) => Hit::new(paragraph(ParagraphPart::Body), Layer::Text, egui::CursorIcon::Move), - (Some(open), _) if open == block => Hit::new(paragraph(ParagraphPart::Body), Layer::Editor, egui::CursorIcon::Text), - (Some(_), _) => Hit::new(paragraph(ParagraphPart::Body), Layer::Text, egui::CursorIcon::Text), - }) -} - -pub(crate) fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { - let p = &info.pages[page]; - let (a, b) = (xf.screen_to_view(r.min), xf.screen_to_view(r.max)); - let (u, v) = (p.view_to_user(a.0, a.1), p.view_to_user(b.0, b.1)); - [u[0].min(v[0]) as f64, u[1].min(v[1]) as f64, u[0].max(v[0]) as f64, u[1].max(v[1]) as f64] -} - -/// Open `block` on `page` in the inline editor. The box's geometry and source are read from the -/// `lines` handed in, so callers pass the document's current inventory. -fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], boxes: &[Rect], block: usize, view: &mut DocView) { - let PageToolContext { ui, xf, doc, page, .. } = cx; - view.pending_text_open = None; - let (Some(l), Some(b)) = (lines.get(block), boxes.get(block)) else { return }; - view.image_selection = None; - let source_lines = doc.text_lines(page); - let text = l.editor_text.clone(); - // Screen pixels per point, from the box's width. - let scale = (b.width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32); - // How far the editor box may grow: a single-line paragraph's rewrite grows to the - // page's right edge, a multi-line paragraph rewraps to its own width. - let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0; - let max_width = if l.rows.len() == 1 { (right - b.left()).max(b.width()) } else { b.width() }; - view.line_editor = Some(LineEditor { - selection: None, - font_resource: None, - page, - block, - text: text.clone(), - original: text, - rect: *b, - source_rect: l.rect.map(|v| v as f32), - multiline: l.rows.len() > 1, - source: Some((l.clone(), source_lines)), - fonts: install_source_fonts(ui.ctx(), doc, page), - overflow: false, - max_width, - size: (l.size as f32 * scale).clamp(8.0, 72.0), - focus: true, - had_focus: false, - look: look_of(l), - look0: look_of(l), - family_chosen: false, - extras: Extras::default(), - extras0: Extras::default(), - typing_from: None, - }); -} - -/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns -/// `true` when the pointer was used. -pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, allow_interaction: bool) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; - let info = &doc.info; - if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { - view.block_drag = None; - return true; - } - let boxes: Vec = lines.iter().map(|l| paragraph_box(xf, info, page, l.rect)).collect(); - // Outlines stay on their page, like its text (#3061). - let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(xf.rect.expand(1.0))); - let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); - for (i, b) in boxes.iter().enumerate() { - if active != Some(i) { - painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - } - // A box a switching click asked for opens now, once the edit queued with that click has - // applied and `lines`/`boxes` are the document's new inventory (#2022). - if allow_interaction - && active.is_none() - && let Some((open_page, key)) = view.pending_text_open.clone() - && open_page == page - { - // The edit may have regrouped the page: open the paragraph that was clicked, wherever it - // is now, and none if it was merged into another (#2516). - match key.find(lines, 0) { - Some(block) => open_editor(cx, lines, &boxes, block, view), - None => view.pending_text_open = None, - } - } - if !allow_interaction { - return false; - } - let upright = has_edges(info, page); - let draw_handles = |b: Rect| { - for h in width_handles(b) { - painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - } - }; - // A drag in progress: the box follows the pointer (or the grabbed edge does); releasing applies it. - if let Some(d) = view.block_drag.filter(|d| d.page == page) - && let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block)) - { - let p = ptr.pos.unwrap_or(d.start); - let dx = p.x - d.start.x; - let preview = match d.grip { - Grip::Move => b.translate(p - d.start), - Grip::Left => Rect::from_min_max(Pos2::new((b.left() + dx).min(b.right() - 12.0), b.min.y), b.max), - Grip::Right => Rect::from_min_max(b.min, Pos2::new((b.right() + dx).max(b.left() + 12.0), b.max.y)), - }; - painter.rect_stroke(preview, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - let resizing = d.grip != Grip::Move; - if resizing { - draw_handles(preview); - } - view.cursor.claim(Layer::Drag, if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); - if resp.drag_stopped() || !ptr.any_down { - view.block_drag = None; - let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); - let edit = if resizing { - // The new width in user space: the box's change, added to the paragraph's own. A - // moved left side (in user space; on an upside-down page that's the screen's right - // edge) moves the paragraph with it. - let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]); - let shift = to[0] - from[0]; - let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }; - // `editor_text` has a newline for every visual row, so a wrap would come back as a - // hard break and a wider box would not reflow it. `reflow_text` rejoins a wrap - // and keeps a newline that was typed. - Edit::EditTextBlock { page, block: d.block, text: l.reflow_text.clone(), style } - } else { - // Translation must keep the selected source operators. Rewriting unchanged text - // would also remove unrelated paragraphs that now overlap this one (#594). - Edit::MoveObjects { - page, - objects: vec![ObjectTarget { kind: ObjectKind::Text, index: d.block }], - offset: [to[0] - from[0], to[1] - from[1]], - } - }; - if preview != b { - view.pending_edit = Some(edit); - } - } - return true; - } - // Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it. - // What it grabs is what the press was on ([`hit`]), not what is under the pointer now: egui - // only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a - // quick flick has left the edge by then. - let owner = match view.router.owner(page) { - Some(Owner::Paragraph { block, part }) => Some((block, part)), - _ => None, - }; - if resp.drag_started() - && active.is_none() - && let Some(o) = ptr.origin - && let Some((block, part)) = owner - { - let grip = match part { - ParagraphPart::Body => Grip::Move, - ParagraphPart::Left => Grip::Left, - ParagraphPart::Right => Grip::Right, - }; - view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); - return true; - } - let Some(p) = ptr.within(xf.rect) else { return false }; - // The paragraph under the pointer: outlined, with its width handles when closed. - let Some((hovered, _)) = grab_at(&boxes, p, upright) else { return false }; - if active != Some(hovered) { - painter.rect_stroke(boxes[hovered], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - } - if upright && active.is_none() { - draw_handles(boxes[hovered]); - } - // A click opens the paragraph the press was on. - let Some((hit, _)) = owner else { return true }; - if resp.clicked() { - view.image_selection = None; - // A click that moves the editor to another paragraph applies the open one first, so its - // typed text is not dropped with the switched-away editor (#2022). A click on the open - // box's own frame, which the editor's overlay does not cover, keeps it open instead. - if let Some(open) = view.line_editor.as_ref().filter(|e| e.has_changes()) { - if open.page == page && open.block == hit { - return true; - } - if let Some(old) = view.line_editor.take() { - let style = old.style(); - if old.text != old.original || style != pdfcraft_engine::BlockStyle::default() { - view.applied_editor = Some(old.clone()); - view.pending_edit = Some(Edit::EditTextBlock { page: old.page, block: old.block, text: old.text, style }); - } - } - // The clicked box opens next frame, from the document with that edit applied. - view.pending_text_open = lines.get(hit).map(|l| (page, pdfcraft_engine::BlockKey::of(l))); - return true; - } - open_editor(cx, lines, &boxes, hit, view); - } - true -} - -/// The inline editor; returns the edit once the text is applied. -pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { - let view_id = view.id; - // Clicks outside the document (the Format text panel) keep the paragraph open, as does the - // keyboard moving there. Where the click landed is `interact_pos`: `latest_pos` is gone on the - // frame a tap lifts. - let away = |p: egui::Pos2| !view.viewport_rect().contains(p); - let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || focus_pos(ctx).is_some_and(away); - let page = view.line_editor.as_ref()?.page; - let xf = view.page_xform(page)?; - let hold = view.objects.hold_editor; - // An OCR word is invisible text over a scan: the textless background still shows the scanned - // word, so the editor covers it, as the applied edit will. - let background_ready = view - .line_editor - .as_ref() - .is_some_and(|ed| view.text_background.ready(ed.page, ed.block) && !ed.source.as_ref().is_some_and(|(b, _)| b.invisible)); - // The measured extent of this paragraph's draft: a click straight from another paragraph - // opens this one after the frame's measuring, which was the other one's. - let draft_rect = view - .line_editor - .as_ref() - .and_then(|ed| view.text_background.draft_rect_for(ed.page, ed.block)) - .map(|r| xf.user_rect(info, page, r.map(|v| v as f32))); - // The editor draws on its page only, as the page itself does: a paragraph running past the - // page's edge (a converter relying on the page clip) must not cover the next page or the - // tool bars (#3061). - let viewport = view.viewport_rect(); - let page_clip = xf.rect.intersect(viewport); - let ed = view.line_editor.as_mut()?; - if hold { - ed.focus = true; - } - // Reproject the source box every frame. The page may have been zoomed, scrolled or rotated - // while the format panel was open. - // A font definition becomes active at the start of the next egui pass. Keep the source - // raster visible until then instead of flashing an unrelated interface face. - if !ctx.fonts(|f| ed.fonts.values().all(|name| f.definitions().families.contains_key(&FontFamily::Name(name.clone().into())))) { - ctx.request_repaint(); - return None; - } - ed.rect = xf.user_rect(info, ed.page, ed.source_rect); - ed.max_width = ed.rect.width(); - let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); - ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); - // egui draws the caret at least one row of this font tall. Each row already has its source - // row's height, so the font stays below every row (half the region's smallest size): on a - // 12 pt row under a 24 pt title the caret is as tall as the row, not the title. - let smallest = - ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); - let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); - let text_color = color32(ed.look.color); - let block = ed.block; - let mut done = None; - let mut reveal = false; - let mut look = None; - // No fade-in: while it faded, the page's own copy of the text showed through the cover. - // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or - // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as - // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::unique("edit-text-line")) - .order(egui::Order::Foreground) - .fade_in(false) - .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) - .constrain_to(viewport) - .constrain(false) - .show(ctx, |ui| { - ui.set_clip_rect(ui.clip_rect().intersect(page_clip)); - // The editor opens at the source size. Once text changes, show the real draft - // extent: single rows grow toward the page edge, paragraphs rewrap at their - // source width, and the visible frame grows/shrinks vertically with their rows. - // An OCR phrase (several stretched words on one scanned line) also grows rightwards. - let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.rows.len() == 1 || block.invisible); - let width = if !ed.multiline && single_run && !ed.text.contains('\n') && ed.has_changes() { - let job = editor_job(ui, ed, &ed.text, scale, f32::INFINITY); - let natural = ui.fonts_mut(|f| f.layout_job(job)).size().x; - natural.max(ed.size * 0.4).min((xf.rect.right() - ed.rect.left() - 36.0 * scale).max(ed.rect.width())) - } else { - ed.rect.width().max(1.0) - }; - ui.set_width(width); - let measured = editor_galley(ui, ed, &ed.text, scale, width); - ed.overflow = ed.has_changes() && (measured.rect.width() > ed.rect.width() + 1.5 || measured.rect.height() > ed.rect.height() + 1.5); - let visible = Rect::from_min_size(ed.rect.min, measured.rect.size().max(egui::vec2(1.0, 1.0))); - // Unsupported source programs use an interface fallback whose glyph advances - // can differ from the PDF's. Keep every visible row inside the growing frame. - let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); - ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - // On the frame, a press takes hold of the box (a click selects it, a drag moves it): - // the pointer says so there, and reads as text inside. - if let Some(p) = ui.input(|i| i.pointer.hover_pos()) - && ui.clip_rect().contains(p) - && live.expand(2.0 + FRAME_REACH).contains(p) - && !live.expand(2.0).contains(p) - { - ui.ctx().set_cursor_icon(egui::CursorIcon::Move); - } - // White, unless the text is too pale to read on white (white text from a coloured - // banner, #913): then a dark box, with a caret that shows on it. - let fill = if background_ready { Color32::TRANSPARENT } else { editor_fill(text_color) }; - // An unchanged paragraph is the page's own text: the editor draws only its frame and - // caret over it. Once it changes, the textless page goes under the editor's text or, - // until that background is ready, a cover over the entire source box, including lines - // the draft no longer occupies. Shrinking the text must never expose a copy. Both are - // decided after this frame's typing, in places kept under the editor's text. - let cover = ui.painter().add(egui::Shape::Noop); - let unchanged = !ed.has_changes(); - egui::Frame::NONE.show(ui, |ui| { - // A textless PDF preview, or the page itself, keeps its real background through - // the transparent editor. The temporary opaque cover uses the shared caret-contrast rule. - ui.visuals_mut().text_cursor.stroke.color = if background_ready || unchanged { - if luminance(text_color) < 0.5 { Color32::BLACK } else { Color32::WHITE } - } else { - caret_color_on(fill) - }; - let rows = 1; - let snapshot = ed.clone(); - let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { - let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width); - if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } - }; - let before = ed.text.clone(); - let input = input_id(); - ed.claim_typing(ui.ctx(), view_id); - let output = egui::TextEdit::multiline(&mut ed.text) - .id(input) - .font(font.clone()) - .margin(egui::Margin::ZERO) - .layouter(&mut layouter) - .text_color(text_color) - .frame(egui::Frame::NONE) - .desired_width(width) - .desired_rows(rows) - .show(ui); - // The editor paints over the page with no visible label; name it for assistive tech. - output.response.response.clone().accessible_name(tl!("Text editing line")); - if ed.has_changes() { - if background_ready { - reveal = true; - } else { - ui.painter().set(cover, egui::Shape::rect_filled(ed.rect.expand(1.0).union(output.response.rect), CornerRadius::ZERO, fill)); - } - } - look = Some((Arc::clone(&output.galley), output.galley_pos)); - if let Some(cursor) = output.cursor_range { - let selected = cursor.as_sorted_char_range(); - let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at); - ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into())); - } - let r = output.response; - if r.changed() { - ed.follow_typed_rows(ctx, &before); - // The next pass measures the updated draft with the Apply/Save rewrite. - ctx.request_repaint(); - } - if ed.focus { - r.request_focus(); - if let Some(range) = &ed.selection { - reselect(ui.ctx(), input, &ed.text, range); - } - ed.focus = false; - } - let lost = lost_focus_once(&r, &mut ed.had_focus); - let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); - let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); - if esc { - done = Some(false); - } else if apply || (lost && !outside && !hold) { - done = Some(true); - } - }); - }); - if reveal { - view.text_background.reveal(); - } - if let Some((galley, pos)) = look { - view.text_background.remember(&xf, page, block, galley, pos, text_color); - } - match done { - Some(apply) => { - let ed = view.line_editor.take()?; - if !apply { - // Esc leaves the text but not the paragraph: its box is selected, as in Acrobat, - // so Delete removes it without aiming for the frame. The draft is discarded. - view.objects.select_paragraph_next(ed.page, ed.block); - return None; - } - view.applied_editor = Some(ed.clone()); - finished_edit(ed) - } - None => None, - } -} - -/// The edit a closed paragraph editor applies, or `None` when nothing in it changed. -pub(crate) fn finished_edit(ed: LineEditor) -> Option { - let style = ed.style(); - (ed.text != ed.original || style != pdfcraft_engine::BlockStyle::default()).then_some(Edit::EditTextBlock { - page: ed.page, - block: ed.block, - text: ed.text, - style, - }) -} - -/// Where the keyboard is: the centre of the widget that has it. A text box that loses it to a -/// widget reached by Tab or a screen reader (the Format text panel's controls) judges where it -/// went by this, as it judges a click by where the click landed. -pub(crate) fn focus_pos(ctx: &egui::Context) -> Option { - ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).map(|r| r.rect.center()) -} - -/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at -/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second -/// frame as well, and an editor that judges a click away by where the click landed must judge it -/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends -/// `PointerGone` with every touch release) and a mouse may have moved on. -pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { - let has = r.has_focus(); - std::mem::replace(had, has) && !has -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Formatting applied restarts the rows' alignments by text with the box's history: a Redo - /// past the format can't bring back an alignment from before it (FredApps on bfc6f8b1). - #[test] - fn formatting_applied_restarts_the_alignments_by_text() { - use pdfcraft_engine::{DocId, TextAlign::Right}; - let (ctx, id) = (egui::Context::default(), egui::Id::unique("box")); - let mut from = None; - forget_typing_before(&ctx, id, DocId(1), &mut from, "A\nB"); - let mut aligns = vec![None, Some(Right)]; - follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); - // Formatting applied: the page now has "A", and the box's record of its text is reset. - from = None; - forget_typing_before(&ctx, id, DocId(1), &mut from, "A"); - follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); - assert_eq!(aligns, pdfcraft_engine::paragraph_align::remap("A", "A\nB", &[None]), "the row before the format doesn't come back aligned"); - } - - /// The rows' alignments by text go aside with the box's history when another tab's box takes - /// the text box, and come back with it: Undo after the trip still restores a deleted row's - /// alignment, while the other tab's box starts with none. - #[test] - fn alignments_by_text_survive_a_trip_to_another_tab() { - use pdfcraft_engine::{DocId, TextAlign::Right}; - let (ctx, id) = (egui::Context::default(), egui::Id::unique("box")); - let (mut a_from, mut b_from) = (None, None); - forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); - let mut aligns = vec![None, Some(Right)]; - follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); - forget_typing_before(&ctx, id, DocId(2), &mut b_from, "A\nB"); - let mut other = vec![None, None]; - follow_typing(&ctx, id, "A\nB", "A", &mut [&mut other]); - follow_typing(&ctx, id, "A", "A\nB", &mut [&mut other]); - assert_eq!(other, [None, None], "tab B's box gets back its own alignments, not tab A's"); - forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); - follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); - assert_eq!(aligns, [None, Some(Right)], "back in tab A, Undo brings the row back right-aligned"); - } - - /// Undo back to a text the box showed brings its rows' alignments back, unless an alignment - /// was picked since: then the older alignments are stale, and the edit is followed instead, so - /// Undo never takes back the pick. - #[test] - fn typing_history_restores_alignments_unless_one_was_picked_since() { - use pdfcraft_engine::TextAlign::{Center, Right}; - let (ctx, id) = (egui::Context::default(), egui::Id::unique("box")); - let mut aligns = vec![None, Some(Center), Some(Right)]; - follow_typing( - &ctx, - id, - "A -B -C", - "A -C", - &mut [&mut aligns], - ); - assert_eq!(aligns, [None, Some(Right)], "the deleted row's entry goes"); - follow_typing( - &ctx, - id, - "A -C", - "A -B -C", - &mut [&mut aligns], - ); - assert_eq!(aligns, [None, Some(Center), Some(Right)], "Undo brings it back"); - follow_typing( - &ctx, - id, - "A -B -C", - "A -C", - &mut [&mut aligns], - ); - // An alignment picked for the first row, with no typing. - aligns = vec![Some(Right), Some(Right)]; - let picked = aligns.clone(); - follow_typing( - &ctx, - id, - "A -C", - "A -B -C", - &mut [&mut aligns], - ); - assert_eq!( - aligns, - pdfcraft_engine::paragraph_align::remap( - "A -C", "A -B -C", &picked - ), - "the pick stays" - ); - } - - #[test] - fn the_rotate_knob_moves_below_when_there_is_no_room_above() { - let view = Rect::from_min_max(Pos2::new(0.0, 100.0), Pos2::new(500.0, 500.0)); - let roomy = Rect::from_min_max(Pos2::new(100.0, 200.0), Pos2::new(200.0, 250.0)); - assert_eq!(rotate_knob(roomy, view).0, Pos2::new(150.0, 178.0)); - let at_top = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0)); - let (knob, stem) = rotate_knob(at_top, view); - assert_eq!((knob, stem), (Pos2::new(150.0, 172.0), Pos2::new(150.0, 150.0))); - } - - #[test] - fn the_rotate_knob_measures_clockwise_degrees_from_straight_up() { - let c = Pos2::new(100.0, 100.0); - let at = |x: f32, y: f32| turn_degrees(c, Pos2::new(x, y), false); - assert!(at(100.0, 0.0).abs() < 1e-3); - assert!((at(200.0, 100.0) - 90.0).abs() < 1e-3, "right is a clockwise quarter"); - assert!((at(0.0, 100.0) + 90.0).abs() < 1e-3); - assert!((at(100.0, 200.0).abs() - 180.0).abs() < 1e-3); - // Shift rounds to 15 degrees; the centre and non-finite points turn nothing. - assert_eq!(turn_degrees(c, Pos2::new(180.0, 50.0), true) % 15.0, 0.0); - assert_eq!(at(101.0, 101.0), 0.0); - assert_eq!(at(f32::NAN, 5.0), 0.0); - } - - /// An app with one text document open, and an editor on its first paragraph. - fn preview_app() -> (crate::PdfCraftApp, LineEditor) { - let mut app = crate::PdfCraftApp::new(); - let bytes = app.session.create_from_text("Preview", "Preview text").unwrap(); - app.open_bytes("preview.pdf", None, bytes.as_ref().clone()).unwrap(); - let doc = app.session.get(app.views[0].id).unwrap(); - let block = doc.text_blocks(0).remove(0); - let ed = LineEditor { - selection: None, - font_resource: None, - page: 0, - block: 0, - text: block.text.clone(), - original: block.text.clone(), - rect: Rect::NOTHING, - source_rect: block.rect.map(|v| v as f32), - multiline: block.rows.len() > 1, - source: None, - fonts: BTreeMap::new(), - overflow: false, - max_width: 400.0, - size: block.size as f32, - focus: true, - had_focus: false, - look: look_of(&block), - look0: look_of(&block), - family_chosen: false, - extras: Extras::default(), - extras0: Extras::default(), - typing_from: None, - }; - (app, ed) - } - - #[test] - fn picking_the_family_the_font_name_reads_as_still_changes_the_font() { - // A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave - // `look` equal to what the paragraph had, so no family change was sent and Calibri stayed. - let (_app, mut ed) = preview_app(); - let family = ed.look0.family; - assert_eq!(ed.style().family, None, "nothing chosen yet"); - ed.choose_family(family); - assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic))); - assert!(ed.has_changes()); - assert_eq!(ed.font_shown(&[]), family.label()); - ed.keep_source_font(); - assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice"); - ed.choose_family(family); - ed.applied(); - assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now"); - } - - #[test] - fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { - // Loading a font used to only register it on the page: the paragraph kept its source - // face until the font was found again in the document-font menu (#2591). - let (mut app, ed) = preview_app(); - app.views[0].line_editor = Some(ed); - let inter = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); - app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]); - let doc = app.session.get(app.views[0].id).unwrap(); - let lines = doc.text_lines(0); - assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text"); - assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); - assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); - } - - #[test] - fn save_applies_the_paragraph_open_in_the_editor() { - // Save wrote the file without the paragraph still open in the editor (#3496). - let (mut app, mut ed) = preview_app(); - ed.text = "Text preview".into(); - app.views[0].line_editor = Some(ed); - let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-paragraph-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned()); - - assert!(app.save_active(crate::SaveTarget::InPlace)); - assert!(app.views[0].line_editor.is_none()); - let doc = app.session.get(app.views[0].id).unwrap(); - assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_refused_text_box_does_not_lose_the_open_paragraph() { - let (mut app, mut ed) = preview_app(); - ed.text = "Text preview".into(); - app.views[0].line_editor = Some(ed); - let draft = crate::content_ui::TextDraft { - page: 9, - index: None, - rect: [20.0, 20.0, 120.0, 40.0], - text: "Typed text".into(), - style: crate::content_ui::default_style(), - focus: true, - selection: None, - typing_from: None, - }; - app.views[0].content.draft = Some(draft.clone()); - - assert!(!app.commit_typing_in(app.views[0].id)); - assert_eq!(app.views[0].content.draft, Some(draft), "the refused text box stays open"); - assert!(app.views[0].line_editor.is_none(), "the paragraph was applied"); - let doc = app.session.get(app.views[0].id).unwrap(); - assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); - } - - #[test] - fn a_refused_paragraph_stays_open_with_its_text() { - let (mut app, mut ed) = preview_app(); - ed.text = "Text preview".into(); - ed.page = 9; - app.views[0].line_editor = Some(ed.clone()); - - assert!(!app.commit_typing_in(app.views[0].id)); - assert_eq!(app.views[0].line_editor, Some(ed), "the paragraph keeps its unsaved text"); - } - - #[test] - fn text_background_is_cached_without_editing_and_released_on_cancel() { - let (app, ed) = preview_app(); - let doc = app.session.get(app.views[0].id).unwrap(); - let source = doc.bytes.clone(); - let generation = doc.edit_generation(); - let ctx = egui::Context::default(); - let mut background = TextBackground::default(); - for _ in 0..100 { - background.prepare(&ctx, doc, Some(&ed), None, 1.0); - if background.ready(0, 0) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(background.ready(0, 0)); - assert!(!background.pending()); - let texture = background.texture.as_ref().unwrap().id(); - background.prepare(&ctx, doc, Some(&ed), None, 1.0); - assert_eq!(background.texture.as_ref().unwrap().id(), texture); - assert_eq!(doc.bytes, source); - assert_eq!(doc.edit_generation(), generation); - assert!(!doc.dirty && doc.can_undo().is_none()); - background.prepare(&ctx, doc, None, None, 1.0); - assert!(background.texture.is_none() && background.renderer.is_none()); - } - - #[test] - fn the_editing_background_stays_sharp_on_a_zoomed_page() { - // While a paragraph is open, the page around it is drawn from this one raster: it is - // asked for at the tiling threshold, not the 2048-pixel backdrop side, which went soft - // beyond about 1.2x on a Retina display. - let (app, ed) = preview_app(); - let doc = app.session.get(app.views[0].id).unwrap(); - let page = &doc.info.pages[0]; - let ctx = egui::Context::default(); - ctx.begin_pass(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }); - let mut background = TextBackground::default(); - background.prepare(&ctx, doc, Some(&ed), None, 8.0); - let expected = 8.0f32.min(crate::canvas::TILE_THRESHOLD / page.width.max(page.height)); - assert!(expected > crate::canvas::BASE_SIDE / page.width.max(page.height), "the test page needs the larger side"); - assert_eq!(background.tag, Some((expected * 1000.0) as u64)); - // A GPU that can't hold such a texture caps it. - ctx.end_pass().textures_delta.clear(); - ctx.begin_pass(egui::RawInput { max_texture_side: Some(2048), ..Default::default() }); - let mut small = TextBackground::default(); - small.prepare(&ctx, doc, Some(&ed), None, 8.0); - assert_eq!(small.tag, Some(((2048.0 / page.width.max(page.height)) * 1000.0) as u64)); - ctx.end_pass().textures_delta.clear(); - } - - #[test] - fn pale_text_is_edited_on_a_dark_box() { - // #913: white text from a coloured banner was typed onto a white box and vanished. - for pale in [Color32::WHITE, Color32::from_rgb(0xFF, 0xFF, 0x00), Color32::from_gray(0xE0), Color32::from_rgb(0xFF, 0xA5, 0x00)] { - assert_eq!(editor_fill(pale), DARK_EDITOR_FILL, "{pale:?}"); - assert!(contrast(pale, editor_fill(pale)) >= 4.5, "{pale:?} is readable"); - } - // Ordinary dark or saturated text keeps the white box. - for dark in [Color32::BLACK, Color32::from_rgb(0x1F, 0x2A, 0x6B), Color32::from_rgb(0xC0, 0x00, 0x00), Color32::from_gray(0x60)] { - assert_eq!(editor_fill(dark), Color32::WHITE, "{dark:?}"); - } - } - - #[test] - fn caret_contrasts_with_its_fill() { - assert_eq!(caret_color_on(Color32::WHITE), Color32::BLACK); - assert_eq!(caret_color_on(Color32::from_rgb(0xFF, 0xFF, 0xF4)), Color32::BLACK); - assert_eq!(caret_color_on(Color32::BLACK), Color32::WHITE); - assert_eq!(caret_color_on(DARK_EDITOR_FILL), Color32::WHITE); - } - - #[test] - fn bullet_list_toggles_and_preserves_content() { - let mut text = "First line\nSecond line\n\nThird line".to_string(); - assert!(!is_bulleted(&text)); - assert!(toggle_bullet_list(&mut text)); - assert!(is_bulleted(&text)); - assert_eq!(text, "• First line\n• Second line\n\n• Third line"); - - // Toggling again removes bullets - assert!(toggle_bullet_list(&mut text)); - assert!(!is_bulleted(&text)); - assert_eq!(text, "First line\nSecond line\n\nThird line"); - } - - #[test] - fn numbered_list_toggles_and_preserves_content() { - let mut text = "Item A\nItem B\n\nItem C".to_string(); - assert!(!is_numbered(&text)); - assert!(toggle_numbered_list(&mut text)); - assert!(is_numbered(&text)); - assert_eq!(text, "1. Item A\n2. Item B\n\n3. Item C"); - - // Toggling again removes numbering - assert!(toggle_numbered_list(&mut text)); - assert!(!is_numbered(&text)); - assert_eq!(text, "Item A\nItem B\n\nItem C"); - } - - #[test] - fn superscript_and_subscript_conversion() { - let mut ed = LineEditor { - page: 0, - block: 0, - text: "E = mc2".to_string(), - original: "E = mc2".to_string(), - rect: egui::Rect::ZERO, - source_rect: [0.0; 4], - max_width: 100.0, - overflow: false, - selection: Some(6..7), // selecting '2' - font_resource: None, - fonts: std::collections::BTreeMap::new(), - source: None, - multiline: false, - size: 12.0, - family_chosen: false, - focus: true, - had_focus: false, - look: pdfcraft_engine::AddedText::default(), - look0: pdfcraft_engine::AddedText::default(), - extras: Extras::default(), - extras0: Extras::default(), - }; - - assert!(toggle_superscript(&mut ed)); - assert_eq!(ed.text, "E = mc²"); - // Toggling superscript again returns to normal '2' - assert!(toggle_superscript(&mut ed)); - assert_eq!(ed.text, "E = mc2"); - - // Test subscript on H2O - ed.text = "H2O".to_string(); - ed.selection = Some(1..2); // selecting '2' - assert!(toggle_subscript(&mut ed)); - assert_eq!(ed.text, "H₂O"); - assert!(toggle_subscript(&mut ed)); - assert_eq!(ed.text, "H2O"); - } - - #[test] - fn value_based_line_spacing_in_style() { - let ed = LineEditor { - page: 0, - block: 0, - text: "Paragraph text".to_string(), - original: "Paragraph text".to_string(), - rect: egui::Rect::ZERO, - source_rect: [0.0; 4], - max_width: 100.0, - overflow: false, - selection: None, - font_resource: None, - fonts: std::collections::BTreeMap::new(), - source: None, - multiline: false, - size: 12.0, - family_chosen: false, - focus: true, - had_focus: false, - look: pdfcraft_engine::AddedText::default(), - look0: pdfcraft_engine::AddedText::default(), - extras: Extras { line_spacing: 1.33, underline: true, ..Default::default() }, - extras0: Extras::default(), - }; - let style = ed.style(); - assert_eq!(style.line_spacing, Some(1.33)); - assert_eq!(style.underline, Some(true)); - } -} +//! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page. +//! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the +//! box, Esc cancels); drag it to move it, or drag the handle on its left or right edge to rewrap +//! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping +//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke, pos2}; +use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; +use pdfcraft_render::DocInfo; + +use crate::canvas::{DocView, PageToolContext, PageXform}; +use crate::page_cursor::Layer; +use crate::page_pointer::PagePointer; +use crate::page_router::{Hit, Owner, ParagraphPart}; + +mod background; +mod image; +mod layout; + +pub(crate) use background::TextBackground; +pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; +pub(crate) use image::{ + ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen, +}; +#[cfg(test)] +use image::{rotate_knob, turn_degrees}; +#[cfg(test)] +use layout::{DARK_EDITOR_FILL, contrast}; +pub(crate) use layout::{caret_color_on, editor_fill, editor_font, install_source_fonts}; +use layout::{color32, editor_galley, editor_job, look_of, luminance, source_look, unseen}; + +const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); + +#[derive(Clone, Copy, PartialEq)] +enum TextTarget { + Block(usize), + Added(usize), +} + +/// How many faint Edit-text outlines are drawn per page per frame. CAD plots can draw +/// ~1000 tiny images on one page; stroking all of them every frame (plus a hover query +/// per box) stalls the UI. Past this, only the selected and hovered boxes are outlined: +/// hit-testing still covers every image, so nothing becomes unselectable. +pub(crate) const MAX_OUTLINES: usize = 250; + +pub fn is_bulleted(text: &str) -> bool { + let mut lines = text.lines(); + let Some(first) = lines.next() else { return false }; + first.trim_start().starts_with("• ") || first.trim_start().starts_with("- ") +} + +pub fn is_numbered(text: &str) -> bool { + let mut lines = text.lines(); + let Some(first) = lines.next() else { return false }; + let t = first.trim_start(); + let num_end = t.find('.').unwrap_or(0); + num_end > 0 && t[..num_end].chars().all(|c| c.is_ascii_digit()) && t[num_end..].starts_with(". ") +} + +pub fn toggle_bullet_list(text: &mut String) -> bool { + let has_trailing_crlf = text.ends_with("\r\n"); + let has_trailing_newline = text.ends_with('\n'); + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return false; + } + let all_bulleted = lines.iter().all(|l| { + let t = l.trim_start(); + t.is_empty() || t.starts_with("• ") || t.starts_with("- ") + }); + let mut new_text = String::with_capacity(text.len() + 16); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + new_text.push('\n'); + } + let trimmed = line.trim_start(); + if all_bulleted { + if let Some(rest) = trimmed.strip_prefix("• ").or_else(|| trimmed.strip_prefix("- ")) { + new_text.push_str(rest); + } else { + new_text.push_str(line); + } + } else if trimmed.is_empty() || trimmed.starts_with("• ") { + new_text.push_str(line); + } else { + new_text.push_str("• "); + new_text.push_str(line); + } + } + if has_trailing_crlf { + new_text.push_str("\r\n"); + } else if has_trailing_newline { + new_text.push('\n'); + } + if new_text != *text { + *text = new_text; + true + } else { + false + } +} + +pub fn toggle_numbered_list(text: &mut String) -> bool { + let has_trailing_crlf = text.ends_with("\r\n"); + let has_trailing_newline = text.ends_with('\n'); + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return false; + } + let is_num_line = |l: &str| { + let t = l.trim_start(); + if t.is_empty() { + return true; + } + let dot_idx = t.find('.').unwrap_or(0); + dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") + }; + let all_numbered = lines.iter().all(|l| is_num_line(l)); + let mut new_text = String::with_capacity(text.len() + 16); + let mut num = 1; + for (i, line) in lines.iter().enumerate() { + if i > 0 { + new_text.push('\n'); + } + let t = line.trim_start(); + if all_numbered { + if let Some(dot_idx) = t.find('.') + && dot_idx > 0 + && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) + && t[dot_idx..].starts_with(". ") + { + new_text.push_str(t.get(dot_idx + 2..).unwrap_or("")); + continue; + } + new_text.push_str(line); + } else if t.is_empty() { + new_text.push_str(line); + } else { + use std::fmt::Write as _; + let _ = write!(new_text, "{num}. "); + new_text.push_str(t); + num += 1; + } + } + if has_trailing_crlf { + new_text.push_str("\r\n"); + } else if has_trailing_newline { + new_text.push('\n'); + } + if new_text != *text { + *text = new_text; + true + } else { + false + } +} + +pub fn char_to_superscript(c: char) -> char { + match c { + '0' => '⁰', + '1' => '¹', + '2' => '²', + '3' => '³', + '4' => '⁴', + '5' => '⁵', + '6' => '⁶', + '7' => '⁷', + '8' => '⁸', + '9' => '⁹', + '+' => '⁺', + '-' | '−' => '⁻', + '=' => '⁼', + '(' => '⁽', + ')' => '⁾', + 'a' => 'ᵃ', + 'b' => 'ᵇ', + 'c' => 'ᶜ', + 'd' => 'ᵈ', + 'e' => 'ᵉ', + 'f' => 'ᶠ', + 'g' => 'ᵍ', + 'h' => 'ʰ', + 'i' => 'ⁱ', + 'j' => 'ʲ', + 'k' => 'ᵏ', + 'l' => 'ˡ', + 'm' => 'ᵐ', + 'n' => 'ⁿ', + 'o' => 'ᵒ', + 'p' => 'ᵖ', + 'r' => 'ʳ', + 's' => 'ˢ', + 't' => 'ᵗ', + 'u' => 'ᵘ', + 'v' => 'ᵛ', + 'w' => 'ʷ', + 'x' => 'ˣ', + 'y' => 'ʸ', + 'z' => 'ᶻ', + 'A' => 'ᴬ', + 'B' => 'ᴮ', + 'D' => 'ᴰ', + 'E' => 'ᴱ', + 'G' => 'ᴳ', + 'H' => 'ᴴ', + 'I' => 'ᴵ', + 'J' => 'ᴶ', + 'K' => 'ᴷ', + 'L' => 'ᴸ', + 'M' => 'ᴹ', + 'N' => 'ᴺ', + 'O' => 'ᴼ', + 'P' => 'ᴾ', + 'R' => 'ᴿ', + 'T' => 'ᵀ', + 'U' => 'ᵁ', + 'V' => 'ⱽ', + 'W' => 'ᵂ', + other => other, + } +} + +pub fn char_from_superscript(c: char) -> char { + match c { + '⁰' => '0', + '¹' => '1', + '²' => '2', + '³' => '3', + '⁴' => '4', + '⁵' => '5', + '⁶' => '6', + '⁷' => '7', + '⁸' => '8', + '⁹' => '9', + '⁺' => '+', + '⁻' => '-', + '⁼' => '=', + '⁽' => '(', + '⁾' => ')', + 'ᵃ' => 'a', + 'ᵇ' => 'b', + 'ᶜ' => 'c', + 'ᵈ' => 'd', + 'ᵉ' => 'e', + 'ᶠ' => 'f', + 'ᵍ' => 'g', + 'ʰ' => 'h', + 'ⁱ' => 'i', + 'ʲ' => 'j', + 'ᵏ' => 'k', + 'ˡ' => 'l', + 'ᵐ' => 'm', + 'ⁿ' => 'n', + 'ᵒ' => 'o', + 'ᵖ' => 'p', + 'ʳ' => 'r', + 'ˢ' => 's', + 'ᵗ' => 't', + 'ᵘ' => 'u', + 'ᵛ' => 'v', + 'ʷ' => 'w', + 'ˣ' => 'x', + 'ʸ' => 'y', + 'ᶻ' => 'z', + 'ᴬ' => 'A', + 'ᴮ' => 'B', + 'ᴰ' => 'D', + 'ᴱ' => 'E', + 'ᴳ' => 'G', + 'ᴴ' => 'H', + 'ᴵ' => 'I', + 'ᴶ' => 'J', + 'ᴷ' => 'K', + 'ᴸ' => 'L', + 'ᴹ' => 'M', + 'ᴺ' => 'N', + 'ᴼ' => 'O', + 'ᴾ' => 'P', + 'ᴿ' => 'R', + 'ᵀ' => 'T', + 'ᵁ' => 'U', + 'ⱽ' => 'V', + 'ᵂ' => 'W', + other => other, + } +} + +pub fn char_to_subscript(c: char) -> char { + match c { + '0' => '₀', + '1' => '₁', + '2' => '₂', + '3' => '₃', + '4' => '₄', + '5' => '₅', + '6' => '₆', + '7' => '₇', + '8' => '₈', + '9' => '₉', + '+' => '₊', + '-' | '−' => '₋', + '=' => '₌', + '(' => '₍', + ')' => '₎', + 'a' => 'ₐ', + 'e' => 'ₑ', + 'h' => 'ₕ', + 'i' => 'ᵢ', + 'j' => 'ⱼ', + 'k' => 'ₖ', + 'l' => 'ₗ', + 'm' => 'ₘ', + 'n' => 'ₙ', + 'o' => 'ₒ', + 'p' => 'ₚ', + 'r' => 'ᵣ', + 's' => 'ₛ', + 't' => 'ₜ', + 'u' => 'ᵤ', + 'v' => 'ᵥ', + 'x' => 'ₓ', + other => other, + } +} + +pub fn char_from_subscript(c: char) -> char { + match c { + '₀' => '0', + '₁' => '1', + '₂' => '2', + '₃' => '3', + '₄' => '4', + '₅' => '5', + '₆' => '6', + '₇' => '7', + '₈' => '8', + '₉' => '9', + '₊' => '+', + '₋' => '-', + '₌' => '=', + '₍' => '(', + '₎' => ')', + 'ₐ' => 'a', + 'ₑ' => 'e', + 'ₕ' => 'h', + 'ᵢ' => 'i', + 'ⱼ' => 'j', + 'ₖ' => 'k', + 'ₗ' => 'l', + 'ₘ' => 'm', + 'ₙ' => 'n', + 'ₒ' => 'o', + 'ₚ' => 'p', + 'ᵣ' => 'r', + 'ₛ' => 's', + 'ₜ' => 't', + 'ᵤ' => 'u', + 'ᵥ' => 'v', + 'ₓ' => 'x', + other => other, + } +} + +fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) { + let mut s_idx = start.min(s.len()); + while s_idx > 0 && !s.is_char_boundary(s_idx) { + s_idx -= 1; + } + let mut e_idx = end.min(s.len()); + while e_idx < s.len() && !s.is_char_boundary(e_idx) { + e_idx += 1; + } + (s_idx, e_idx) +} + +pub fn toggle_superscript(ed: &mut LineEditor) -> bool { + let (s_pos, e_pos) = if let Some(r) = &ed.selection { (r.start.min(ed.text.len()), r.end.min(ed.text.len())) } else { (0, ed.text.len()) }; + let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); + if start >= end { + return false; + } + let target = &ed.text[start..end]; + let all_sup = target.chars().all(|c| char_from_superscript(c) != c); + let converted: String = + if all_sup { target.chars().map(char_from_superscript).collect() } else { target.chars().map(char_to_superscript).collect() }; + if converted != target { + let conv_len = converted.len(); + ed.text.replace_range(start..end, &converted); + if ed.selection.is_some() { + ed.selection = Some(start..start + conv_len); + } + true + } else { + false + } +} + +pub fn toggle_subscript(ed: &mut LineEditor) -> bool { + let (s_pos, e_pos) = if let Some(r) = &ed.selection { (r.start.min(ed.text.len()), r.end.min(ed.text.len())) } else { (0, ed.text.len()) }; + let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); + if start >= end { + return false; + } + let target = &ed.text[start..end]; + let all_sub = target.chars().all(|c| char_from_subscript(c) != c); + let converted: String = if all_sub { target.chars().map(char_from_subscript).collect() } else { target.chars().map(char_to_subscript).collect() }; + if converted != target { + let conv_len = converted.len(); + ed.text.replace_range(start..end, &converted); + if ed.selection.is_some() { + ed.selection = Some(start..start + conv_len); + } + true + } else { + false + } +} + +/// A paragraph being edited. +#[derive(Clone, Debug, PartialEq)] +pub struct LineEditor { + pub selection: Option>, + pub font_resource: Option, + pub page: usize, + pub block: usize, + pub text: String, + original: String, + rect: Rect, + /// The original PDF rectangle, in user space. It is converted again each frame so the editor + /// stays attached while the page is zoomed, scrolled, or rotated. + source_rect: [f32; 4], + multiline: bool, + source: Option<(pdfcraft_engine::TextBlock, Vec)>, + pub(crate) fonts: BTreeMap, + /// The draft extends outside the source container; its frame follows the live draft bounds. + pub overflow: bool, + /// The initial source container width in screen pixels. + max_width: f32, + /// Current screen-space size. Recomputed from the current page transform before painting. + size: f32, + /// Take the keyboard focus on the next frame. + pub(crate) focus: bool, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`lost_focus_once`]). + had_focus: bool, + /// The Format text panel's values, and what the paragraph had (to send only changes). + pub look: pdfcraft_engine::AddedText, + look0: pdfcraft_engine::AddedText, + /// A standard family was picked from the font list. The family guessed from a PDF font name + /// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone + /// would miss the choice. + family_chosen: bool, + /// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and + /// horizontal scale (%), and what they were. + pub extras: Extras, + extras0: Extras, + /// The text the box's own Undo history starts from; see [`forget_typing_before`]. + typing_from: Option, +} + +/// The paragraph editor's text box. +pub(crate) fn input_id() -> egui::Id { + egui::Id::unique("edit-text-line-input") +} + +/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown +/// in every tab (they share the id). Once the page has `text` (the box opened on it, or formatting +/// applied it), that history is cleared: Undo in the box takes back only what was typed since, and +/// then reaches the document. Kept, it put back text from another paragraph, or the whole paragraph +/// as it was before a format change, into a box showing the page as it is now. `from` is the box's +/// own record of that; which document `doc` last used the history is kept beside it, since a box +/// in another tab on the same text would otherwise take that document's typing with its Redo. +/// When another document's box takes the text box, the box it leaves keeps its history (and +/// caret) aside, and gets it back on its return, so its typing can still be undone there. Each +/// box claims the text box when it is drawn, and Undo and Redo from the menu claim it first too, +/// since they can run before the box is drawn again after a tab switch. +pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcraft_engine::DocId, from: &mut Option, text: &str) { + let owner = id.with("typing-doc"); + let last = ctx.data(|d| d.get_temp::(owner)); + if from.as_deref() == Some(text) && last == Some(doc) { + return; + } + let aside = |doc: pdfcraft_engine::DocId| id.with(("typing-aside", doc)); + let aligns = id.with(ALIGN_HISTORY); + if let Some(left) = last.filter(|l| *l != doc) + && let Some(state) = egui::TextEdit::load_state(ctx, id) + { + // A copy of its own: a state's history is shared between its clones, and the next box + // clears it in place. The rows' alignments by text go with it. + let mut kept = egui::text_edit::TextEditState::default(); + kept.cursor = state.cursor; + kept.set_undoer(state.undoer()); + ctx.data_mut(|d| { + let rows = d.get_temp::(aligns).unwrap_or_default(); + d.insert_temp(aside(left), (kept, rows)); + }); + } + // Only the same box, still on the text it opened on, takes its history back. + let back = + ctx.data_mut(|d| d.remove_temp::<(egui::text_edit::TextEditState, AlignHistory)>(aside(doc))).filter(|_| from.as_deref() == Some(text)); + // A box new to the text box starts with nothing of the last one's: no history, caret, + // selection or rows' alignments by text. + let (state, rows) = back.unwrap_or_default(); + egui::TextEdit::store_state(ctx, id, state); + ctx.data_mut(|d| { + d.insert_temp(owner, doc); + d.insert_temp(aligns, rows); + }); + *from = Some(text.to_owned()); +} + +/// Each text a page box has shown through its own typing, with its rows' alignments then, oldest +/// last: egui's history keeps only the text and the caret. +type AlignHistory = Vec<(String, Vec>>)>; +const ALIGN_HISTORY: &str = "aligns-by-text"; +/// As many texts as egui's own history keeps. +const ALIGN_HISTORY_LEN: usize = 100; + +/// The page text box `id` went from `before` to `after` through its own typing (typing, Undo or +/// Redo), and each list of row alignments in `aligns` follows. A text the box showed before gets +/// back the alignments it had then, so Undo bringing back a deleted row brings back its +/// alignment; a new text follows the edit ([`pdfcraft_engine::paragraph_align::remap`]). An +/// alignment picked since makes the older texts' alignments stale, and they are dropped. +pub(crate) fn follow_typing( + ctx: &egui::Context, + id: egui::Id, + before: &str, + after: &str, + aligns: &mut [&mut Vec>], +) { + let now: Vec> = aligns.iter().map(|a| a.to_vec()).collect(); + // Where the edit left the caret, as a byte of `after`: it says which of two same rows changed. + let caret = egui::TextEdit::load_state(ctx, id) + .and_then(|s| s.cursor.char_range()) + .map(|r| after.char_indices().nth(r.primary.index.into()).map_or(after.len(), |(at, _)| at)); + let next = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_default::(id.with(ALIGN_HISTORY)); + if seen.iter().any(|(text, was)| text == before && *was != now) { + seen.clear(); + } + remember(seen, before, now.clone()); + let next = match seen.iter().rev().find(|(text, _)| text == after) { + Some((_, was)) if was.len() == now.len() => was.clone(), + _ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap_at(before, after, a, caret)).collect(), + }; + remember(seen, after, next.clone()); + next + }); + for (slot, a) in aligns.iter_mut().zip(next) { + **slot = a; + } +} + +fn remember(seen: &mut AlignHistory, text: &str, aligns: Vec>>) { + seen.retain(|(t, _)| t != text); + seen.push((text.to_owned(), aligns)); + if seen.len() > ALIGN_HISTORY_LEN { + seen.remove(0); + } +} + +/// Select `range` (bytes of `text`) in the page text box `id` again. egui keeps a box's selection +/// only while it has the focus, so a click on Format text drops it; given back as formatting +/// returns the focus, it stays shown, and a second choice reaches the same text. +pub(crate) fn reselect(ctx: &egui::Context, id: egui::Id, text: &str, range: &std::ops::Range) { + let chars = |at: usize| text.get(..at).map(|s| s.chars().count()); + let (Some(start), Some(end)) = (chars(range.start), chars(range.end)) else { return }; + if start < end { + let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(start), egui::text::CCursor::new(end)))); + egui::TextEdit::store_state(ctx, id, state); + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Extras { + pub underline: bool, + pub line_spacing: f64, + pub char_spacing: f64, + pub scale: f64, +} + +impl Default for Extras { + fn default() -> Self { + Extras { underline: false, line_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } + } +} + +impl LineEditor { + /// What Format text's font list shows: the chosen page font, a standard family picked + /// instead, or the paragraph's own font (its `/BaseFont` without the subset tag). + pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String { + if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) { + return face.clone(); + } + if self.look.family != self.look0.family || self.family_chosen { + return self.look.family.label().to_string(); + } + let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default(); + // "ABCDEF+Calibri" is a subset of Calibri. + let name = match base.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => base, + }; + if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } + } + + /// Back to the paragraph's own font: no page font chosen and the family it started with. + pub(crate) fn keep_source_font(&mut self) { + self.font_resource = None; + self.look.family = self.look0.family; + self.family_chosen = false; + } + + /// A standard family picked from the font list, replacing the paragraph's font even when it + /// is the family the paragraph's font name reads as. + pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) { + self.font_resource = None; + self.look.family = family; + self.family_chosen = true; + } + + pub(crate) fn has_changes(&self) -> bool { + self.changes_with(&self.text) + } + + /// Whether the paragraph with `text` in it differs from what was opened. + fn changes_with(&self, text: &str) -> bool { + text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen + } + + /// Extra width permitted by the editor. Source containers now remain fixed. + pub fn growth(&self) -> Option { + (self.max_width > self.rect.width()).then_some(self.max_width) + } + + /// Whether the edited text differs from the original (unsaved changes). + pub fn has_unsaved_text(&self) -> bool { + self.text != self.original + } + + /// The formatting the panel changed. + pub fn style(&self) -> pdfcraft_engine::BlockStyle { + let (l, o) = (&self.look, &self.look0); + let mut style = pdfcraft_engine::BlockStyle { + resource: self.font_resource.clone(), + family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), + size: (l.size != o.size).then_some(l.size), + color: (l.color != o.color).then_some(l.color), + // The whole paragraph's alignment when it was picked, or when the rows' own were + // dropped for it (the rows are where those put them, so leaving it out keeps them there). + align: (l.align != o.align || l.aligns.is_empty() && !o.aligns.is_empty()).then_some(l.align), + underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline), + line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0) + .then_some(self.extras.line_spacing), + char_spacing: (self.extras.char_spacing != self.extras0.char_spacing).then_some(self.extras.char_spacing), + scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale), + ..Default::default() + }; + // Rows aligned on their own, sent with every rewrite of the paragraph while it is open + // (a colour or size change too): nothing in the page records them, so a rewrite without + // them would undo them. Alone, they are sent only when they changed. + if l.aligns != o.aligns || self.text != self.original || style != pdfcraft_engine::BlockStyle::default() { + style.aligns = l.aligns.clone(); + } + if let Some(range) = &self.selection + && (style.family.is_some() + || style.resource.is_some() + || style.size.is_some() + || style.color.is_some() + || style.underline.is_some() + || style.char_spacing.is_some() + || style.scale.is_some()) + { + style.ranges.push(pdfcraft_engine::RangeStyle { + range: range.clone(), + family: style.family.take(), + resource: style.resource.take(), + size: style.size.take(), + color: style.color.take(), + underline: style.underline.take(), + char_spacing: style.char_spacing.take(), + scale: style.scale.take(), + }); + } + style + } + + /// After applying formatting: the paragraph now has it. The selection stays, as the box still + /// shows it, so a second alignment or format picked for it applies to the same rows. + pub fn applied(&mut self) { + self.font_resource = None; + self.family_chosen = false; + self.look0 = self.look.clone(); + self.extras0 = self.extras; + self.original = self.text.clone(); + self.selection = None; + // The page now has this text: the box's history restarts, even when the format left the + // text as it was, so a Redo from before can't put undone typing back on top of it. + self.typing_from = None; + self.focus = true; + } + + /// Take the shared text box for this paragraph ([`forget_typing_before`]): its own history, + /// or none once the page has its text. + pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId) { + forget_typing_before(ctx, input_id(), doc, &mut self.typing_from, &self.original); + } + + /// Adopt the rewritten paragraph's current geometry before the next overlay frame. + pub(crate) fn refresh_source(&mut self, block: &pdfcraft_engine::TextBlock, lines: Vec) { + self.source_rect = block.rect.map(|v| v as f32); + self.multiline = block.rows.len() > 1; + self.source = Some((block.clone(), lines)); + let before = std::mem::replace(&mut self.text, block.editor_text.clone()); + if self.text != before { + // A byte range of the old text: it may name other characters, or none, in the new. + self.selection = None; + } + self.follow_rows(&before); + self.original = self.text.clone(); + } + + pub fn selection_formatting(&self, sources: &[(String, String)]) -> SelectionFormatting { + let Some((block, lines)) = &self.source else { + return SelectionFormatting { + font_shown: self.font_shown(sources), + size: Some(self.look.size), + family: Some(self.look.family), + bold: Some(self.look.bold), + italic: Some(self.look.italic), + color: Some(self.look.color), + }; + }; + + let spans = block.edited_spans(&self.text); + let matching_lines: Vec<&pdfcraft_engine::TextLine> = match &self.selection { + Some(range) if range.start < range.end => { + spans.iter().filter(|(span, _)| span.start < range.end && span.end > range.start).filter_map(|(_, idx)| lines.get(*idx)).collect() + } + _ => spans.iter().filter_map(|(_, idx)| lines.get(*idx)).collect(), + }; + + if matching_lines.is_empty() { + return SelectionFormatting { + font_shown: self.font_shown(sources), + size: Some(self.look.size), + family: Some(self.look.family), + bold: Some(self.look.bold), + italic: Some(self.look.italic), + color: Some(self.look.color), + }; + } + + let line_font = |l: &pdfcraft_engine::TextLine| -> String { + if let Some((_, face)) = sources.iter().find(|(res, _)| res == &l.font) { + return face.clone(); + } + let base = l.base_font.as_str(); + let name = match base.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => base, + }; + if let Some((_, face)) = sources.iter().find(|(_, face)| face.as_str() == name) { + return face.clone(); + } + let look = look_of_line(l); + let lower = name.to_ascii_lowercase(); + if ["times", "serif", "roman", "courier", "helvetica", "arial"].iter().any(|s| lower.contains(s)) { + return look.family.label().to_string(); + } + if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } + }; + + let first_font = line_font(matching_lines[0]); + let same_font = matching_lines.iter().skip(1).all(|l| line_font(l) == first_font); + let font_shown = if same_font { + if self.font_resource.is_some() || self.look.family != self.look0.family || self.family_chosen { + self.font_shown(sources) + } else { + first_font + } + } else { + tl!("Mixed").to_string() + }; + + let first_size = (matching_lines[0].size * 10.0).round() / 10.0; + let same_size = matching_lines.iter().skip(1).all(|l| ((l.size * 10.0).round() / 10.0 - first_size).abs() < 0.05); + let size = if same_size { Some(first_size) } else { None }; + + let first_look = look_of_line(matching_lines[0]); + let same_family = matching_lines.iter().skip(1).all(|l| look_of_line(l).family == first_look.family); + let family = if same_family { Some(first_look.family) } else { None }; + let same_bold = matching_lines.iter().skip(1).all(|l| l.bold == matching_lines[0].bold); + let bold = if same_bold { Some(matching_lines[0].bold) } else { None }; + let same_italic = matching_lines.iter().skip(1).all(|l| l.italic == matching_lines[0].italic); + let italic = if same_italic { Some(matching_lines[0].italic) } else { None }; + let same_color = matching_lines.iter().skip(1).all(|l| l.color == matching_lines[0].color); + let color = if same_color { Some(matching_lines[0].color) } else { None }; + + SelectionFormatting { font_shown, size, family, bold, italic, color } + } + + pub(crate) fn sync_selection_look(&mut self, fmt: &SelectionFormatting) { + if let Some(sz) = fmt.size { + self.look.size = sz; + self.look0.size = sz; + } else { + if self.look.size <= 0.0 { + self.look.size = 12.0; + } + self.look0.size = self.look.size; + } + if let Some(f) = fmt.family { + self.look.family = f; + self.look0.family = f; + } + match fmt.bold { + Some(b) => { + self.look.bold = b; + self.look0.bold = b; + } + None => { + self.look.bold = false; + self.look0.bold = false; + } + } + match fmt.italic { + Some(it) => { + self.look.italic = it; + self.look0.italic = it; + } + None => { + self.look.italic = false; + self.look0.italic = false; + } + } + if let Some(c) = fmt.color { + self.look.color = c; + self.look0.color = c; + } + } + + /// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows + /// aligned on their own keep their alignment, and a row Undo brings back gets its own back. + pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) { + follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]); + } + + /// The text changed from `before`: rows aligned on their own keep their alignment. + pub(crate) fn follow_rows(&mut self, before: &str) { + for look in [&mut self.look, &mut self.look0] { + look.aligns = pdfcraft_engine::paragraph_align::remap(before, &self.text, &look.aligns); + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SelectionFormatting { + pub font_shown: String, + pub size: Option, + pub family: Option, + pub bold: Option, + pub italic: Option, + pub color: Option<[f64; 3]>, +} + +fn look_of_line(l: &pdfcraft_engine::TextLine) -> pdfcraft_engine::AddedText { + source_look(&l.base_font, l.size, l.color, l.bold, l.italic) +} + +/// What a drag on a paragraph box takes hold of. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Grip { + Move, + /// An edge (screen left or right), dragged to rewrap the paragraph. + Left, + Right, +} + +/// A paragraph box being dragged: moved, or (from a handle on its left or right edge) resized. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlockDrag { + page: usize, + block: usize, + /// A paragraph index belongs to the document generation where the press was resolved. + generation: u64, + /// Where the drag started (screen). + start: Pos2, + grip: Grip, +} + +/// How far an edge grip reaches either side of a box's edge (screen pixels). +const EDGE_REACH: f32 = 6.0; +/// How far outside an open paragraph's frame the pointer still means the frame (screen pixels): +/// the frame's Move cursor shows, and a press there takes hold of the box (`object_ui`). +pub(crate) const FRAME_REACH: f32 = 6.0; + +/// The rewrap handles drawn on a paragraph box's left and right edges (screen). +fn width_handles(b: Rect) -> [Rect; 2] { + let h = (b.height() * 0.5).clamp(14.0, 28.0); + [b.left(), b.right()].map(|x| Rect::from_center_size(Pos2::new(x, b.center().y), egui::vec2(7.0, h))) +} + +/// The edge of `b` that `p` grabs: anywhere along its height, within reach of the line. A narrow +/// box keeps its middle for moving. +fn edge_at(b: Rect, p: Pos2) -> Option { + if p.y < b.top() - EDGE_REACH || p.y > b.bottom() + EDGE_REACH { + return None; + } + let inside = EDGE_REACH.min(b.width() / 4.0); + let (dl, dr) = (p.x - b.left(), b.right() - p.x); + match (dl >= -EDGE_REACH && dl <= inside, dr >= -EDGE_REACH && dr <= inside) { + (true, true) if dl <= dr => Some(Grip::Left), + (_, true) => Some(Grip::Right), + (true, false) => Some(Grip::Left), + _ => None, + } +} + +/// The topmost box under `p` and what it grabs there; edges only on an upright page. +fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { + boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() { + Some(g) => Some((i, g)), + None => b.contains(p).then_some((i, Grip::Move)), + }) +} + +/// A paragraph's box on screen: its text rect (user space), with a little room around it. +fn paragraph_box(xf: &PageXform, info: &DocInfo, page: usize, rect: [f64; 4]) -> Rect { + xf.user_rect(info, page, rect.map(|v| v as f32)).expand(2.0) +} + +/// Whether `p` lies in the box of one of the paragraphs `lines` on `page`, its edge grips there +/// included, not beside it: where Add text acts on the page's own paragraphs. +pub(crate) fn over_paragraph(xf: &PageXform, info: &DocInfo, page: usize, lines: &[pdfcraft_engine::TextBlock], p: Pos2) -> bool { + lines.iter().any(|l| paragraph_box(xf, info, page, l.rect).contains(p)) +} + +/// The width handles need the page upright (or upside down): on a quarter-turned page the +/// screen's horizontal is the paragraph's vertical. +fn has_edges(info: &DocInfo, page: usize) -> bool { + info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0) +} + +/// What of the paragraphs `lines` lies under `p` on `page`: the one hit test of Edit text's +/// paragraphs, for the cursor and the press. A closed paragraph moves when dragged (the four-way +/// arrow says so; a click still opens it for typing) and its edge rewraps it, from a little way +/// outside the box (the handle is drawn half outside it, #2276); the open one takes the text +/// cursor. `active` is the paragraph open for typing on this page. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, lines: &[pdfcraft_engine::TextBlock], active: Option, p: Pos2) -> Option { + if !xf.rect.contains(p) { + return None; + } + let boxes: Vec = lines.iter().map(|l| paragraph_box(xf, info, page, l.rect)).collect(); + // Edges rewrap closed paragraphs only: around the open one lies its frame (`object_ui`). + let (block, grip) = grab_at(&boxes, p, has_edges(info, page) && active.is_none())?; + let paragraph = |part| Owner::Paragraph { block, part }; + Some(match (active, grip) { + (None, Grip::Left) => Hit::new(paragraph(ParagraphPart::Left), Layer::Handle, egui::CursorIcon::ResizeHorizontal), + (None, Grip::Right) => Hit::new(paragraph(ParagraphPart::Right), Layer::Handle, egui::CursorIcon::ResizeHorizontal), + (None, Grip::Move) => Hit::new(paragraph(ParagraphPart::Body), Layer::Text, egui::CursorIcon::Move), + (Some(open), _) if open == block => Hit::new(paragraph(ParagraphPart::Body), Layer::Editor, egui::CursorIcon::Text), + (Some(_), _) => Hit::new(paragraph(ParagraphPart::Body), Layer::Text, egui::CursorIcon::Text), + }) +} + +pub(crate) fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { + let p = &info.pages[page]; + let (a, b) = (xf.screen_to_view(r.min), xf.screen_to_view(r.max)); + let (u, v) = (p.view_to_user(a.0, a.1), p.view_to_user(b.0, b.1)); + [u[0].min(v[0]) as f64, u[1].min(v[1]) as f64, u[0].max(v[0]) as f64, u[1].max(v[1]) as f64] +} + +/// Open `block` on `page` in the inline editor. The box's geometry and source are read from the +/// `lines` handed in, so callers pass the document's current inventory. +fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], boxes: &[Rect], block: usize, view: &mut DocView) { + let PageToolContext { ui, xf, doc, page, .. } = cx; + view.pending_text_open = None; + let (Some(l), Some(b)) = (lines.get(block), boxes.get(block)) else { return }; + view.image_selection = None; + let source_lines = doc.text_lines(page); + let text = l.editor_text.clone(); + // Screen pixels per point, from the box's width. + let scale = (b.width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32); + // How far the editor box may grow: a single-line paragraph's rewrite grows to the + // page's right edge, a multi-line paragraph rewraps to its own width. + let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0; + let max_width = if l.rows.len() == 1 { (right - b.left()).max(b.width()) } else { b.width() }; + view.line_editor = Some(LineEditor { + selection: None, + font_resource: None, + page, + block, + text: text.clone(), + original: text, + rect: *b, + source_rect: l.rect.map(|v| v as f32), + multiline: l.rows.len() > 1, + source: Some((l.clone(), source_lines)), + fonts: install_source_fonts(ui.ctx(), doc, page), + overflow: false, + max_width, + size: (l.size as f32 * scale).clamp(8.0, 72.0), + focus: true, + had_focus: false, + look: look_of(l), + look0: look_of(l), + family_chosen: false, + extras: Extras::default(), + extras0: Extras::default(), + typing_from: None, + }); +} + +/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns +/// `true` when the pointer was used. +pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, allow_interaction: bool) -> bool { + let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; + let info = &doc.info; + if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { + view.block_drag = None; + return true; + } + let boxes: Vec = lines.iter().map(|l| paragraph_box(xf, info, page, l.rect)).collect(); + // Outlines stay on their page, like its text (#3061). + let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(xf.rect.expand(1.0))); + let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); + for (i, b) in boxes.iter().enumerate() { + if active != Some(i) { + painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + // A box a switching click asked for opens now, once the edit queued with that click has + // applied and `lines`/`boxes` are the document's new inventory (#2022). + if allow_interaction + && active.is_none() + && let Some((open_page, key)) = view.pending_text_open.clone() + && open_page == page + { + // The edit may have regrouped the page: open the paragraph that was clicked, wherever it + // is now, and none if it was merged into another (#2516). + match key.find(lines, 0) { + Some(block) => open_editor(cx, lines, &boxes, block, view), + None => view.pending_text_open = None, + } + } + if !allow_interaction { + return false; + } + let upright = has_edges(info, page); + let draw_handles = |b: Rect| { + for h in width_handles(b) { + painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + } + }; + // A drag in progress: the box follows the pointer (or the grabbed edge does); releasing applies it. + if let Some(d) = view.block_drag.filter(|d| d.page == page) + && let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block)) + { + let p = ptr.pos.unwrap_or(d.start); + let dx = p.x - d.start.x; + let preview = match d.grip { + Grip::Move => b.translate(p - d.start), + Grip::Left => Rect::from_min_max(Pos2::new((b.left() + dx).min(b.right() - 12.0), b.min.y), b.max), + Grip::Right => Rect::from_min_max(b.min, Pos2::new((b.right() + dx).max(b.left() + 12.0), b.max.y)), + }; + painter.rect_stroke(preview, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + let resizing = d.grip != Grip::Move; + if resizing { + draw_handles(preview); + } + view.cursor.claim(Layer::Drag, if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); + if resp.drag_stopped() || !ptr.any_down { + view.block_drag = None; + let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); + let edit = if resizing { + // The new width in user space: the box's change, added to the paragraph's own. A + // moved left side (in user space; on an upside-down page that's the screen's right + // edge) moves the paragraph with it. + let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]); + let shift = to[0] - from[0]; + let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }; + // `editor_text` has a newline for every visual row, so a wrap would come back as a + // hard break and a wider box would not reflow it. `reflow_text` rejoins a wrap + // and keeps a newline that was typed. + Edit::EditTextBlock { page, block: d.block, text: l.reflow_text.clone(), style } + } else { + // Translation must keep the selected source operators. Rewriting unchanged text + // would also remove unrelated paragraphs that now overlap this one (#594). + Edit::MoveObjects { + page, + objects: vec![ObjectTarget { kind: ObjectKind::Text, index: d.block }], + offset: [to[0] - from[0], to[1] - from[1]], + } + }; + if preview != b { + view.pending_edit = Some(edit); + } + } + return true; + } + // Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it. + // What it grabs is what the press was on ([`hit`]), not what is under the pointer now: egui + // only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a + // quick flick has left the edge by then. + let owner = match view.router.owner(page) { + Some(Owner::Paragraph { block, part }) => Some((block, part)), + _ => None, + }; + if resp.drag_started() + && active.is_none() + && let Some(o) = ptr.origin + && let Some((block, part)) = owner + { + let grip = match part { + ParagraphPart::Body => Grip::Move, + ParagraphPart::Left => Grip::Left, + ParagraphPart::Right => Grip::Right, + }; + view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); + return true; + } + let Some(p) = ptr.within(xf.rect) else { return false }; + // The paragraph under the pointer: outlined, with its width handles when closed. + let Some((hovered, _)) = grab_at(&boxes, p, upright) else { return false }; + if active != Some(hovered) { + painter.rect_stroke(boxes[hovered], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + } + if upright && active.is_none() { + draw_handles(boxes[hovered]); + } + // A click opens the paragraph the press was on. + let Some((hit, _)) = owner else { return true }; + if resp.clicked() { + view.image_selection = None; + // A click that moves the editor to another paragraph applies the open one first, so its + // typed text is not dropped with the switched-away editor (#2022). A click on the open + // box's own frame, which the editor's overlay does not cover, keeps it open instead. + if let Some(open) = view.line_editor.as_ref().filter(|e| e.has_changes()) { + if open.page == page && open.block == hit { + return true; + } + if let Some(old) = view.line_editor.take() { + let style = old.style(); + if old.text != old.original || style != pdfcraft_engine::BlockStyle::default() { + view.applied_editor = Some(old.clone()); + view.pending_edit = Some(Edit::EditTextBlock { page: old.page, block: old.block, text: old.text, style }); + } + } + // The clicked box opens next frame, from the document with that edit applied. + view.pending_text_open = lines.get(hit).map(|l| (page, pdfcraft_engine::BlockKey::of(l))); + return true; + } + open_editor(cx, lines, &boxes, hit, view); + } + true +} + +/// The inline editor; returns the edit once the text is applied. +pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { + let view_id = view.id; + // Clicks outside the document (the Format text panel) keep the paragraph open, as does the + // keyboard moving there. Where the click landed is `interact_pos`: `latest_pos` is gone on the + // frame a tap lifts. + let away = |p: egui::Pos2| !view.viewport_rect().contains(p); + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || focus_pos(ctx).is_some_and(away); + let page = view.line_editor.as_ref()?.page; + let xf = view.page_xform(page)?; + let hold = view.objects.hold_editor; + // An OCR word is invisible text over a scan: the textless background still shows the scanned + // word, so the editor covers it, as the applied edit will. + let background_ready = view + .line_editor + .as_ref() + .is_some_and(|ed| view.text_background.ready(ed.page, ed.block) && !ed.source.as_ref().is_some_and(|(b, _)| b.invisible)); + // The measured extent of this paragraph's draft: a click straight from another paragraph + // opens this one after the frame's measuring, which was the other one's. + let draft_rect = view + .line_editor + .as_ref() + .and_then(|ed| view.text_background.draft_rect_for(ed.page, ed.block)) + .map(|r| xf.user_rect(info, page, r.map(|v| v as f32))); + // The editor draws on its page only, as the page itself does: a paragraph running past the + // page's edge (a converter relying on the page clip) must not cover the next page or the + // tool bars (#3061). + let viewport = view.viewport_rect(); + let page_clip = xf.rect.intersect(viewport); + let ed = view.line_editor.as_mut()?; + if hold { + ed.focus = true; + } + // Reproject the source box every frame. The page may have been zoomed, scrolled or rotated + // while the format panel was open. + // A font definition becomes active at the start of the next egui pass. Keep the source + // raster visible until then instead of flashing an unrelated interface face. + if !ctx.fonts(|f| ed.fonts.values().all(|name| f.definitions().families.contains_key(&FontFamily::Name(name.clone().into())))) { + ctx.request_repaint(); + return None; + } + ed.rect = xf.user_rect(info, ed.page, ed.source_rect); + ed.max_width = ed.rect.width(); + let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); + ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); + // egui draws the caret at least one row of this font tall. Each row already has its source + // row's height, so the font stays below every row (half the region's smallest size): on a + // 12 pt row under a 24 pt title the caret is as tall as the row, not the title. + let smallest = + ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); + let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); + let text_color = color32(ed.look.color); + let block = ed.block; + let mut done = None; + let mut reveal = false; + let mut look = None; + // No fade-in: while it faded, the page's own copy of the text showed through the cover. + // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or + // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as + // the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::unique("edit-text-line")) + .order(egui::Order::Foreground) + .fade_in(false) + .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) + .constrain_to(viewport) + .constrain(false) + .show(ctx, |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(page_clip)); + // The editor opens at the source size. Once text changes, show the real draft + // extent: single rows grow toward the page edge, paragraphs rewrap at their + // source width, and the visible frame grows/shrinks vertically with their rows. + // An OCR phrase (several stretched words on one scanned line) also grows rightwards. + let single_run = ed.source.as_ref().is_some_and(|(block, _)| block.rows.len() == 1 || block.invisible); + let width = if !ed.multiline && single_run && !ed.text.contains('\n') && ed.has_changes() { + let job = editor_job(ui, ed, &ed.text, scale, f32::INFINITY); + let natural = ui.fonts_mut(|f| f.layout_job(job)).size().x; + natural.max(ed.size * 0.4).min((xf.rect.right() - ed.rect.left() - 36.0 * scale).max(ed.rect.width())) + } else { + ed.rect.width().max(1.0) + }; + ui.set_width(width); + let measured = editor_galley(ui, ed, &ed.text, scale, width); + ed.overflow = ed.has_changes() && (measured.rect.width() > ed.rect.width() + 1.5 || measured.rect.height() > ed.rect.height() + 1.5); + let visible = Rect::from_min_size(ed.rect.min, measured.rect.size().max(egui::vec2(1.0, 1.0))); + // Unsupported source programs use an interface fallback whose glyph advances + // can differ from the PDF's. Keep every visible row inside the growing frame. + let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); + ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + // On the frame, a press takes hold of the box (a click selects it, a drag moves it): + // the pointer says so there, and reads as text inside. + if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && ui.clip_rect().contains(p) + && live.expand(2.0 + FRAME_REACH).contains(p) + && !live.expand(2.0).contains(p) + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + // White, unless the text is too pale to read on white (white text from a coloured + // banner, #913): then a dark box, with a caret that shows on it. + let fill = if background_ready { Color32::TRANSPARENT } else { editor_fill(text_color) }; + // An unchanged paragraph is the page's own text: the editor draws only its frame and + // caret over it. Once it changes, the textless page goes under the editor's text or, + // until that background is ready, a cover over the entire source box, including lines + // the draft no longer occupies. Shrinking the text must never expose a copy. Both are + // decided after this frame's typing, in places kept under the editor's text. + let cover = ui.painter().add(egui::Shape::Noop); + let unchanged = !ed.has_changes(); + egui::Frame::NONE.show(ui, |ui| { + // A textless PDF preview, or the page itself, keeps its real background through + // the transparent editor. The temporary opaque cover uses the shared caret-contrast rule. + ui.visuals_mut().text_cursor.stroke.color = if background_ready || unchanged { + if luminance(text_color) < 0.5 { Color32::BLACK } else { Color32::WHITE } + } else { + caret_color_on(fill) + }; + let rows = 1; + let snapshot = ed.clone(); + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width); + if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } + }; + let before = ed.text.clone(); + let input = input_id(); + ed.claim_typing(ui.ctx(), view_id); + let output = egui::TextEdit::multiline(&mut ed.text) + .id(input) + .font(font.clone()) + .margin(egui::Margin::ZERO) + .layouter(&mut layouter) + .text_color(text_color) + .frame(egui::Frame::NONE) + .desired_width(width) + .desired_rows(rows) + .show(ui); + // The editor paints over the page with no visible label; name it for assistive tech. + output.response.response.clone().accessible_name(tl!("Text editing line")); + if ed.has_changes() { + if background_ready { + reveal = true; + } else { + ui.painter().set(cover, egui::Shape::rect_filled(ed.rect.expand(1.0).union(output.response.rect), CornerRadius::ZERO, fill)); + } + } + look = Some((Arc::clone(&output.galley), output.galley_pos)); + if let Some(cursor) = output.cursor_range { + let selected = cursor.as_sorted_char_range(); + let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at); + ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into())); + } + let r = output.response; + if r.changed() { + ed.follow_typed_rows(ctx, &before); + // The next pass measures the updated draft with the Apply/Save rewrite. + ctx.request_repaint(); + } + if ed.focus { + r.request_focus(); + if let Some(range) = &ed.selection { + reselect(ui.ctx(), input, &ed.text, range); + } + ed.focus = false; + } + let lost = lost_focus_once(&r, &mut ed.had_focus); + let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); + if esc { + done = Some(false); + } else if apply || (lost && !outside && !hold) { + done = Some(true); + } + }); + }); + if reveal { + view.text_background.reveal(); + } + if let Some((galley, pos)) = look { + view.text_background.remember(&xf, page, block, galley, pos, text_color); + } + match done { + Some(apply) => { + let ed = view.line_editor.take()?; + if !apply { + // Esc leaves the text but not the paragraph: its box is selected, as in Acrobat, + // so Delete removes it without aiming for the frame. The draft is discarded. + view.objects.select_paragraph_next(ed.page, ed.block); + return None; + } + view.applied_editor = Some(ed.clone()); + finished_edit(ed) + } + None => None, + } +} + +/// The edit a closed paragraph editor applies, or `None` when nothing in it changed. +pub(crate) fn finished_edit(ed: LineEditor) -> Option { + let style = ed.style(); + (ed.text != ed.original || style != pdfcraft_engine::BlockStyle::default()).then_some(Edit::EditTextBlock { + page: ed.page, + block: ed.block, + text: ed.text, + style, + }) +} + +/// Where the keyboard is: the centre of the widget that has it. A text box that loses it to a +/// widget reached by Tab or a screen reader (the Format text panel's controls) judges where it +/// went by this, as it judges a click by where the click landed. +pub(crate) fn focus_pos(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).map(|r| r.rect.center()) +} + +/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at +/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second +/// frame as well, and an editor that judges a click away by where the click landed must judge it +/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends +/// `PointerGone` with every touch release) and a mouse may have moved on. +pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { + let has = r.has_focus(); + std::mem::replace(had, has) && !has +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Formatting applied restarts the rows' alignments by text with the box's history: a Redo + /// past the format can't bring back an alignment from before it (FredApps on bfc6f8b1). + #[test] + fn formatting_applied_restarts_the_alignments_by_text() { + use pdfcraft_engine::{DocId, TextAlign::Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::unique("box")); + let mut from = None; + forget_typing_before(&ctx, id, DocId(1), &mut from, "A\nB"); + let mut aligns = vec![None, Some(Right)]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); + // Formatting applied: the page now has "A", and the box's record of its text is reset. + from = None; + forget_typing_before(&ctx, id, DocId(1), &mut from, "A"); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); + assert_eq!(aligns, pdfcraft_engine::paragraph_align::remap("A", "A\nB", &[None]), "the row before the format doesn't come back aligned"); + } + + /// The rows' alignments by text go aside with the box's history when another tab's box takes + /// the text box, and come back with it: Undo after the trip still restores a deleted row's + /// alignment, while the other tab's box starts with none. + #[test] + fn alignments_by_text_survive_a_trip_to_another_tab() { + use pdfcraft_engine::{DocId, TextAlign::Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::unique("box")); + let (mut a_from, mut b_from) = (None, None); + forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); + let mut aligns = vec![None, Some(Right)]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); + forget_typing_before(&ctx, id, DocId(2), &mut b_from, "A\nB"); + let mut other = vec![None, None]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut other]); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut other]); + assert_eq!(other, [None, None], "tab B's box gets back its own alignments, not tab A's"); + forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); + assert_eq!(aligns, [None, Some(Right)], "back in tab A, Undo brings the row back right-aligned"); + } + + /// Undo back to a text the box showed brings its rows' alignments back, unless an alignment + /// was picked since: then the older alignments are stale, and the edit is followed instead, so + /// Undo never takes back the pick. + #[test] + fn typing_history_restores_alignments_unless_one_was_picked_since() { + use pdfcraft_engine::TextAlign::{Center, Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::unique("box")); + let mut aligns = vec![None, Some(Center), Some(Right)]; + follow_typing( + &ctx, + id, + "A +B +C", + "A +C", + &mut [&mut aligns], + ); + assert_eq!(aligns, [None, Some(Right)], "the deleted row's entry goes"); + follow_typing( + &ctx, + id, + "A +C", + "A +B +C", + &mut [&mut aligns], + ); + assert_eq!(aligns, [None, Some(Center), Some(Right)], "Undo brings it back"); + follow_typing( + &ctx, + id, + "A +B +C", + "A +C", + &mut [&mut aligns], + ); + // An alignment picked for the first row, with no typing. + aligns = vec![Some(Right), Some(Right)]; + let picked = aligns.clone(); + follow_typing( + &ctx, + id, + "A +C", + "A +B +C", + &mut [&mut aligns], + ); + assert_eq!( + aligns, + pdfcraft_engine::paragraph_align::remap( + "A +C", "A +B +C", &picked + ), + "the pick stays" + ); + } + + #[test] + fn the_rotate_knob_moves_below_when_there_is_no_room_above() { + let view = Rect::from_min_max(Pos2::new(0.0, 100.0), Pos2::new(500.0, 500.0)); + let roomy = Rect::from_min_max(Pos2::new(100.0, 200.0), Pos2::new(200.0, 250.0)); + assert_eq!(rotate_knob(roomy, view).0, Pos2::new(150.0, 178.0)); + let at_top = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0)); + let (knob, stem) = rotate_knob(at_top, view); + assert_eq!((knob, stem), (Pos2::new(150.0, 172.0), Pos2::new(150.0, 150.0))); + } + + #[test] + fn the_rotate_knob_measures_clockwise_degrees_from_straight_up() { + let c = Pos2::new(100.0, 100.0); + let at = |x: f32, y: f32| turn_degrees(c, Pos2::new(x, y), false); + assert!(at(100.0, 0.0).abs() < 1e-3); + assert!((at(200.0, 100.0) - 90.0).abs() < 1e-3, "right is a clockwise quarter"); + assert!((at(0.0, 100.0) + 90.0).abs() < 1e-3); + assert!((at(100.0, 200.0).abs() - 180.0).abs() < 1e-3); + // Shift rounds to 15 degrees; the centre and non-finite points turn nothing. + assert_eq!(turn_degrees(c, Pos2::new(180.0, 50.0), true) % 15.0, 0.0); + assert_eq!(at(101.0, 101.0), 0.0); + assert_eq!(at(f32::NAN, 5.0), 0.0); + } + + /// An app with one text document open, and an editor on its first paragraph. + fn preview_app() -> (crate::PdfCraftApp, LineEditor) { + let mut app = crate::PdfCraftApp::new(); + let bytes = app.session.create_from_text("Preview", "Preview text").unwrap(); + app.open_bytes("preview.pdf", None, bytes.as_ref().clone()).unwrap(); + let doc = app.session.get(app.views[0].id).unwrap(); + let block = doc.text_blocks(0).remove(0); + let ed = LineEditor { + selection: None, + font_resource: None, + page: 0, + block: 0, + text: block.text.clone(), + original: block.text.clone(), + rect: Rect::NOTHING, + source_rect: block.rect.map(|v| v as f32), + multiline: block.rows.len() > 1, + source: None, + fonts: BTreeMap::new(), + overflow: false, + max_width: 400.0, + size: block.size as f32, + focus: true, + had_focus: false, + look: look_of(&block), + look0: look_of(&block), + family_chosen: false, + extras: Extras::default(), + extras0: Extras::default(), + typing_from: None, + }; + (app, ed) + } + + #[test] + fn picking_the_family_the_font_name_reads_as_still_changes_the_font() { + // A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave + // `look` equal to what the paragraph had, so no family change was sent and Calibri stayed. + let (_app, mut ed) = preview_app(); + let family = ed.look0.family; + assert_eq!(ed.style().family, None, "nothing chosen yet"); + ed.choose_family(family); + assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic))); + assert!(ed.has_changes()); + assert_eq!(ed.font_shown(&[]), family.label()); + ed.keep_source_font(); + assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice"); + ed.choose_family(family); + ed.applied(); + assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now"); + } + + #[test] + fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { + // Loading a font used to only register it on the page: the paragraph kept its source + // face until the font was found again in the document-font menu (#2591). + let (mut app, ed) = preview_app(); + app.views[0].line_editor = Some(ed); + let inter = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]); + let doc = app.session.get(app.views[0].id).unwrap(); + let lines = doc.text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text"); + assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); + } + + #[test] + fn save_applies_the_paragraph_open_in_the_editor() { + // Save wrote the file without the paragraph still open in the editor (#3496). + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + app.views[0].line_editor = Some(ed); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-paragraph-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned()); + + assert!(app.save_active(crate::SaveTarget::InPlace)); + assert!(app.views[0].line_editor.is_none()); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_refused_text_box_does_not_lose_the_open_paragraph() { + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + app.views[0].line_editor = Some(ed); + let draft = crate::content_ui::TextDraft { + page: 9, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style: crate::content_ui::default_style(), + focus: true, + selection: None, + typing_from: None, + }; + app.views[0].content.draft = Some(draft.clone()); + + assert!(!app.commit_typing_in(app.views[0].id)); + assert_eq!(app.views[0].content.draft, Some(draft), "the refused text box stays open"); + assert!(app.views[0].line_editor.is_none(), "the paragraph was applied"); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); + } + + #[test] + fn a_refused_paragraph_stays_open_with_its_text() { + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + ed.page = 9; + app.views[0].line_editor = Some(ed.clone()); + + assert!(!app.commit_typing_in(app.views[0].id)); + assert_eq!(app.views[0].line_editor, Some(ed), "the paragraph keeps its unsaved text"); + } + + #[test] + fn text_background_is_cached_without_editing_and_released_on_cancel() { + let (app, ed) = preview_app(); + let doc = app.session.get(app.views[0].id).unwrap(); + let source = doc.bytes.clone(); + let generation = doc.edit_generation(); + let ctx = egui::Context::default(); + let mut background = TextBackground::default(); + for _ in 0..100 { + background.prepare(&ctx, doc, Some(&ed), None, 1.0); + if background.ready(0, 0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(background.ready(0, 0)); + assert!(!background.pending()); + let texture = background.texture.as_ref().unwrap().id(); + background.prepare(&ctx, doc, Some(&ed), None, 1.0); + assert_eq!(background.texture.as_ref().unwrap().id(), texture); + assert_eq!(doc.bytes, source); + assert_eq!(doc.edit_generation(), generation); + assert!(!doc.dirty && doc.can_undo().is_none()); + background.prepare(&ctx, doc, None, None, 1.0); + assert!(background.texture.is_none() && background.renderer.is_none()); + } + + #[test] + fn the_editing_background_stays_sharp_on_a_zoomed_page() { + // While a paragraph is open, the page around it is drawn from this one raster: it is + // asked for at the tiling threshold, not the 2048-pixel backdrop side, which went soft + // beyond about 1.2x on a Retina display. + let (app, ed) = preview_app(); + let doc = app.session.get(app.views[0].id).unwrap(); + let page = &doc.info.pages[0]; + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }); + let mut background = TextBackground::default(); + background.prepare(&ctx, doc, Some(&ed), None, 8.0); + let expected = 8.0f32.min(crate::canvas::TILE_THRESHOLD / page.width.max(page.height)); + assert!(expected > crate::canvas::BASE_SIDE / page.width.max(page.height), "the test page needs the larger side"); + assert_eq!(background.tag, Some((expected * 1000.0) as u64)); + // A GPU that can't hold such a texture caps it. + ctx.end_pass().textures_delta.clear(); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(2048), ..Default::default() }); + let mut small = TextBackground::default(); + small.prepare(&ctx, doc, Some(&ed), None, 8.0); + assert_eq!(small.tag, Some(((2048.0 / page.width.max(page.height)) * 1000.0) as u64)); + ctx.end_pass().textures_delta.clear(); + } + + #[test] + fn pale_text_is_edited_on_a_dark_box() { + // #913: white text from a coloured banner was typed onto a white box and vanished. + for pale in [Color32::WHITE, Color32::from_rgb(0xFF, 0xFF, 0x00), Color32::from_gray(0xE0), Color32::from_rgb(0xFF, 0xA5, 0x00)] { + assert_eq!(editor_fill(pale), DARK_EDITOR_FILL, "{pale:?}"); + assert!(contrast(pale, editor_fill(pale)) >= 4.5, "{pale:?} is readable"); + } + // Ordinary dark or saturated text keeps the white box. + for dark in [Color32::BLACK, Color32::from_rgb(0x1F, 0x2A, 0x6B), Color32::from_rgb(0xC0, 0x00, 0x00), Color32::from_gray(0x60)] { + assert_eq!(editor_fill(dark), Color32::WHITE, "{dark:?}"); + } + } + + #[test] + fn caret_contrasts_with_its_fill() { + assert_eq!(caret_color_on(Color32::WHITE), Color32::BLACK); + assert_eq!(caret_color_on(Color32::from_rgb(0xFF, 0xFF, 0xF4)), Color32::BLACK); + assert_eq!(caret_color_on(Color32::BLACK), Color32::WHITE); + assert_eq!(caret_color_on(DARK_EDITOR_FILL), Color32::WHITE); + } + + #[test] + fn bullet_list_toggles_and_preserves_content() { + let mut text = "First line\nSecond line\n\nThird line".to_string(); + assert!(!is_bulleted(&text)); + assert!(toggle_bullet_list(&mut text)); + assert!(is_bulleted(&text)); + assert_eq!(text, "• First line\n• Second line\n\n• Third line"); + + // Toggling again removes bullets + assert!(toggle_bullet_list(&mut text)); + assert!(!is_bulleted(&text)); + assert_eq!(text, "First line\nSecond line\n\nThird line"); + } + + #[test] + fn numbered_list_toggles_and_preserves_content() { + let mut text = "Item A\nItem B\n\nItem C".to_string(); + assert!(!is_numbered(&text)); + assert!(toggle_numbered_list(&mut text)); + assert!(is_numbered(&text)); + assert_eq!(text, "1. Item A\n2. Item B\n\n3. Item C"); + + // Toggling again removes numbering + assert!(toggle_numbered_list(&mut text)); + assert!(!is_numbered(&text)); + assert_eq!(text, "Item A\nItem B\n\nItem C"); + } + + #[test] + fn superscript_and_subscript_conversion() { + let mut ed = LineEditor { + page: 0, + block: 0, + text: "E = mc2".to_string(), + original: "E = mc2".to_string(), + rect: egui::Rect::ZERO, + source_rect: [0.0; 4], + max_width: 100.0, + overflow: false, + selection: Some(6..7), // selecting '2' + font_resource: None, + fonts: std::collections::BTreeMap::new(), + source: None, + multiline: false, + size: 12.0, + family_chosen: false, + focus: true, + had_focus: false, + look: pdfcraft_engine::AddedText::default(), + look0: pdfcraft_engine::AddedText::default(), + extras: Extras::default(), + extras0: Extras::default(), + typing_from: None, + }; + + assert!(toggle_superscript(&mut ed)); + assert_eq!(ed.text, "E = mc²"); + // Toggling superscript again returns to normal '2' + assert!(toggle_superscript(&mut ed)); + assert_eq!(ed.text, "E = mc2"); + + // Test subscript on H2O + ed.text = "H2O".to_string(); + ed.selection = Some(1..2); // selecting '2' + assert!(toggle_subscript(&mut ed)); + assert_eq!(ed.text, "H₂O"); + assert!(toggle_subscript(&mut ed)); + assert_eq!(ed.text, "H2O"); + } + + #[test] + fn value_based_line_spacing_in_style() { + let ed = LineEditor { + page: 0, + block: 0, + text: "Paragraph text".to_string(), + original: "Paragraph text".to_string(), + rect: egui::Rect::ZERO, + source_rect: [0.0; 4], + max_width: 100.0, + overflow: false, + selection: None, + font_resource: None, + fonts: std::collections::BTreeMap::new(), + source: None, + multiline: false, + size: 12.0, + family_chosen: false, + focus: true, + had_focus: false, + look: pdfcraft_engine::AddedText::default(), + look0: pdfcraft_engine::AddedText::default(), + extras: Extras { line_spacing: 1.33, underline: true, ..Default::default() }, + extras0: Extras::default(), + typing_from: None, + }; + let style = ed.style(); + assert_eq!(style.line_spacing, Some(1.33)); + assert_eq!(style.underline, Some(true)); + } +} diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index fab0486534..7e6eb3c32d 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -1,1252 +1,1242 @@ -use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, pos2, user_box}; -use crate::canvas::{ImageObb, PainterExt}; -use crate::image_engine::{CROP_ACCENT, HandleId, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds}; -use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner, PicturePart}; - -/// What the pointer and the page are, for the image tools on one page. -#[derive(Clone, Copy)] -pub(crate) struct ImageCx<'a> { - pub ui: &'a egui::Ui, - pub resp: &'a egui::Response, - pub xf: &'a PageXform, - pub ptr: &'a PagePointer, - pub page: usize, - pub info: &'a DocInfo, - pub allow_interaction: bool, -} - -/// The 8 resize handles on an image (4 corners + 4 edge centers). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ImageHandle { - TopLeft, - TopCenter, - TopRight, - RightCenter, - BottomRight, - BottomCenter, - BottomLeft, - LeftCenter, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DragTarget { - Move, - Handle(ImageHandle), - CropHandle(ImageHandle), - Rotate, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ImageDrag { - pub start: Pos2, - pub target: DragTarget, - pub initial_rect: Rect, - pub initial_matrix: [f64; 6], -} - -/// A selected page image, and what the pointer is doing to it. -#[derive(Clone, Debug, PartialEq)] -pub struct ImageSelection { - pub page: usize, - pub index: usize, - /// Dragging in progress. - pub drag: Option, - /// Crop mode: the box kept so far. - pub crop: Option, - pub crop_mode: bool, - pub crop_rect: Option, - /// The drag is turning the picture by its rotate handle (the `drag` start is then unused). - pub turning: bool, - /// A move or resize applied on release: the frame it had and the one it got, in the page's - /// 0..1 coordinates, while the page raster still has the picture where it was. - pub settled: Option<(Rect, Rect)>, -} - -impl ImageSelection { - /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. - pub(crate) fn cancel_pointer_gesture(&mut self) { - self.drag = None; - self.turning = false; - if let Some(crop) = &mut self.crop - && let Some((_, _, before)) = crop.drag.take() - { - crop.keep = before; - } - } - - pub(crate) fn dragging(&self) -> bool { - self.drag.is_some() - } -} - -/// Cropping a page image: the handles trim instead of resizing, the part that will go is -/// dimmed, Enter or Done applies and Esc or Cancel leaves it. -#[derive(Clone, Debug, PartialEq)] -pub struct CropMode { - /// The box to keep (user space or normalized UV). - pub keep: [f64; 4], - /// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then. - pub(crate) drag: Option<(usize, Pos2, [f64; 4])>, -} - -impl CropMode { - pub fn new(keep: [f64; 4]) -> Self { - Self { keep, drag: None } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct Matrix(pub(crate) [f64; 6]); - -impl Matrix { - pub(crate) fn apply(&self, x: f64, y: f64) -> (f64, f64) { - let [a, b, c, d, e, f] = self.0; - (a * x + c * y + e, b * x + d * y + f) - } - - pub(crate) fn invert(&self) -> Option { - let [a, b, c, d, e, f] = self.0; - let det = a * d - b * c; - if det.abs() < 1e-12 || !det.is_finite() { - return None; - } - let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)])) - } - - pub(crate) fn then(&self, then: &Matrix) -> Matrix { - let [a, b, c, d, e, f] = self.0; - let [a2, b2, c2, d2, e2, f2] = then.0; - Matrix([a * a2 + b * c2, a * b2 + b * d2, c * a2 + d * c2, c * b2 + d * d2, e * a2 + f * c2 + e2, e * b2 + f * d2 + f2]) - } -} - -pub(crate) fn user_to_screen(xf: &PageXform, info: &DocInfo, page: usize, x: f64, y: f64) -> Pos2 { - let Some(p) = info.pages.get(page) else { return Pos2::ZERO }; - let v = p.user_to_view(x as f32, y as f32); - xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph) -} - -fn screen_to_user(xf: &PageXform, info: &DocInfo, page: usize, pt: Pos2) -> (f64, f64) { - let (vx, vy) = xf.screen_to_view(pt); - let Some(p) = info.pages.get(page) else { return (0.0, 0.0) }; - let u = p.view_to_user(vx, vy); - (f64::from(u[0]), f64::from(u[1])) -} - -pub(crate) fn quad_contains(quad: [Pos2; 4], p: Pos2) -> bool { - if !p.x.is_finite() || !p.y.is_finite() || quad.iter().any(|pt| !pt.x.is_finite() || !pt.y.is_finite()) { - return false; - } - let mut sign = 0; - for i in 0..4 { - let p1 = quad[i]; - let p2 = quad[(i + 1) % 4]; - let cross = (p2.x - p1.x) * (p.y - p1.y) - (p2.y - p1.y) * (p.x - p1.x); - if cross.abs() > 1e-4 { - let s = if cross > 0.0 { 1 } else { -1 }; - if sign == 0 { - sign = s; - } else if sign != s { - return false; - } - } - } - sign != 0 -} - -/// The screen positions of the crop's eight handles: corners, then the top, right, bottom and left edge midpoints. -fn crop_handles(k: Rect) -> [Pos2; 8] { - [k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()] -} - -/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` -/// (the pointer and keys are the crop's while it lasts). +use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, pos2, user_box}; +use crate::canvas::{ImageObb, PainterExt}; +use crate::image_engine::{CROP_ACCENT, HandleId, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds}; +use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner, PicturePart}; + +/// What the pointer and the page are, for the image tools on one page. +#[derive(Clone, Copy)] +pub(crate) struct ImageCx<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub info: &'a DocInfo, + pub allow_interaction: bool, +} + +/// The 8 resize handles on an image (4 corners + 4 edge centers). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageHandle { + TopLeft, + TopCenter, + TopRight, + RightCenter, + BottomRight, + BottomCenter, + BottomLeft, + LeftCenter, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragTarget { + Move, + Handle(ImageHandle), + CropHandle(ImageHandle), + Rotate, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImageDrag { + pub start: Pos2, + pub target: DragTarget, + pub initial_rect: Rect, + pub initial_matrix: [f64; 6], +} + +/// A selected page image, and what the pointer is doing to it. +#[derive(Clone, Debug, PartialEq)] +pub struct ImageSelection { + pub page: usize, + pub index: usize, + /// Dragging in progress. + pub drag: Option, + /// Crop mode: the box kept so far. + pub crop: Option, + pub crop_mode: bool, + pub crop_rect: Option, + /// The drag is turning the picture by its rotate handle (the `drag` start is then unused). + pub turning: bool, + /// A move or resize applied on release: the frame it had and the one it got, in the page's + /// 0..1 coordinates, while the page raster still has the picture where it was. + pub settled: Option<(Rect, Rect)>, +} + +impl ImageSelection { + /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.drag = None; + self.turning = false; + if let Some(crop) = &mut self.crop + && let Some((_, _, before)) = crop.drag.take() + { + crop.keep = before; + } + } + + pub(crate) fn dragging(&self) -> bool { + self.drag.is_some() + } +} + +/// Cropping a page image: the handles trim instead of resizing, the part that will go is +/// dimmed, Enter or Done applies and Esc or Cancel leaves it. +#[derive(Clone, Debug, PartialEq)] +pub struct CropMode { + /// The box to keep (user space or normalized UV). + pub keep: [f64; 4], + /// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then. + pub(crate) drag: Option<(usize, Pos2, [f64; 4])>, +} + +impl CropMode { + pub fn new(keep: [f64; 4]) -> Self { + Self { keep, drag: None } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Matrix(pub(crate) [f64; 6]); + +impl Matrix { + pub(crate) fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let [a, b, c, d, e, f] = self.0; + (a * x + c * y + e, b * x + d * y + f) + } + + pub(crate) fn invert(&self) -> Option { + let [a, b, c, d, e, f] = self.0; + let det = a * d - b * c; + if det.abs() < 1e-12 || !det.is_finite() { + return None; + } + let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); + Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)])) + } + + pub(crate) fn then(&self, then: &Matrix) -> Matrix { + let [a, b, c, d, e, f] = self.0; + let [a2, b2, c2, d2, e2, f2] = then.0; + Matrix([a * a2 + b * c2, a * b2 + b * d2, c * a2 + d * c2, c * b2 + d * d2, e * a2 + f * c2 + e2, e * b2 + f * d2 + f2]) + } +} + +pub(crate) fn user_to_screen(xf: &PageXform, info: &DocInfo, page: usize, x: f64, y: f64) -> Pos2 { + let Some(p) = info.pages.get(page) else { return Pos2::ZERO }; + let v = p.user_to_view(x as f32, y as f32); + xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph) +} + +fn screen_to_user(xf: &PageXform, info: &DocInfo, page: usize, pt: Pos2) -> (f64, f64) { + let (vx, vy) = xf.screen_to_view(pt); + let Some(p) = info.pages.get(page) else { return (0.0, 0.0) }; + let u = p.view_to_user(vx, vy); + (f64::from(u[0]), f64::from(u[1])) +} + +pub(crate) fn quad_contains(quad: [Pos2; 4], p: Pos2) -> bool { + if !p.x.is_finite() || !p.y.is_finite() || quad.iter().any(|pt| !pt.x.is_finite() || !pt.y.is_finite()) { + return false; + } + let mut sign = 0; + for i in 0..4 { + let p1 = quad[i]; + let p2 = quad[(i + 1) % 4]; + let cross = (p2.x - p1.x) * (p.y - p1.y) - (p2.y - p1.y) * (p.x - p1.x); + if cross.abs() > 1e-4 { + let s = if cross > 0.0 { 1 } else { -1 }; + if sign == 0 { + sign = s; + } else if sign != s { + return false; + } + } + } + sign != 0 +} + +/// The screen positions of the crop's eight handles: corners, then the top, right, bottom and left edge midpoints. +fn crop_handles(k: Rect) -> [Pos2; 8] { + [k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()] +} + +/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` +/// (the pointer and keys are the crop's while it lasts). #[allow(clippy::cognitive_complexity)] -fn crop_input( - cx: &ImageCx<'_>, - view: &mut DocView, - i: usize, - image: &pdfcraft_engine::PageImage, -) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; - let painter = ui.painter(); - let Some(sel) = view.image_selection.as_mut() else { return false }; - if let Some(rect) = sel.crop_rect - && let Some(mode) = sel.crop.as_mut() - { - mode.keep = user_box(xf, info, page, rect); - sel.crop_rect = None; - } - let Some(mode) = sel.crop.as_mut() else { return false }; - let hover_pos = ptr.pos; - let full_b = xf.user_rect(info, page, image.rect.map(|v| v as f32)); - let b = if full_b.is_positive() { full_b } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }; - let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); - let k = keep_screen(mode.keep); - - if allow_interaction - && resp.drag_started() - && let Some(o) = ptr.origin - { - let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8)); - if let Some(g) = grip { - mode.drag = Some((g, o, mode.keep)); - } - } - - if let Some((grip, start, at_start)) = mode.drag - && let Some(p) = hover_pos - { - let d = p - start; - let k0 = keep_screen(at_start); - if grip < 8 { - let handle_id = match grip { - 0 => HandleId::TopLeft, - 1 => HandleId::TopRight, - 2 => HandleId::BottomLeft, - 3 => HandleId::BottomRight, - 4 => HandleId::TopMid, - 5 => HandleId::RightMid, - 6 => HandleId::BottomMid, - _ => HandleId::LeftMid, - }; - let uv0 = if b.width() > 0.0 && b.height() > 0.0 { - Rect::from_min_max( - pos2(((k0.min.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.min.y - b.min.y) / b.height()).clamp(0.0, 1.0)), - pos2(((k0.max.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.max.y - b.min.y) / b.height()).clamp(0.0, 1.0)), - ) - } else { - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) - }; - let (new_screen, _) = apply_crop(k0, uv0, handle_id, d); - let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height())); - let clamped = Rect::from_min_max( - pos2(new_screen.min.x.clamp(b.min.x, b.max.x - min_w), new_screen.min.y.clamp(b.min.y, b.max.y - min_h)), - pos2(new_screen.max.x.clamp(b.min.x + min_w, b.max.x), new_screen.max.y.clamp(b.min.y + min_h, b.max.y)), - ); - mode.keep = user_box(xf, info, page, clamped); - } else { - let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y)); - mode.keep = user_box(xf, info, page, Rect::from_min_max(k0.min + shift, k0.max + shift)); - } - if resp.drag_stopped() || !ptr.primary_down { - mode.drag = None; - } - } - - // Faint ghost outline of the full uncropped image underneath - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgba_unmultiplied(200, 200, 200, 80)), egui::StrokeKind::Outside); - - // Dim outer excluded bands - let dim = Color32::from_black_alpha(96); - if k.min.y > b.min.y { - painter.rect_filled(Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)), CornerRadius::ZERO, dim); - } - if k.max.y < b.max.y { - painter.rect_filled(Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max), CornerRadius::ZERO, dim); - } - if k.min.x > b.min.x { - painter.rect_filled(Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)), CornerRadius::ZERO, dim); - } - if k.max.x < b.max.x { - painter.rect_filled(Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)), CornerRadius::ZERO, dim); - } - - // Rule-of-Thirds Grid inside crop area - draw_rule_of_thirds(painter, k, Stroke::new(1.0, CROP_ACCENT.gamma_multiply(0.45))); - - // Crop border stroke - painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, CROP_ACCENT), egui::StrokeKind::Outside); - - // Crop handles - for (idx, h) in crop_handles(k).iter().enumerate() { - let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx); - let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0); - draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT); - } - - // Action pills - let font = egui::FontId::proportional(12.5); - let done_w = painter.layout_no_wrap(tl!("Done").to_string(), font.clone(), Color32::WHITE).size().x + 22.0; - let cancel_w = painter.layout_no_wrap(tl!("Cancel").to_string(), font.clone(), Color32::WHITE).size().x + 22.0; - let is_full_rect = (mode.keep[0] - image.rect[0]).abs() < 1e-3 - && (mode.keep[1] - image.rect[1]).abs() < 1e-3 - && (mode.keep[2] - image.rect[2]).abs() < 1e-3 - && (mode.keep[3] - image.rect[3]).abs() < 1e-3; - let has_crop = !is_full_rect || image.visible_rect.is_some_and(|v| v != image.rect); - let reset_w = - if has_crop { Some(painter.layout_no_wrap(tl!("Remove Crop").to_string(), font.clone(), Color32::WHITE).size().x + 22.0) } else { None }; - let spacing = 6.0; - let total_w = done_w + spacing + cancel_w + reset_w.map_or(0.0, |w| w + spacing); - - // Position pills below crop box if there's room, otherwise above - let y = if k.max.y + 32.0 < xf.rect.max.y { - k.max.y + 6.0 - } else if k.min.y - 28.0 > xf.rect.min.y { - k.min.y - 28.0 - } else { - k.max.y + 6.0 - }; - - // Right-align pills with k.max.x, clamped inside the page bounds - let mut right = k.max.x.min(xf.rect.max.x - 8.0); - if right - total_w < xf.rect.min.x + 8.0 { - right = (xf.rect.min.x + 8.0 + total_w).min(xf.rect.max.x - 8.0); - } - - let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0)); - let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0)); - let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0))); - - let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i); - let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i); - let reset_id = ui.scope_id().with("crop_pill_reset").with(page).with(i); - - let done_resp = ui.interact(done, done_id, egui::Sense::click()); - let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click()); - let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click())); - - let t = crate::theme::Tokens::get(ui.ctx()); - - // Done button (accent colored) - let done_bg = if done_resp.is_pointer_button_down_on() { - ACCENT.gamma_multiply(0.8) - } else if done_resp.hovered() { - Color32::from_rgb(40, 145, 255) - } else { - ACCENT - }; - painter.rect_filled(done, CornerRadius::same(11), done_bg); - painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); - - // Cancel button (panel style with border) - let cancel_bg = if cancel_resp.is_pointer_button_down_on() { - t.pressed - } else if cancel_resp.hovered() { - t.hover - } else { - t.card - }; - let cancel_stroke = Stroke::new(1.0, if cancel_resp.hovered() { ACCENT } else { t.border }); - let cancel_text = if cancel_resp.hovered() { ACCENT } else { t.text }; - painter.rect(cancel, CornerRadius::same(11), cancel_bg, cancel_stroke, egui::StrokeKind::Inside); - painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font.clone(), cancel_text); - - // Reset button (panel style with border) - if let (Some(r), Some(r_resp)) = (reset, reset_resp.as_ref()) { - let reset_bg = if r_resp.is_pointer_button_down_on() { - t.pressed - } else if r_resp.hovered() { - t.hover - } else { - t.card - }; - let reset_stroke = Stroke::new(1.0, if r_resp.hovered() { ACCENT } else { t.border }); - let reset_text = if r_resp.hovered() { ACCENT } else { t.text }; - painter.rect(r, CornerRadius::same(11), reset_bg, reset_stroke, egui::StrokeKind::Inside); - painter.text(r.center(), egui::Align2::CENTER_CENTER, tl!("Remove Crop"), font.clone(), reset_text); - } - - if let Some(p) = hover_pos { - let icon = if done_resp.hovered() - || cancel_resp.hovered() - || reset_resp.as_ref().is_some_and(|r| r.hovered()) - || done.contains(p) - || cancel.contains(p) - || reset.is_some_and(|r| r.contains(p)) - { - egui::CursorIcon::PointingHand - } else if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { - match g { - 0 => HandleId::TopLeft.cursor_icon(), - 1 => HandleId::TopRight.cursor_icon(), - 2 => HandleId::BottomLeft.cursor_icon(), - 3 => HandleId::BottomRight.cursor_icon(), - 4 => HandleId::TopMid.cursor_icon(), - 5 => HandleId::RightMid.cursor_icon(), - 6 => HandleId::BottomMid.cursor_icon(), - _ => HandleId::LeftMid.cursor_icon(), - } - } else if k.contains(p) { - if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } - } else { - egui::CursorIcon::Default - }; - view.cursor.claim(Layer::Editor, icon); - } - - let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); - let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); - let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); - let reset_clicked = allow_interaction - && reset - .is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)))); - - if reset_clicked { - mode.keep = image.rect; - mode.drag = None; - if let Some(s) = view.image_selection.as_mut() { - s.crop_rect = None; - } - } else if done_clicked || keys(egui::Key::Enter) { - let keep = mode.keep; - if let Some(s) = view.image_selection.as_mut() { - s.crop = None; - s.crop_mode = false; - s.crop_rect = None; - } - let is_full = (keep[0] - image.rect[0]).abs() < 1e-3 - && (keep[1] - image.rect[1]).abs() < 1e-3 - && (keep[2] - image.rect[2]).abs() < 1e-3 - && (keep[3] - image.rect[3]).abs() < 1e-3; - if is_full { - if image.visible_rect.is_some_and(|v| v != image.rect) { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop }); - } - } else if image.visible_rect != Some(keep) { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); - } - } else if (cancel_clicked || keys(egui::Key::Escape)) - && let Some(s) = view.image_selection.as_mut() - { - s.crop = None; - s.crop_mode = false; - s.crop_rect = None; - } - true -} - -/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where -/// the screen may still show the picture). -fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { - let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); - for r in blank { - clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); - } - let mut mesh = egui::Mesh::with_texture(tex); - for (a, b) in [ - (from.left_top(), to.left_top()), - (from.right_top(), to.right_top()), - (from.right_bottom(), to.right_bottom()), - (from.left_bottom(), to.left_bottom()), - ] { - let (u, v) = xf.screen_to_norm(a); - mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - clipped.add(egui::Shape::mesh(mesh)); -} - -/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same -/// applies after the pointer leaves the image/page while dragging. -#[allow(dead_code)] -pub(crate) fn image_owns_pointer(_ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { - let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; - if selection.drag.is_some() || selection.crop.is_some() || selection.crop_mode { - return true; - } - let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; - let obb = ImageObb::from_image(image, xf, info, page); - let p = if ptr.primary_down { ptr.origin.or(ptr.pos) } else { ptr.pos }; - let Some(p) = p else { return false }; - - obb.handles().iter().any(|(_, h)| h.distance(p) < 8.0) || obb.s_rot.distance(p) < 8.0 -} - -/// The selected picture's grips under `p`: its corners and its rotate knob. They are asked after -/// the paragraphs, so text on top of a picture cannot take them; `clip` is the pages' visible -/// area, which the knob stays inside. -#[allow(clippy::too_many_arguments)] -pub(crate) fn handle_hit( - clip: Rect, - xf: &PageXform, - page: usize, - info: &DocInfo, - images: &[pdfcraft_engine::PageImage], - view: &DocView, - p: Pos2, -) -> Option { - let s = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none())?; - let b = images.get(s.index)?.visible_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32)))?; - let picture = |part| Owner::Picture { index: s.index, part }; - if rotate_knob(b, clip).0.distance(p) < 8.0 { - return Some(Hit::new(picture(PicturePart::Knob), Layer::Handle, egui::CursorIcon::Grab)); - } - let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0)?; - let icon = if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }; - Some(Hit::new(picture(PicturePart::Corner(corner)), Layer::Handle, icon)) -} - -/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the -/// selected one. On the text layer, asked before the paragraphs: editable text on top of a -/// picture takes the press. -pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { - if !xf.rect.contains(p) { - return None; - } - let (x, y) = xf.screen_to_view(p); - let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from); - let index = images - .iter() - .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?; - let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index); - let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; - Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon)) -} - -/// What a right-click on a selected image asks for. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ImageAction { - Replace(usize, usize), - Save(usize, usize), - Copy(usize, usize), - CopyFile(usize, usize), - /// Copy the picture, then remove it from the page. - Cut(usize, usize), - /// Mark the picture for redaction (applied later from Redact a PDF). - Redact(usize, usize), - /// Create a link over the picture. - Link(usize, usize), - /// Open Recognize Text for the picture's page. - Recognize(usize), - Paste(usize, Option<[f64; 2]>), - DrawFreehand, - AddComment(usize, usize), - Crop, -} - -/// Where the rotate knob sits, and the edge point its stem starts from: above the middle of the -/// top edge, or below the bottom edge when the area on screen (`view`) has no room above. -pub(super) fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) { - let above = b.center_top() - egui::vec2(0.0, 22.0); - if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) } -} - -/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap` -/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing. -pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { - let v = pointer - centre; - if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 { - return 0.0; - } - // Screen y points down: up is (0, -1); clockwise is atan2(x, -y). - let d = v.x.atan2(-v.y).to_degrees(); - if snap { (d / 15.0).round() * 15.0 } else { d } -} - -#[doc(hidden)] -pub fn image_menu_id() -> egui::Id { - egui::Id::unique("image-menu") -} - -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let painter = ui.painter(); + let Some(sel) = view.image_selection.as_mut() else { return false }; + if let Some(rect) = sel.crop_rect + && let Some(mode) = sel.crop.as_mut() + { + mode.keep = user_box(xf, info, page, rect); + sel.crop_rect = None; + } + let Some(mode) = sel.crop.as_mut() else { return false }; + let hover_pos = ptr.pos; + let full_b = xf.user_rect(info, page, image.rect.map(|v| v as f32)); + let b = if full_b.is_positive() { full_b } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }; + let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + let k = keep_screen(mode.keep); + + if allow_interaction + && resp.drag_started() + && let Some(o) = ptr.origin + { + let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8)); + if let Some(g) = grip { + mode.drag = Some((g, o, mode.keep)); + } + } + + if let Some((grip, start, at_start)) = mode.drag + && let Some(p) = hover_pos + { + let d = p - start; + let k0 = keep_screen(at_start); + if grip < 8 { + let handle_id = match grip { + 0 => HandleId::TopLeft, + 1 => HandleId::TopRight, + 2 => HandleId::BottomLeft, + 3 => HandleId::BottomRight, + 4 => HandleId::TopMid, + 5 => HandleId::RightMid, + 6 => HandleId::BottomMid, + _ => HandleId::LeftMid, + }; + let uv0 = if b.width() > 0.0 && b.height() > 0.0 { + Rect::from_min_max( + pos2(((k0.min.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.min.y - b.min.y) / b.height()).clamp(0.0, 1.0)), + pos2(((k0.max.x - b.min.x) / b.width()).clamp(0.0, 1.0), ((k0.max.y - b.min.y) / b.height()).clamp(0.0, 1.0)), + ) + } else { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) + }; + let (new_screen, _) = apply_crop(k0, uv0, handle_id, d); + let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height())); + let clamped = Rect::from_min_max( + pos2(new_screen.min.x.clamp(b.min.x, b.max.x - min_w), new_screen.min.y.clamp(b.min.y, b.max.y - min_h)), + pos2(new_screen.max.x.clamp(b.min.x + min_w, b.max.x), new_screen.max.y.clamp(b.min.y + min_h, b.max.y)), + ); + mode.keep = user_box(xf, info, page, clamped); + } else { + let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y)); + mode.keep = user_box(xf, info, page, Rect::from_min_max(k0.min + shift, k0.max + shift)); + } + if resp.drag_stopped() || !ptr.primary_down { + mode.drag = None; + } + } + + // Faint ghost outline of the full uncropped image underneath + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgba_unmultiplied(200, 200, 200, 80)), egui::StrokeKind::Outside); + + // Dim outer excluded bands + let dim = Color32::from_black_alpha(96); + if k.min.y > b.min.y { + painter.rect_filled(Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)), CornerRadius::ZERO, dim); + } + if k.max.y < b.max.y { + painter.rect_filled(Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max), CornerRadius::ZERO, dim); + } + if k.min.x > b.min.x { + painter.rect_filled(Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)), CornerRadius::ZERO, dim); + } + if k.max.x < b.max.x { + painter.rect_filled(Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)), CornerRadius::ZERO, dim); + } + + // Rule-of-Thirds Grid inside crop area + draw_rule_of_thirds(painter, k, Stroke::new(1.0, CROP_ACCENT.gamma_multiply(0.45))); + + // Crop border stroke + painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, CROP_ACCENT), egui::StrokeKind::Outside); + + // Crop handles + for (idx, h) in crop_handles(k).iter().enumerate() { + let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx); + let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0); + draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT); + } + + // Action pills + let font = egui::FontId::proportional(12.5); + let done_w = painter.layout_no_wrap(tl!("Done").to_string(), font.clone(), Color32::WHITE).size().x + 22.0; + let cancel_w = painter.layout_no_wrap(tl!("Cancel").to_string(), font.clone(), Color32::WHITE).size().x + 22.0; + let is_full_rect = (mode.keep[0] - image.rect[0]).abs() < 1e-3 + && (mode.keep[1] - image.rect[1]).abs() < 1e-3 + && (mode.keep[2] - image.rect[2]).abs() < 1e-3 + && (mode.keep[3] - image.rect[3]).abs() < 1e-3; + let has_crop = !is_full_rect || image.visible_rect.is_some_and(|v| v != image.rect); + let reset_w = + if has_crop { Some(painter.layout_no_wrap(tl!("Remove Crop").to_string(), font.clone(), Color32::WHITE).size().x + 22.0) } else { None }; + let spacing = 6.0; + let total_w = done_w + spacing + cancel_w + reset_w.map_or(0.0, |w| w + spacing); + + // Position pills below crop box if there's room, otherwise above + let y = if k.max.y + 32.0 < xf.rect.max.y { + k.max.y + 6.0 + } else if k.min.y - 28.0 > xf.rect.min.y { + k.min.y - 28.0 + } else { + k.max.y + 6.0 + }; + + // Right-align pills with k.max.x, clamped inside the page bounds + let mut right = k.max.x.min(xf.rect.max.x - 8.0); + if right - total_w < xf.rect.min.x + 8.0 { + right = (xf.rect.min.x + 8.0 + total_w).min(xf.rect.max.x - 8.0); + } + + let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0)); + let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0)); + let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0))); + + let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i); + let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i); + let reset_id = ui.scope_id().with("crop_pill_reset").with(page).with(i); + + let done_resp = ui.interact(done, done_id, egui::Sense::click()); + let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click()); + let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click())); + + let t = crate::theme::Tokens::get(ui.ctx()); + + // Done button (accent colored) + let done_bg = if done_resp.is_pointer_button_down_on() { + ACCENT.gamma_multiply(0.8) + } else if done_resp.hovered() { + Color32::from_rgb(40, 145, 255) + } else { + ACCENT + }; + painter.rect_filled(done, CornerRadius::same(11), done_bg); + painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); + + // Cancel button (panel style with border) + let cancel_bg = if cancel_resp.is_pointer_button_down_on() { + t.pressed + } else if cancel_resp.hovered() { + t.hover + } else { + t.card + }; + let cancel_stroke = Stroke::new(1.0, if cancel_resp.hovered() { ACCENT } else { t.border }); + let cancel_text = if cancel_resp.hovered() { ACCENT } else { t.text }; + painter.rect(cancel, CornerRadius::same(11), cancel_bg, cancel_stroke, egui::StrokeKind::Inside); + painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font.clone(), cancel_text); + + // Reset button (panel style with border) + if let (Some(r), Some(r_resp)) = (reset, reset_resp.as_ref()) { + let reset_bg = if r_resp.is_pointer_button_down_on() { + t.pressed + } else if r_resp.hovered() { + t.hover + } else { + t.card + }; + let reset_stroke = Stroke::new(1.0, if r_resp.hovered() { ACCENT } else { t.border }); + let reset_text = if r_resp.hovered() { ACCENT } else { t.text }; + painter.rect(r, CornerRadius::same(11), reset_bg, reset_stroke, egui::StrokeKind::Inside); + painter.text(r.center(), egui::Align2::CENTER_CENTER, tl!("Remove Crop"), font.clone(), reset_text); + } + + if let Some(p) = hover_pos { + let icon = if done_resp.hovered() + || cancel_resp.hovered() + || reset_resp.as_ref().is_some_and(|r| r.hovered()) + || done.contains(p) + || cancel.contains(p) + || reset.is_some_and(|r| r.contains(p)) + { + egui::CursorIcon::PointingHand + } else if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { + match g { + 0 => HandleId::TopLeft.cursor_icon(), + 1 => HandleId::TopRight.cursor_icon(), + 2 => HandleId::BottomLeft.cursor_icon(), + 3 => HandleId::BottomRight.cursor_icon(), + 4 => HandleId::TopMid.cursor_icon(), + 5 => HandleId::RightMid.cursor_icon(), + 6 => HandleId::BottomMid.cursor_icon(), + _ => HandleId::LeftMid.cursor_icon(), + } + } else if k.contains(p) { + if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } + } else { + egui::CursorIcon::Default + }; + view.cursor.claim(Layer::Editor, icon); + } + + let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); + let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); + let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); + let reset_clicked = allow_interaction + && reset + .is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)))); + + if reset_clicked { + mode.keep = image.rect; + mode.drag = None; + if let Some(s) = view.image_selection.as_mut() { + s.crop_rect = None; + } + } else if done_clicked || keys(egui::Key::Enter) { + let keep = mode.keep; + if let Some(s) = view.image_selection.as_mut() { + s.crop = None; + s.crop_mode = false; + s.crop_rect = None; + } + let is_full = (keep[0] - image.rect[0]).abs() < 1e-3 + && (keep[1] - image.rect[1]).abs() < 1e-3 + && (keep[2] - image.rect[2]).abs() < 1e-3 + && (keep[3] - image.rect[3]).abs() < 1e-3; + if is_full { + if image.visible_rect.is_some_and(|v| v != image.rect) { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop }); + } + } else if image.visible_rect != Some(keep) { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); + } + } else if (cancel_clicked || keys(egui::Key::Escape)) + && let Some(s) = view.image_selection.as_mut() + { + s.crop = None; + s.crop_mode = false; + s.crop_rect = None; + } + true +} + +/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where +/// the screen may still show the picture). +fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + for r in blank { + clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); + } + let mut mesh = egui::Mesh::with_texture(tex); + for (a, b) in [ + (from.left_top(), to.left_top()), + (from.right_top(), to.right_top()), + (from.right_bottom(), to.right_bottom()), + (from.left_bottom(), to.left_bottom()), + ] { + let (u, v) = xf.screen_to_norm(a); + mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clipped.add(egui::Shape::mesh(mesh)); +} + +/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same +/// applies after the pointer leaves the image/page while dragging. +#[allow(dead_code)] +pub(crate) fn image_owns_pointer(_ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { + let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; + if selection.drag.is_some() || selection.crop.is_some() || selection.crop_mode { + return true; + } + let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; + let obb = ImageObb::from_image(image, xf, info, page); + let p = if ptr.primary_down { ptr.origin.or(ptr.pos) } else { ptr.pos }; + let Some(p) = p else { return false }; + + obb.handles().iter().any(|(_, h)| h.distance(p) < 8.0) || obb.s_rot.distance(p) < 8.0 +} + +/// The selected picture's grips under `p`: its corners and its rotate knob. They are asked after +/// the paragraphs, so text on top of a picture cannot take them; `clip` is the pages' visible +/// area, which the knob stays inside. +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_hit( + clip: Rect, + xf: &PageXform, + page: usize, + info: &DocInfo, + images: &[pdfcraft_engine::PageImage], + view: &DocView, + p: Pos2, +) -> Option { + let s = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none())?; + let b = images.get(s.index)?.visible_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32)))?; + let picture = |part| Owner::Picture { index: s.index, part }; + if rotate_knob(b, clip).0.distance(p) < 8.0 { + return Some(Hit::new(picture(PicturePart::Knob), Layer::Handle, egui::CursorIcon::Grab)); + } + let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0)?; + let icon = if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }; + Some(Hit::new(picture(PicturePart::Corner(corner)), Layer::Handle, icon)) +} + +/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the +/// selected one. On the text layer, asked before the paragraphs: editable text on top of a +/// picture takes the press. +pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { + if !xf.rect.contains(p) { + return None; + } + let (x, y) = xf.screen_to_view(p); + let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from); + let index = images + .iter() + .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?; + let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index); + let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; + Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon)) +} + +/// What a right-click on a selected image asks for. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ImageAction { + Replace(usize, usize), + Save(usize, usize), + Copy(usize, usize), + CopyFile(usize, usize), + /// Copy the picture, then remove it from the page. + Cut(usize, usize), + /// Mark the picture for redaction (applied later from Redact a PDF). + Redact(usize, usize), + /// Create a link over the picture. + Link(usize, usize), + /// Open Recognize Text for the picture's page. + Recognize(usize), + Paste(usize, Option<[f64; 2]>), + DrawFreehand, + AddComment(usize, usize), + Crop, +} + +/// Where the rotate knob sits, and the edge point its stem starts from: above the middle of the +/// top edge, or below the bottom edge when the area on screen (`view`) has no room above. +pub(super) fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) { + let above = b.center_top() - egui::vec2(0.0, 22.0); + if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) } +} + +/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap` +/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing. +pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { + let v = pointer - centre; + if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 { + return 0.0; + } + // Screen y points down: up is (0, -1); clockwise is atan2(x, -y). + let d = v.x.atan2(-v.y).to_degrees(); + if snap { (d / 15.0).round() * 15.0 } else { d } +} + +#[doc(hidden)] +pub fn image_menu_id() -> egui::Id { + egui::Id::unique("image-menu") +} + +/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. #[allow(clippy::cognitive_complexity)] -pub(crate) fn image_input( - cx: &ImageCx<'_>, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, -) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; - let _ = &action; - let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); - let painter = ui.painter(); - let hover_pos = ptr.pos; - let image_contains = |index: usize, point: Pos2| { - let (x, y) = xf.screen_to_view(point); - let Some(p) = info.pages.get(page) else { return false }; - let user = p.view_to_user(x, y); - images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) - }; - let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); - let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); - let faint_all = boxes.len() <= MAX_OUTLINES; - let moving = selected.filter(|_| dragging); - if faint_all { - for (idx, im) in images.iter().enumerate() { - if moving == Some(idx) { - continue; - } - if let Some(b) = boxes.get(idx).filter(|b| b.is_positive()) { - let obb = ImageObb::from_image(im, xf, info, page); - if obb.is_rotated { - let pts = obb.corners(); - for k in 0..4 { - painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(0.75, ACCENT.gamma_multiply(0.35))); - } - } else { - painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - } - } - } - let hovered_box = hover_pos.and_then(|p| { - boxes.iter().enumerate().rposition(|(i, b)| { - b.is_positive() - && images.get(i).is_some_and(|img| b.contains(p) || quad_contains(ImageObb::from_image(img, xf, info, page).corners(), p)) - && image_contains(i, p) - }) - }); - if !faint_all { - for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { - if let (Some(b), Some(img)) = (boxes.get(i).filter(|b| b.is_positive()), images.get(i)) { - let obb = ImageObb::from_image(img, xf, info, page); - if obb.is_rotated { - let pts = obb.corners(); - for k in 0..4 { - painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(0.75, ACCENT.gamma_multiply(0.35))); - } - } else { - painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - } - } - } - for i in selected.into_iter().chain(hovered_box).collect::>() { - let (Some(b), Some(img)) = (boxes.get(i).copied().filter(|b| b.is_positive()), images.get(i)) else { continue }; - let obb = ImageObb::from_image(img, xf, info, page); - if selected == Some(i) { - if !dragging { - if obb.is_rotated { - painter.polygon(obb.corners().to_vec(), ACCENT.gamma_multiply(0.04), Stroke::NONE); - } else { - painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - } - } - } else if obb.is_rotated { - painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, ACCENT.gamma_multiply(0.7))); - } else { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); - } - } - // The selected image: frame, corner handles, dragging. - if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { - let Some(b) = boxes.get(i).copied() else { return false }; - let Some(image) = images.get(i) else { return false }; - let obb = ImageObb::from_image(image, xf, info, page); - let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page)); - let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); - let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); - if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) { - match (s.settled, raster) { - (Some((from, to)), Some(tex)) if out_of_date => { - let source = if raster_old { from } else { to }; - stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to)); - } - _ if !out_of_date => s.settled = None, - _ => {} - } - } - - if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some() || s.crop_mode) { - if let Some(s) = view.image_selection.as_mut() - && s.crop.is_none() - { - let rect = images.get(i).and_then(|im| im.visible_rect).unwrap_or(images.get(i).map_or([0.0; 4], |im| im.rect)); - s.crop = Some(CropMode::new(rect)); - } - return crop_input(cx, view, i, image); - } - let (knob, stem) = rotate_knob(b, ui.clip_rect()); - if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { - if obb.is_rotated { - painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, ACCENT)); - painter.line_segment([obb.s_tc, obb.s_rot], Stroke::new(1.2, ACCENT)); - painter.circle(obb.s_rot, 4.5, Color32::WHITE, Stroke::new(1.0, ACCENT)); - } else { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT)); - painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT)); - } - for (_, h) in obb.handles() { - let is_hovered = hover_pos.is_some_and(|p| p.distance(h) < 10.0); - draw_handle(painter, h, false, is_hovered, false, ACCENT); - } - } - - let mut handle_drag = None; - for (h, h_pos, handle_id) in obb.handles_with_ids(ui.scope_id().with("img_handle")) { - let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0)); - let handle_resp = ui.interact(handle_rect, handle_id, egui::Sense::drag()); - if allow_interaction && handle_resp.drag_started() { - handle_drag = Some(h); - } - } - let rot_id = ui.scope_id().with("img_handle").with(8); - let rot_rect = Rect::from_center_size(if obb.is_rotated { obb.s_rot } else { knob }, egui::vec2(12.0, 12.0)); - let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag()); - let rot_drag = allow_interaction && rot_resp.drag_started(); - - if allow_interaction && let Some(p) = hover_pos { - if obb.s_rot.distance(p) < 8.0 || knob.distance(p) < 8.0 { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); - } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) { - let icon = match h { - ImageHandle::TopLeft | ImageHandle::BottomRight => { - if obb.is_rotated { - let v = obb.handle_pos(*h) - obb.screen_center; - if v.x * v.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw } - } else { - egui::CursorIcon::ResizeNwSe - } - } - ImageHandle::TopRight | ImageHandle::BottomLeft => { - if obb.is_rotated { - let v = obb.handle_pos(*h) - obb.screen_center; - if v.x * v.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw } - } else { - egui::CursorIcon::ResizeNeSw - } - } - ImageHandle::TopCenter | ImageHandle::BottomCenter => { - if obb.is_rotated && (obb.v_axis.0.abs() > obb.v_axis.1.abs()) { - egui::CursorIcon::ResizeHorizontal - } else { - egui::CursorIcon::ResizeVertical - } - } - ImageHandle::RightCenter | ImageHandle::LeftCenter => { - if obb.is_rotated && (obb.u_axis.1.abs() > obb.u_axis.0.abs()) { - egui::CursorIcon::ResizeVertical - } else { - egui::CursorIcon::ResizeHorizontal - } - } - }; - view.cursor.claim(Layer::Handle, icon); - } - } - - let origin = ptr.origin; - if allow_interaction { - if let Some(h) = handle_drag { - if let Some(s) = view.image_selection.as_mut() { - let o = origin.unwrap_or_else(|| obb.handle_pos(h)); - s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b, initial_matrix: image.matrix }); - } - } else if rot_drag { - if let Some(s) = view.image_selection.as_mut() { - let o = origin.unwrap_or(if obb.is_rotated { obb.s_rot } else { knob }); - s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); - s.turning = true; - } - } else if resp.drag_started() - && let Some(o) = origin - { - if obb.s_rot.distance(o) < 8.0 || knob.distance(o) < 8.0 { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); - s.turning = true; - } - } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(*h), initial_rect: b, initial_matrix: image.matrix }); - } - } else if (image_contains(i, o) || quad_contains(obb.corners(), o)) - && let Some(s) = view.image_selection.as_mut() - { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); - } - } - } - - if view.image_selection.as_ref().is_some_and(|s| s.turning) { - let shift = ui.input(|i| i.modifiers.shift); - let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); - let centre = b.center(); - let (sin, cos) = degrees.to_radians().sin_cos(); - let turn = |c: Pos2| { - let v = c - centre; - centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) - }; - let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); - painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); - let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); - let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); - painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); - view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - s.turning = false; - } - if degrees.abs() >= 0.05 { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); - } - } - return true; - } - - if let Some(drag) = view.image_selection.as_ref().and_then(|s| s.drag) - && let Some(p) = hover_pos - { - if !obb.is_rotated && !matches!(drag.target, DragTarget::Rotate) { - let preview = match drag.target { - DragTarget::Handle(handle) => { - let handle_id = HandleId::from(handle); - let delta = p - drag.start; - let is_corner = matches!(handle_id, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight); - let shift = ui.input(|inp| inp.modifiers.shift); - if is_corner && !shift { - apply_resize_proportional(drag.initial_rect, handle_id, delta) - } else { - apply_resize(drag.initial_rect, handle_id, delta) - } - } - DragTarget::Move => drag.initial_rect.translate(p - drag.start), - _ => drag.initial_rect, - }; - - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let f_min = egui::vec2((raw.min.x - b.min.x) * factor.x, (raw.min.y - b.min.y) * factor.y); - let f_max = egui::vec2((raw.max.x - b.min.x) * factor.x, (raw.max.y - b.min.y) * factor.y); - let target = Rect::from_min_max(preview.min + f_min, preview.min + f_max); - let visible = preview.intersect(xf.rect); - - if visible.is_positive() { - if preview != b - && let Some(tex) = raster - { - let source = left_at.filter(|_| raster_old).map_or(b, screen); - let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); - stretched_picture(painter, xf, tex, &blank, source, preview); - } - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - for h in [ - preview.left_top(), - preview.right_top(), - preview.left_bottom(), - preview.right_bottom(), - preview.center_top(), - preview.right_center(), - preview.center_bottom(), - preview.left_center(), - ] { - draw_handle(painter, h, false, false, false, ACCENT); - } - } - - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - let norm = |r: Rect| { - let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); - Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) - }; - s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); - } - if preview != b { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); - } - } - - let icon = match drag.target { - DragTarget::Handle(handle) => HandleId::from(handle).cursor_icon(), - DragTarget::Move => egui::CursorIcon::Grabbing, - _ => egui::CursorIcon::Move, - }; - view.cursor.claim(Layer::Drag, icon); - return true; - } - let m_init = Matrix(drag.initial_matrix); - let [a0, b0, c0, d0, e0, f0] = drag.initial_matrix; - let w0 = (a0 * a0 + b0 * b0).sqrt().max(1e-6); - let h0 = (c0 * c0 + d0 * d0).sqrt().max(1e-6); - let u_axis = (a0 / w0, b0 / w0); - let v_axis = (c0 / h0, d0 / h0); - let center_user = (e0 + 0.5 * (a0 + c0), f0 + 0.5 * (b0 + d0)); - - let (sx, sy) = screen_to_user(xf, info, page, drag.start); - let (px, py) = screen_to_user(xf, info, page, p); - - let (m_new, cursor_icon) = match drag.target { - DragTarget::Rotate => { - let v0 = (sx - center_user.0, sy - center_user.1); - let v1 = (px - center_user.0, py - center_user.1); - let theta0 = v0.1.atan2(v0.0); - let theta1 = v1.1.atan2(v1.0); - let mut delta = theta1 - theta0; - if ui.input(|i| i.modifiers.shift) { - let step = std::f64::consts::PI / 12.0; - delta = (delta / step).round() * step; - } - let (cos_d, sin_d) = (delta.cos(), delta.sin()); - let t_rot = [ - cos_d, - sin_d, - -sin_d, - cos_d, - center_user.0 * (1.0 - cos_d) + center_user.1 * sin_d, - center_user.1 * (1.0 - cos_d) - center_user.0 * sin_d, - ]; - (m_init.then(&Matrix(t_rot)), egui::CursorIcon::Grab) - } - DragTarget::Move => { - let (dx, dy) = (px - sx, py - sy); - (Matrix([a0, b0, c0, d0, e0 + dx, f0 + dy]), egui::CursorIcon::Move) - } - DragTarget::Handle(handle) => { - let p00 = (e0, f0); - let p10 = (e0 + a0, f0 + b0); - let p11 = (e0 + a0 + c0, f0 + b0 + d0); - let p01 = (e0 + c0, f0 + d0); - let p_curr = (px, py); - - let (new_w, new_h, p00_new, icon) = match handle { - ImageHandle::TopRight => { - let anchor = p00; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let du = d.0 * u_axis.0 + d.1 * u_axis.1; - let dv = d.0 * v_axis.0 + d.1 * v_axis.1; - let su = du / w0; - let sv = dv / h0; - let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; - let s = s.max(5.0 / w0).max(5.0 / h0); - (w0 * s, h0 * s, anchor, egui::CursorIcon::ResizeNeSw) - } - ImageHandle::TopLeft => { - let anchor = p10; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let du = d.0 * (-u_axis.0) + d.1 * (-u_axis.1); - let dv = d.0 * v_axis.0 + d.1 * v_axis.1; - let su = du / w0; - let sv = dv / h0; - let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; - let s = s.max(5.0 / w0).max(5.0 / h0); - let nw = w0 * s; - let nh = h0 * s; - (nw, nh, (anchor.0 - nw * u_axis.0, anchor.1 - nw * u_axis.1), egui::CursorIcon::ResizeNwSe) - } - ImageHandle::BottomLeft => { - let anchor = p11; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let du = d.0 * (-u_axis.0) + d.1 * (-u_axis.1); - let dv = d.0 * (-v_axis.0) + d.1 * (-v_axis.1); - let su = du / w0; - let sv = dv / h0; - let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; - let s = s.max(5.0 / w0).max(5.0 / h0); - let nw = w0 * s; - let nh = h0 * s; - ( - nw, - nh, - (anchor.0 - nw * u_axis.0 - nh * v_axis.0, anchor.1 - nw * u_axis.1 - nh * v_axis.1), - egui::CursorIcon::ResizeNeSw, - ) - } - ImageHandle::BottomRight => { - let anchor = p01; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let du = d.0 * u_axis.0 + d.1 * u_axis.1; - let dv = d.0 * (-v_axis.0) + d.1 * (-v_axis.1); - let su = du / w0; - let sv = dv / h0; - let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; - let s = s.max(5.0 / w0).max(5.0 / h0); - let nw = w0 * s; - let nh = h0 * s; - (nw, nh, (anchor.0 - nh * v_axis.0, anchor.1 - nh * v_axis.1), egui::CursorIcon::ResizeNwSe) - } - ImageHandle::RightCenter => { - let anchor = p00; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let du = (d.0 * u_axis.0 + d.1 * u_axis.1).max(5.0); - (du, h0, anchor, egui::CursorIcon::ResizeHorizontal) - } - ImageHandle::LeftCenter => { - let anchor = p10; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let du = (d.0 * (-u_axis.0) + d.1 * (-u_axis.1)).max(5.0); - (du, h0, (anchor.0 - du * u_axis.0, anchor.1 - du * u_axis.1), egui::CursorIcon::ResizeHorizontal) - } - ImageHandle::TopCenter => { - let anchor = p00; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let dv = (d.0 * v_axis.0 + d.1 * v_axis.1).max(5.0); - (w0, dv, anchor, egui::CursorIcon::ResizeVertical) - } - ImageHandle::BottomCenter => { - let anchor = p01; - let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); - let dv = (d.0 * (-v_axis.0) + d.1 * (-v_axis.1)).max(5.0); - (w0, dv, (anchor.0 - dv * v_axis.0, anchor.1 - dv * v_axis.1), egui::CursorIcon::ResizeVertical) - } - }; - let mat = Matrix([new_w * u_axis.0, new_w * u_axis.1, new_h * v_axis.0, new_h * v_axis.1, p00_new.0, p00_new.1]); - (mat, icon) - } - _ => (m_init, egui::CursorIcon::Default), - }; - - let preview_obb = ImageObb::new(m_new.0, xf, info, page); - if let Some(tex) = view.page_raster(page) { - let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); - let mut mesh = egui::Mesh::with_texture(tex); - let orig_corners = obb.corners(); - let new_corners = preview_obb.corners(); - for (from, to) in [ - (orig_corners[3], new_corners[3]), - (orig_corners[2], new_corners[2]), - (orig_corners[1], new_corners[1]), - (orig_corners[0], new_corners[0]), - ] { - let (u, v) = xf.screen_to_norm(from); - mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - clipped.add(egui::Shape::mesh(mesh)); - } - - if preview_obb.is_rotated { - let pts = preview_obb.corners(); - painter.polygon(pts.to_vec(), ACCENT.gamma_multiply(0.04), Stroke::new(1.5, ACCENT)); - } else { - let p_rect = Rect::from_two_pos(preview_obb.s01, preview_obb.s10); - let visible = p_rect.intersect(xf.rect); - if visible.is_positive() { - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - } - } - for (_, h) in preview_obb.handles() { - draw_handle(painter, h, false, false, false, ACCENT); - } - - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - } - if m_new.0 != drag.initial_matrix { - if matches!(drag.target, DragTarget::Move) { - let (dx, dy) = (px - sx, py - sy); - let new_rect = [image.rect[0] + dx, image.rect[1] + dy, image.rect[2] + dx, image.rect[3] + dy]; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(new_rect) }); - } else if let Some(inv) = Matrix(drag.initial_matrix).invert() { - let t = inv.then(&m_new); - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Transform(t.0) }); - } - } - } - view.cursor.claim(Layer::Drag, cursor_icon); - return true; - } - if allow_interaction - && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) - && !ui.ctx().egui_wants_keyboard_input() - { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); - return true; - } - } - if !allow_interaction { - return false; - } - let menu_key = image_menu_id(); - let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| { - b.is_positive() - && images.get(i).is_some_and(|img| b.contains(p) || quad_contains(ImageObb::from_image(img, xf, info, page).corners(), p)) - && image_contains(i, p) - }) else { - if resp.secondary_clicked() || resp.clicked() { - view.image_selection = None; - ui.ctx().data_mut(|d| d.insert_persisted(menu_key, false)); - } - return false; - }; - let Some(hit_img) = images.get(hit) else { return false }; - let hit_obb = ImageObb::from_image(hit_img, xf, info, page); - if selected != Some(hit) { - if hit_obb.is_rotated { - let pts = hit_obb.corners(); - for k in 0..4 { - painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7))); - } - } else if let Some(b) = boxes.get(hit).copied() { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); - } - } - let corner = hit_obb.handles().iter().position(|(_, c)| c.distance(p) < 10.0); - let cursor = match (selected == Some(hit), corner) { - (true, Some(0 | 4)) => egui::CursorIcon::ResizeNwSe, - (true, Some(2 | 6)) => egui::CursorIcon::ResizeNeSw, - (true, Some(1 | 5)) => egui::CursorIcon::ResizeVertical, - (true, Some(3 | 7)) => egui::CursorIcon::ResizeHorizontal, - (true, None) => egui::CursorIcon::Grab, - _ => egui::CursorIcon::PointingHand, - }; - view.cursor.claim(Layer::Text, cursor); - let clicked = match view.router.owner(page) { - Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index), - _ => (resp.clicked() || resp.secondary_clicked()).then_some(hit), - }; - if let Some(index) = clicked { - view.image_selection = - Some(ImageSelection { page, index, drag: None, crop: None, crop_mode: false, crop_rect: None, turning: false, settled: None }); - } - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_persisted(menu_key, true)); - } - true -} - -/// The right-click context menu for a selected image. -#[allow(clippy::too_many_arguments)] -pub(crate) fn image_context_menu( - ui: &mut egui::Ui, - view: &mut DocView, - images: &[pdfcraft_engine::PageImage], - page: usize, - hit: usize, - can_paste: bool, - action: &mut Option, - at: Option<[f64; 2]>, -) { - let Some(img) = images.get(hit) else { return }; - use pdfcraft_engine::ImageEdit as E; - let raster = !img.is_form; - - let close = |ui: &mut egui::Ui| { - ui.ctx().data_mut(|d| d.insert_persisted(image_menu_id(), false)); - egui::Popup::close_all(ui.ctx()); - ui.close(); - }; - - // Top horizontal icon bar: Comment, Crop, Rotate CCW, Rotate CW - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if crate::icons::button(ui, "message-square", 24.0, false, tl!("Add a sticky note here")).clicked() { - *action = Some(ImageAction::AddComment(page, hit)); - close(ui); - } - if raster && crate::icons::button(ui, "crop", 24.0, false, tl!("Crop")).clicked() { - *action = Some(ImageAction::Crop); - if let Some(s) = view.image_selection.as_mut() { - let rect = img.visible_rect.unwrap_or(img.rect); - s.crop = Some(CropMode::new(rect)); - s.crop_mode = true; - s.crop_rect = None; - } - close(ui); - } - if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(3) }); - close(ui); - } - if crate::icons::button(ui, "rotate-cw", 24.0, false, tl!("Rotate Clockwise")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(1) }); - close(ui); - } - }); - - ui.separator(); - - if ui.button(tl!("Cut Image")).clicked() { - *action = Some(ImageAction::Cut(page, hit)); - close(ui); - } - if ui.button(tl!("Copy Image")).clicked() { - *action = Some(ImageAction::Copy(page, hit)); - close(ui); - } - if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste"))).clicked() { - *action = Some(ImageAction::Paste(page, at)); - close(ui); - } - - ui.separator(); - - let is_cropped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); - if raster && ui.add_enabled(is_cropped, egui::Button::new(tl!("Remove crop"))).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::RemoveCrop }); - close(ui); - } - - if ui.button(tl!("Flip horizontal")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: true } }); - close(ui); - } - if ui.button(tl!("Flip vertical")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: false } }); - close(ui); - } - - ui.menu_button(tl!("Arrange image"), |ui| { - if ui.button(tl!("Bring to front")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront) }); - close(ui); - } - if ui.button(tl!("Send to back")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack) }); - close(ui); - } - if ui.button(tl!("Bring forward")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringForward) }); - close(ui); - } - if ui.button(tl!("Send backward")).clicked() { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward) }); - close(ui); - } - }); - - if ui.add_enabled(raster, egui::Button::new(tl!("Replace image"))).clicked() { - *action = Some(ImageAction::Replace(page, hit)); - close(ui); - } - if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() { - *action = Some(ImageAction::Save(page, hit)); - close(ui); - } - - ui.separator(); - - if ui.button(tl!("Draw freehand")).clicked() { - *action = Some(ImageAction::DrawFreehand); - close(ui); - } - if ui.button(tl!("Redact Image")).clicked() { - *action = Some(ImageAction::Redact(page, hit)); - close(ui); - } - if ui.add_enabled(raster, egui::Button::new(tl!("Export image"))).clicked() { - *action = Some(ImageAction::Save(page, hit)); - close(ui); - } - - ui.separator(); - - if ui.button(tl!("Create Link…")).clicked() { - *action = Some(ImageAction::Link(page, hit)); - close(ui); - } - - ui.menu_button(tl!("Edit image using"), |ui| { - ui.add_enabled(false, egui::Button::new(tl!("Default image editor"))); - }); - - if ui.button(tl!("Recognize Text…")).clicked() { - *action = Some(ImageAction::Recognize(page)); - close(ui); - } - - ui.separator(); - - if ui.button(tl!("Delete")).clicked() { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); - close(ui); - } -} +pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView, action: &mut Option) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let _ = &action; + let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); + let painter = ui.painter(); + let hover_pos = ptr.pos; + let image_contains = |index: usize, point: Pos2| { + let (x, y) = xf.screen_to_view(point); + let Some(p) = info.pages.get(page) else { return false }; + let user = p.view_to_user(x, y); + images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) + }; + let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); + let faint_all = boxes.len() <= MAX_OUTLINES; + let moving = selected.filter(|_| dragging); + if faint_all { + for (idx, im) in images.iter().enumerate() { + if moving == Some(idx) { + continue; + } + if let Some(b) = boxes.get(idx).filter(|b| b.is_positive()) { + let obb = ImageObb::from_image(im, xf, info, page); + if obb.is_rotated { + let pts = obb.corners(); + for k in 0..4 { + painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(0.75, ACCENT.gamma_multiply(0.35))); + } + } else { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + } + } + let hovered_box = hover_pos.and_then(|p| { + boxes.iter().enumerate().rposition(|(i, b)| { + b.is_positive() + && images.get(i).is_some_and(|img| b.contains(p) || quad_contains(ImageObb::from_image(img, xf, info, page).corners(), p)) + && image_contains(i, p) + }) + }); + if !faint_all { + for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { + if let (Some(b), Some(img)) = (boxes.get(i).filter(|b| b.is_positive()), images.get(i)) { + let obb = ImageObb::from_image(img, xf, info, page); + if obb.is_rotated { + let pts = obb.corners(); + for k in 0..4 { + painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(0.75, ACCENT.gamma_multiply(0.35))); + } + } else { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + } + } + for i in selected.into_iter().chain(hovered_box).collect::>() { + let (Some(b), Some(img)) = (boxes.get(i).copied().filter(|b| b.is_positive()), images.get(i)) else { continue }; + let obb = ImageObb::from_image(img, xf, info, page); + if selected == Some(i) { + if !dragging { + if obb.is_rotated { + painter.polygon(obb.corners().to_vec(), ACCENT.gamma_multiply(0.04), Stroke::NONE); + } else { + painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + } + } + } else if obb.is_rotated { + painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, ACCENT.gamma_multiply(0.7))); + } else { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + } + // The selected image: frame, corner handles, dragging. + if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { + let Some(b) = boxes.get(i).copied() else { return false }; + let Some(image) = images.get(i) else { return false }; + let obb = ImageObb::from_image(image, xf, info, page); + let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page)); + let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); + let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); + if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) { + match (s.settled, raster) { + (Some((from, to)), Some(tex)) if out_of_date => { + let source = if raster_old { from } else { to }; + stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to)); + } + _ if !out_of_date => s.settled = None, + _ => {} + } + } + + if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some() || s.crop_mode) { + if let Some(s) = view.image_selection.as_mut() + && s.crop.is_none() + { + let rect = images.get(i).and_then(|im| im.visible_rect).unwrap_or(images.get(i).map_or([0.0; 4], |im| im.rect)); + s.crop = Some(CropMode::new(rect)); + } + return crop_input(cx, view, i, image); + } + let (knob, stem) = rotate_knob(b, ui.clip_rect()); + if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { + if obb.is_rotated { + painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, ACCENT)); + painter.line_segment([obb.s_tc, obb.s_rot], Stroke::new(1.2, ACCENT)); + painter.circle(obb.s_rot, 4.5, Color32::WHITE, Stroke::new(1.0, ACCENT)); + } else { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT)); + painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT)); + } + for (_, h) in obb.handles() { + let is_hovered = hover_pos.is_some_and(|p| p.distance(h) < 10.0); + draw_handle(painter, h, false, is_hovered, false, ACCENT); + } + } + + let mut handle_drag = None; + for (h, h_pos, handle_id) in obb.handles_with_ids(ui.scope_id().with("img_handle")) { + let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0)); + let handle_resp = ui.interact(handle_rect, handle_id, egui::Sense::drag()); + if allow_interaction && handle_resp.drag_started() { + handle_drag = Some(h); + } + } + let rot_id = ui.scope_id().with("img_handle").with(8); + let rot_rect = Rect::from_center_size(if obb.is_rotated { obb.s_rot } else { knob }, egui::vec2(12.0, 12.0)); + let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag()); + let rot_drag = allow_interaction && rot_resp.drag_started(); + + if allow_interaction && let Some(p) = hover_pos { + if obb.s_rot.distance(p) < 8.0 || knob.distance(p) < 8.0 { + view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); + } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) { + let icon = match h { + ImageHandle::TopLeft | ImageHandle::BottomRight => { + if obb.is_rotated { + let v = obb.handle_pos(*h) - obb.screen_center; + if v.x * v.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw } + } else { + egui::CursorIcon::ResizeNwSe + } + } + ImageHandle::TopRight | ImageHandle::BottomLeft => { + if obb.is_rotated { + let v = obb.handle_pos(*h) - obb.screen_center; + if v.x * v.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw } + } else { + egui::CursorIcon::ResizeNeSw + } + } + ImageHandle::TopCenter | ImageHandle::BottomCenter => { + if obb.is_rotated && (obb.v_axis.0.abs() > obb.v_axis.1.abs()) { + egui::CursorIcon::ResizeHorizontal + } else { + egui::CursorIcon::ResizeVertical + } + } + ImageHandle::RightCenter | ImageHandle::LeftCenter => { + if obb.is_rotated && (obb.u_axis.1.abs() > obb.u_axis.0.abs()) { + egui::CursorIcon::ResizeVertical + } else { + egui::CursorIcon::ResizeHorizontal + } + } + }; + view.cursor.claim(Layer::Handle, icon); + } + } + + let origin = ptr.origin; + if allow_interaction { + if let Some(h) = handle_drag { + if let Some(s) = view.image_selection.as_mut() { + let o = origin.unwrap_or_else(|| obb.handle_pos(h)); + s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b, initial_matrix: image.matrix }); + } + } else if rot_drag { + if let Some(s) = view.image_selection.as_mut() { + let o = origin.unwrap_or(if obb.is_rotated { obb.s_rot } else { knob }); + s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); + s.turning = true; + } + } else if resp.drag_started() + && let Some(o) = origin + { + if obb.s_rot.distance(o) < 8.0 || knob.distance(o) < 8.0 { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); + s.turning = true; + } + } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(*h), initial_rect: b, initial_matrix: image.matrix }); + } + } else if (image_contains(i, o) || quad_contains(obb.corners(), o)) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); + } + } + } + + if view.image_selection.as_ref().is_some_and(|s| s.turning) { + let shift = ui.input(|i| i.modifiers.shift); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); + let centre = b.center(); + let (sin, cos) = degrees.to_radians().sin_cos(); + let turn = |c: Pos2| { + let v = c - centre; + centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) + }; + let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); + painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); + let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); + let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); + painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + s.turning = false; + } + if degrees.abs() >= 0.05 { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); + } + } + return true; + } + + if let Some(drag) = view.image_selection.as_ref().and_then(|s| s.drag) + && let Some(p) = hover_pos + { + if !obb.is_rotated && !matches!(drag.target, DragTarget::Rotate) { + let preview = match drag.target { + DragTarget::Handle(handle) => { + let handle_id = HandleId::from(handle); + let delta = p - drag.start; + let is_corner = matches!(handle_id, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight); + let shift = ui.input(|inp| inp.modifiers.shift); + if is_corner && !shift { + apply_resize_proportional(drag.initial_rect, handle_id, delta) + } else { + apply_resize(drag.initial_rect, handle_id, delta) + } + } + DragTarget::Move => drag.initial_rect.translate(p - drag.start), + _ => drag.initial_rect, + }; + + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let f_min = egui::vec2((raw.min.x - b.min.x) * factor.x, (raw.min.y - b.min.y) * factor.y); + let f_max = egui::vec2((raw.max.x - b.min.x) * factor.x, (raw.max.y - b.min.y) * factor.y); + let target = Rect::from_min_max(preview.min + f_min, preview.min + f_max); + let visible = preview.intersect(xf.rect); + + if visible.is_positive() { + if preview != b + && let Some(tex) = raster + { + let source = left_at.filter(|_| raster_old).map_or(b, screen); + let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); + stretched_picture(painter, xf, tex, &blank, source, preview); + } + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + for h in [ + preview.left_top(), + preview.right_top(), + preview.left_bottom(), + preview.right_bottom(), + preview.center_top(), + preview.right_center(), + preview.center_bottom(), + preview.left_center(), + ] { + draw_handle(painter, h, false, false, false, ACCENT); + } + } + + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + let norm = |r: Rect| { + let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); + Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) + }; + s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + } + } + + let icon = match drag.target { + DragTarget::Handle(handle) => HandleId::from(handle).cursor_icon(), + DragTarget::Move => egui::CursorIcon::Grabbing, + _ => egui::CursorIcon::Move, + }; + view.cursor.claim(Layer::Drag, icon); + return true; + } + let m_init = Matrix(drag.initial_matrix); + let [a0, b0, c0, d0, e0, f0] = drag.initial_matrix; + let w0 = (a0 * a0 + b0 * b0).sqrt().max(1e-6); + let h0 = (c0 * c0 + d0 * d0).sqrt().max(1e-6); + let u_axis = (a0 / w0, b0 / w0); + let v_axis = (c0 / h0, d0 / h0); + let center_user = (e0 + 0.5 * (a0 + c0), f0 + 0.5 * (b0 + d0)); + + let (sx, sy) = screen_to_user(xf, info, page, drag.start); + let (px, py) = screen_to_user(xf, info, page, p); + + let (m_new, cursor_icon) = match drag.target { + DragTarget::Rotate => { + let v0 = (sx - center_user.0, sy - center_user.1); + let v1 = (px - center_user.0, py - center_user.1); + let theta0 = v0.1.atan2(v0.0); + let theta1 = v1.1.atan2(v1.0); + let mut delta = theta1 - theta0; + if ui.input(|i| i.modifiers.shift) { + let step = std::f64::consts::PI / 12.0; + delta = (delta / step).round() * step; + } + let (cos_d, sin_d) = (delta.cos(), delta.sin()); + let t_rot = [ + cos_d, + sin_d, + -sin_d, + cos_d, + center_user.0 * (1.0 - cos_d) + center_user.1 * sin_d, + center_user.1 * (1.0 - cos_d) - center_user.0 * sin_d, + ]; + (m_init.then(&Matrix(t_rot)), egui::CursorIcon::Grab) + } + DragTarget::Move => { + let (dx, dy) = (px - sx, py - sy); + (Matrix([a0, b0, c0, d0, e0 + dx, f0 + dy]), egui::CursorIcon::Move) + } + DragTarget::Handle(handle) => { + let p00 = (e0, f0); + let p10 = (e0 + a0, f0 + b0); + let p11 = (e0 + a0 + c0, f0 + b0 + d0); + let p01 = (e0 + c0, f0 + d0); + let p_curr = (px, py); + + let (new_w, new_h, p00_new, icon) = match handle { + ImageHandle::TopRight => { + let anchor = p00; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let du = d.0 * u_axis.0 + d.1 * u_axis.1; + let dv = d.0 * v_axis.0 + d.1 * v_axis.1; + let su = du / w0; + let sv = dv / h0; + let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; + let s = s.max(5.0 / w0).max(5.0 / h0); + (w0 * s, h0 * s, anchor, egui::CursorIcon::ResizeNeSw) + } + ImageHandle::TopLeft => { + let anchor = p10; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let du = d.0 * (-u_axis.0) + d.1 * (-u_axis.1); + let dv = d.0 * v_axis.0 + d.1 * v_axis.1; + let su = du / w0; + let sv = dv / h0; + let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; + let s = s.max(5.0 / w0).max(5.0 / h0); + let nw = w0 * s; + let nh = h0 * s; + (nw, nh, (anchor.0 - nw * u_axis.0, anchor.1 - nw * u_axis.1), egui::CursorIcon::ResizeNwSe) + } + ImageHandle::BottomLeft => { + let anchor = p11; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let du = d.0 * (-u_axis.0) + d.1 * (-u_axis.1); + let dv = d.0 * (-v_axis.0) + d.1 * (-v_axis.1); + let su = du / w0; + let sv = dv / h0; + let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; + let s = s.max(5.0 / w0).max(5.0 / h0); + let nw = w0 * s; + let nh = h0 * s; + ( + nw, + nh, + (anchor.0 - nw * u_axis.0 - nh * v_axis.0, anchor.1 - nw * u_axis.1 - nh * v_axis.1), + egui::CursorIcon::ResizeNeSw, + ) + } + ImageHandle::BottomRight => { + let anchor = p01; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let du = d.0 * u_axis.0 + d.1 * u_axis.1; + let dv = d.0 * (-v_axis.0) + d.1 * (-v_axis.1); + let su = du / w0; + let sv = dv / h0; + let s = if (su - 1.0).abs() >= (sv - 1.0).abs() { su } else { sv }; + let s = s.max(5.0 / w0).max(5.0 / h0); + let nw = w0 * s; + let nh = h0 * s; + (nw, nh, (anchor.0 - nh * v_axis.0, anchor.1 - nh * v_axis.1), egui::CursorIcon::ResizeNwSe) + } + ImageHandle::RightCenter => { + let anchor = p00; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let du = (d.0 * u_axis.0 + d.1 * u_axis.1).max(5.0); + (du, h0, anchor, egui::CursorIcon::ResizeHorizontal) + } + ImageHandle::LeftCenter => { + let anchor = p10; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let du = (d.0 * (-u_axis.0) + d.1 * (-u_axis.1)).max(5.0); + (du, h0, (anchor.0 - du * u_axis.0, anchor.1 - du * u_axis.1), egui::CursorIcon::ResizeHorizontal) + } + ImageHandle::TopCenter => { + let anchor = p00; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let dv = (d.0 * v_axis.0 + d.1 * v_axis.1).max(5.0); + (w0, dv, anchor, egui::CursorIcon::ResizeVertical) + } + ImageHandle::BottomCenter => { + let anchor = p01; + let d = (p_curr.0 - anchor.0, p_curr.1 - anchor.1); + let dv = (d.0 * (-v_axis.0) + d.1 * (-v_axis.1)).max(5.0); + (w0, dv, (anchor.0 - dv * v_axis.0, anchor.1 - dv * v_axis.1), egui::CursorIcon::ResizeVertical) + } + }; + let mat = Matrix([new_w * u_axis.0, new_w * u_axis.1, new_h * v_axis.0, new_h * v_axis.1, p00_new.0, p00_new.1]); + (mat, icon) + } + _ => (m_init, egui::CursorIcon::Default), + }; + + let preview_obb = ImageObb::new(m_new.0, xf, info, page); + if let Some(tex) = view.page_raster(page) { + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + let mut mesh = egui::Mesh::with_texture(tex); + let orig_corners = obb.corners(); + let new_corners = preview_obb.corners(); + for (from, to) in [ + (orig_corners[3], new_corners[3]), + (orig_corners[2], new_corners[2]), + (orig_corners[1], new_corners[1]), + (orig_corners[0], new_corners[0]), + ] { + let (u, v) = xf.screen_to_norm(from); + mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clipped.add(egui::Shape::mesh(mesh)); + } + + if preview_obb.is_rotated { + let pts = preview_obb.corners(); + painter.polygon(pts.to_vec(), ACCENT.gamma_multiply(0.04), Stroke::new(1.5, ACCENT)); + } else { + let p_rect = Rect::from_two_pos(preview_obb.s01, preview_obb.s10); + let visible = p_rect.intersect(xf.rect); + if visible.is_positive() { + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + } + } + for (_, h) in preview_obb.handles() { + draw_handle(painter, h, false, false, false, ACCENT); + } + + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + } + if m_new.0 != drag.initial_matrix { + if matches!(drag.target, DragTarget::Move) { + let (dx, dy) = (px - sx, py - sy); + let new_rect = [image.rect[0] + dx, image.rect[1] + dy, image.rect[2] + dx, image.rect[3] + dy]; + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(new_rect) }); + } else if let Some(inv) = Matrix(drag.initial_matrix).invert() { + let t = inv.then(&m_new); + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Transform(t.0) }); + } + } + } + view.cursor.claim(Layer::Drag, cursor_icon); + return true; + } + if allow_interaction + && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) + && !ui.ctx().egui_wants_keyboard_input() + { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); + return true; + } + } + if !allow_interaction { + return false; + } + let menu_key = image_menu_id(); + let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| { + b.is_positive() + && images.get(i).is_some_and(|img| b.contains(p) || quad_contains(ImageObb::from_image(img, xf, info, page).corners(), p)) + && image_contains(i, p) + }) else { + if resp.secondary_clicked() || resp.clicked() { + view.image_selection = None; + ui.ctx().data_mut(|d| d.insert_persisted(menu_key, false)); + } + return false; + }; + let Some(hit_img) = images.get(hit) else { return false }; + let hit_obb = ImageObb::from_image(hit_img, xf, info, page); + if selected != Some(hit) { + if hit_obb.is_rotated { + let pts = hit_obb.corners(); + for k in 0..4 { + painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7))); + } + } else if let Some(b) = boxes.get(hit).copied() { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + } + let corner = hit_obb.handles().iter().position(|(_, c)| c.distance(p) < 10.0); + let cursor = match (selected == Some(hit), corner) { + (true, Some(0 | 4)) => egui::CursorIcon::ResizeNwSe, + (true, Some(2 | 6)) => egui::CursorIcon::ResizeNeSw, + (true, Some(1 | 5)) => egui::CursorIcon::ResizeVertical, + (true, Some(3 | 7)) => egui::CursorIcon::ResizeHorizontal, + (true, None) => egui::CursorIcon::Grab, + _ => egui::CursorIcon::PointingHand, + }; + view.cursor.claim(Layer::Text, cursor); + let clicked = match view.router.owner(page) { + Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index), + _ => (resp.clicked() || resp.secondary_clicked()).then_some(hit), + }; + if let Some(index) = clicked { + view.image_selection = + Some(ImageSelection { page, index, drag: None, crop: None, crop_mode: false, crop_rect: None, turning: false, settled: None }); + } + if resp.secondary_clicked() { + ui.ctx().data_mut(|d| d.insert_persisted(menu_key, true)); + } + true +} + +/// The right-click context menu for a selected image. +#[allow(clippy::too_many_arguments)] +pub(crate) fn image_context_menu( + ui: &mut egui::Ui, + view: &mut DocView, + images: &[pdfcraft_engine::PageImage], + page: usize, + hit: usize, + can_paste: bool, + action: &mut Option, + at: Option<[f64; 2]>, +) { + let Some(img) = images.get(hit) else { return }; + use pdfcraft_engine::ImageEdit as E; + let raster = !img.is_form; + + let close = |ui: &mut egui::Ui| { + ui.ctx().data_mut(|d| d.insert_persisted(image_menu_id(), false)); + egui::Popup::close_all(ui.ctx()); + ui.close(); + }; + + // Top horizontal icon bar: Comment, Crop, Rotate CCW, Rotate CW + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::icons::button(ui, "message-square", 24.0, false, tl!("Add a sticky note here")).clicked() { + *action = Some(ImageAction::AddComment(page, hit)); + close(ui); + } + if raster && crate::icons::button(ui, "crop", 24.0, false, tl!("Crop")).clicked() { + *action = Some(ImageAction::Crop); + if let Some(s) = view.image_selection.as_mut() { + let rect = img.visible_rect.unwrap_or(img.rect); + s.crop = Some(CropMode::new(rect)); + s.crop_mode = true; + s.crop_rect = None; + } + close(ui); + } + if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(3) }); + close(ui); + } + if crate::icons::button(ui, "rotate-cw", 24.0, false, tl!("Rotate Clockwise")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(1) }); + close(ui); + } + }); + + ui.separator(); + + if ui.button(tl!("Cut Image")).clicked() { + *action = Some(ImageAction::Cut(page, hit)); + close(ui); + } + if ui.button(tl!("Copy Image")).clicked() { + *action = Some(ImageAction::Copy(page, hit)); + close(ui); + } + if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste"))).clicked() { + *action = Some(ImageAction::Paste(page, at)); + close(ui); + } + + ui.separator(); + + let is_cropped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); + if raster && ui.add_enabled(is_cropped, egui::Button::new(tl!("Remove crop"))).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::RemoveCrop }); + close(ui); + } + + if ui.button(tl!("Flip horizontal")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: true } }); + close(ui); + } + if ui.button(tl!("Flip vertical")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: false } }); + close(ui); + } + + ui.menu_button(tl!("Arrange image"), |ui| { + if ui.button(tl!("Bring to front")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront) }); + close(ui); + } + if ui.button(tl!("Send to back")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack) }); + close(ui); + } + if ui.button(tl!("Bring forward")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringForward) }); + close(ui); + } + if ui.button(tl!("Send backward")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward) }); + close(ui); + } + }); + + if ui.add_enabled(raster, egui::Button::new(tl!("Replace image"))).clicked() { + *action = Some(ImageAction::Replace(page, hit)); + close(ui); + } + if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() { + *action = Some(ImageAction::Save(page, hit)); + close(ui); + } + + ui.separator(); + + if ui.button(tl!("Draw freehand")).clicked() { + *action = Some(ImageAction::DrawFreehand); + close(ui); + } + if ui.button(tl!("Redact Image")).clicked() { + *action = Some(ImageAction::Redact(page, hit)); + close(ui); + } + if ui.add_enabled(raster, egui::Button::new(tl!("Export image"))).clicked() { + *action = Some(ImageAction::Save(page, hit)); + close(ui); + } + + ui.separator(); + + if ui.button(tl!("Create Link…")).clicked() { + *action = Some(ImageAction::Link(page, hit)); + close(ui); + } + + ui.menu_button(tl!("Edit image using"), |ui| { + ui.add_enabled(false, egui::Button::new(tl!("Default image editor"))); + }); + + if ui.button(tl!("Recognize Text…")).clicked() { + *action = Some(ImageAction::Recognize(page)); + close(ui); + } + + ui.separator(); + + if ui.button(tl!("Delete")).clicked() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); + close(ui); + } +} diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index c4526e06c8..c515a885a8 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -2098,7 +2098,7 @@ fn accessible_labels_on_format_text_and_pin_dialog() { let font_combo = ok(&mut h, &c, "ui.inspect", json!({"query": "Font", "role": "ComboBox"})); assert!(font_combo["count"].as_u64().unwrap() >= 1, "format-font has accessible label: {font_combo}"); - let size_combo = ok(&mut h, &c, "ui.inspect", json!({"query": "Font size", "role": "ComboBox"})); + let size_combo = ok(&mut h, &c, "ui.inspect", json!({"query": "Font size"})); assert!(size_combo["count"].as_u64().unwrap() >= 1, "format-size has accessible label: {size_combo}"); let line_spacing_combo = ok(&mut h, &c, "ui.inspect", json!({"query": "Line spacing", "role": "ComboBox"}));