From 21cae663d155442b76b4c3f16c9028a7ea215104 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH] Refactor ui tools for strict Clippy checks --- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/comments/geometry.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 516 ++++++++++-------- crates/ui-egui/src/tools/comments/menu.rs | 5 +- crates/ui-egui/src/tools/comments/paint.rs | 5 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- crates/ui-egui/src/tools/edit_text_ui.rs | 2 +- .../src/tools/edit_text_ui/background.rs | 2 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 338 +++++++----- .../ui-egui/src/tools/edit_text_ui/layout.rs | 2 +- crates/ui-egui/src/tools/fill_sign/scan.rs | 5 +- crates/ui-egui/src/tools/forms_ui.rs | 172 +++--- crates/ui-egui/src/tools/measure_ui.rs | 67 ++- crates/ui-egui/src/tools/prepare/draft.rs | 2 +- .../ui-egui/src/tools/prepare/properties.rs | 2 +- crates/ui-egui/src/tools/prepare/tabs.rs | 2 +- crates/ui-egui/src/tools/sign_ui/dialog.rs | 5 +- crates/ui-egui/src/tools/sign_ui/panel.rs | 2 +- 18 files changed, 634 insertions(+), 502 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 8c83c3ba15..fa2d7f9b1a 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -1,7 +1,10 @@ use super::geometry::{drawn_text_box_rect, page_rotation, text_box_rect}; use super::paint::{edited_in_view, new_comment}; use super::state::{ComposerKind, PageCx}; -use super::*; +use super::{ + Color32, CommentPrefs, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NoteIcon, PagePointer, QuickTool, Shape, Tokens, pos2, + theme, +}; /// The floating composer card (new note, new text box, edit). Returns the edit to apply. pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 75d56c2258..051cf09dba 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -1,5 +1,5 @@ use super::state::PageCx; -use super::*; +use super::{Annotation, DocInfo, PageInfo, PageText, Pos2, QuickTool, Rect, pos2, vec2}; pub(super) fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index d8cc33400b..ef0341b716 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -4,7 +4,7 @@ use super::geometry::{ }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::*; +use super::{Annotation, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; /// Comment input on one page, before text selection runs. Returns `true` when the pointer /// gesture belongs to commenting (text selection must ignore it). @@ -31,247 +31,15 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.context_at = Some((cx.page, cx.to_user(p))); cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); } + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { - // The shape just drawn is selected with its handles showing: they resize it without a - // trip to the Select tool (#2338). Anywhere else the tool keeps drawing. QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), - QuickTool::Comment(tool) if tool.draws() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - let p = clamp_to(page_rect, p); - let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { - let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); - if last.distance(p) >= 1.5 { - points.push(u); - } - } else { - points.truncate(1); - points.push(u); - } - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if tool == CommentTool::Eraser { - view.pending_edit = erase(cx, &points); - } else if let Some(shape) = drawn_shape(tool, &points) { - let now = ui.input(|i| i.time); - cv.settle(now, page, tool, &points); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats - // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a - // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink - && resp.clicked() - && over_page - && let Some(p) = pointer - && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) - { - cv.selected = None; - let now = ui.input(|i| i.time); - cv.settle(now, cx.page, tool, &[cx.to_user(p)]); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - true - } - QuickTool::Comment(tool) if tool.clicks_points() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); - // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. - // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. - if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) - && !ui.ctx().egui_wants_keyboard_input() - && ui.input_mut(|i| { - i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) - }) - { - cv.undo_point(); - return true; - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let u = cx.to_user(p); - match cv.gesture.as_mut() { - Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { - // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); - if closes { - let pts = std::mem::take(points); - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &pts) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - return true; - } - if !repeat { - points.push(u); - } - } - _ => { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); - cv.selected = None; - } - } - } - if finish - && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() - && page == cx.page - && t == tool - { - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &points) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - true - } - QuickTool::Comment(CommentTool::Callout) => { - // Drag from what the callout points at to where its text box goes. - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let [point, at] = points[..] - && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 - { - cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); - } - } - true - } - QuickTool::Comment(CommentTool::Attach) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - cv.attach_at = Some((cx.page, cx.to_user(p))); - cv.selected = None; - } - true - } - QuickTool::Comment(CommentTool::Caret) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let (vx, vy) = cx.xf.screen_to_view(p); - let snapped = cx.info.pages.get(cx.page).zip(caret_text.as_deref()).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); - let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); - cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); - cv.selected = None; - } - true - } - QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its - // text wraps inside it (#2669). - if tool == CommentTool::TextBox { - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let (Some(first), Some(last)) = (points.first(), points.last()) { - let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); - // A drag of a few pixels is a click: the box fits its text. - let drawn = - (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); - let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); - cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); - } - } - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; - cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); - cv.selected = None; - } - true - } + QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), + QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), + QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), + QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), + QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), + QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here), QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), _ => false, @@ -512,6 +280,274 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut } } +/// What the pointer is doing on one page, for the comment tools that draw or place things. +#[derive(Clone, Copy)] +struct Gestures<'a> { + ui: &'a egui::Ui, + resp: &'a egui::Response, + cx: &'a PageCx<'a>, + pointer: Option, + origin: Option, + page_rect: Rect, + pressed_here: bool, + over_page: bool, +} + +impl Gestures<'_> { + fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + let p = clamp_to(page_rect, p); + let u = cx.to_user(p); + if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); + if last.distance(p) >= 1.5 { + points.push(u); + } + } else { + points.truncate(1); + points.push(u); + } + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if tool == CommentTool::Eraser { + view.pending_edit = erase(cx, &points); + } else if let Some(shape) = drawn_shape(tool, &points) { + let now = ui.input(|i| i.time); + cv.settle(now, page, tool, &points); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats + // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a + // single PDF point is visible and remains editable like any other drawing. + if tool == CommentTool::Ink + && resp.clicked() + && over_page + && let Some(p) = pointer + && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) + { + cv.selected = None; + let now = ui.input(|i| i.time); + cv.settle(now, cx.page, tool, &[cx.to_user(p)]); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + true + } + + fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. + // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. + if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) + && !ui.ctx().egui_wants_keyboard_input() + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) + { + cv.undo_point(); + return true; + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let u = cx.to_user(p); + match cv.gesture.as_mut() { + Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { + // Clicking the first point again closes a polygon. + let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + if closes { + let pts = std::mem::take(points); + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &pts) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + return true; + } + if !repeat { + points.push(u); + } + } + _ => { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); + cv.selected = None; + } + } + } + if finish + && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() + && page == cx.page + && t == tool + { + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &points) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + true + } + + fn callout(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + // Drag from what the callout points at to where its text box goes. + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let [point, at] = points[..] + && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 + { + cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); + } + } + true + } + + fn attach(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + cv.attach_at = Some((cx.page, cx.to_user(p))); + cv.selected = None; + } + true + } + + fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let (vx, vy) = cx.xf.screen_to_view(p); + let snapped = cx.info.pages.get(cx.page).zip(caret_text).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); + let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); + cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); + cv.selected = None; + } + true + } + + fn note_or_box(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its + // text wraps inside it (#2669). + if tool == CommentTool::TextBox { + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let (Some(first), Some(last)) = (points.first(), points.last()) { + let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); + // A drag of a few pixels is a click: the box fits its text. + let drawn = + (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); + let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); + cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); + } + } + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; + cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); + cv.selected = None; + } + true + } +} + /// Delete / Escape handling for comments (only while no text field has focus). pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { if !ctx.input(|i| i.pointer.any_down()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 3d9a793111..d3de66a4e7 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,6 +1,9 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind}; -use super::*; +use super::{ + Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, + icons, theme, tool_for, vec2, +}; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 55e99d79ac..bfcd67982b 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,6 +1,9 @@ use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; use super::state::{Gesture, PageCx, SETTLE_SECS}; -use super::*; +use super::{ + Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, + Stroke, color32, vec2, +}; /// Paint comment selection, hover and gesture previews on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 97ccb365a2..36149bb2a0 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -1,5 +1,5 @@ use super::geometry::{corner_ratio, is_markup, resized}; -use super::*; +use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect}; /// The user's login name, which Acrobat uses as the default comment author. pub(super) fn login_name() -> String { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 66daa6edb8..297c3d9a31 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -20,7 +20,7 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, ImageSelection}; -pub(crate) use image::{ImageAction, image_input, image_owns_pointer}; +pub(crate) use image::{ImageAction, ImageCx, image_input, image_owns_pointer}; #[cfg(test)] use image::{rotate_knob, turn_degrees}; #[cfg(test)] diff --git a/crates/ui-egui/src/tools/edit_text_ui/background.rs b/crates/ui-egui/src/tools/edit_text_ui/background.rs index 71b2bffd31..591cfbf2a2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/background.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/background.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Color32, Edit, LineEditor, PageXform, Pos2, TextTarget}; /// A cached, read-only page background. The source glyphs are removed before rasterization; /// colour, pictures and unselected text beneath the editor remain visible. 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 9f55a7d5ed..2c231c19d8 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box}; /// A selected page image, and what the pointer is doing to it. #[derive(Clone, Debug, PartialEq)] @@ -46,6 +46,18 @@ impl CropMode { } } +/// 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 screen boxes of the crop's eight handles: corners, then the top, right, bottom and left /// edge midpoints. fn crop_handles(k: Rect) -> [Pos2; 8] { @@ -54,19 +66,8 @@ fn crop_handles(k: Rect) -> [Pos2; 8] { /// 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::too_many_arguments)] -fn crop_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - view: &mut DocView, - i: usize, - b: Rect, - allow_interaction: bool, -) -> bool { +fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; @@ -276,44 +277,26 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { if snap { (d / 15.0).round() * 15.0 } else { d } } -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. -#[allow(clippy::too_many_arguments)] -pub(crate) fn image_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, - allow_interaction: bool, -) -> bool { - let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).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()); - // Few images: faint outlines for all of them, as before. Many: only the selected and - // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. - // Fully clipped placements (empty visible frames) get no outline. - let faint_all = boxes.len() <= MAX_OUTLINES; - // The picture being dragged has its frame at the pointer; its outline where it was would - // trail behind until the release. - let moving = selected.filter(|_| dragging); +/// Which boxes are outlined on the page, and why. +struct OutlineState { + selected: Option, + hovered_box: Option, + /// The picture being dragged, whose outline would trail behind until the release. + moving: Option, + dragging: bool, + /// Few images: faint outlines for all of them. + faint_all: bool, +} + +/// Outlines the images: all of them faintly when there are few, else the selected and hovered +/// ones; the hovered or selected one is emphasised. +fn draw_outlines(painter: &egui::Painter, boxes: &[Rect], state: OutlineState) { + let OutlineState { selected, hovered_box, moving, dragging, faint_all } = state; if faint_all { for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { 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() && b.contains(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) = boxes.get(i).filter(|b| b.is_positive()) { @@ -332,24 +315,169 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } +} + +fn draw_handles(painter: &egui::Painter, rect: Rect) { + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } +} + +/// Turning the picture by its rotate handle: the turned outline as a preview (the engine redraws +/// the pixels on release). +fn turn_preview(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect, knob: Pos2) { + let ImageCx { ui, resp, ptr, page, .. } = *cx; + let painter = ui.painter(); + let hover_pos = ptr.pos; + let shift = ui.input(|i| i.modifiers.shift); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); + // The turned outline as a preview; the engine redraws the pixels on release. + 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); + ui.ctx().set_cursor_icon(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)) }); + } + } +} + +/// Where the screen may still show the picture from before an applied move, and whether the +/// page's whole raster is the old one. +#[derive(Clone, Copy)] +struct Blanked { + left_at: Option, + raster_old: bool, +} + +/// A frame of the picture being moved or resized: the stretched picture, its frame and handles, +/// and on release the edit. +fn drag_preview( + cx: &ImageCx<'_>, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + (i, b): (usize, Rect), + (start, opposite, p): (Pos2, Option, Pos2), + blanked: Blanked, +) { + let ImageCx { ui, resp, xf, ptr, page, info, .. } = *cx; + let painter = ui.painter(); + let Blanked { left_at, raster_old } = blanked; + 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 preview = match opposite { + // Resize from the opposite corner, keeping the aspect ratio. + Some(fixed) => { + let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); + // Use the axis with the larger relative pointer change. Taking the maximum + // absolute size freezes shrinking when the other axis hasn't moved. Measure + // from the press point so grabbing a handle off-centre cannot cause a jump. + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + None => b.translate(p - start), + }; + // Move/resize the full placement by the visible frame's change. The engine + // transforms an isolated crop together with its image, preserving its contents. + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the + // placement's scale instead of producing inf/NaN. + 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 target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + if preview != b + && let Some(tex) = view.page_raster(page) + { + // Its pixels: where the whole-page raster has it. + 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); + draw_handles(painter, preview); + } + 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)) }); + } + } + ui.ctx().set_cursor_icon(match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Move, + }); +} + +/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +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 boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).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()); + // Few images: faint outlines for all of them, as before. Many: only the selected and + // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + // Fully clipped placements (empty visible frames) get no outline. + let faint_all = boxes.len() <= MAX_OUTLINES; + // The picture being dragged has its frame at the pointer; its outline where it was would + // trail behind until the release. + let moving = selected.filter(|_| dragging); + let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); + draw_outlines(painter, &boxes, OutlineState { selected, hovered_box, moving, dragging, faint_all }); // 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 b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); + return crop_input(cx, view, i, b); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); - } - }; // Until the page is rendered again its raster has an applied move or resize's picture // where it was: it stays blanked there and drawn where it went. // The whole-page raster and its tiles are replaced separately: the old frame stays blanked @@ -372,7 +500,7 @@ pub(crate) fn image_input( } if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); + draw_handles(painter, b); } if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); @@ -408,99 +536,13 @@ pub(crate) fn image_input( } } 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)); - // The turned outline as a preview; the engine redraws the pixels on release. - 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); - ui.ctx().set_cursor_icon(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)) }); - } - } + turn_preview(cx, view, i, b, knob); return true; } if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) && let Some(p) = hover_pos { - let preview = match opposite { - // Resize from the opposite corner, keeping the aspect ratio. - Some(fixed) => { - let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; - Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) - } - None => b.translate(p - start), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - 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 target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - // The pixels follow the frame: the page raster's picture of the image, stretched - // into the preview over a blank where it was. Re-rendering every frame of a drag - // would lag; the edit renders it properly on release. - if preview != b - && let Some(tex) = view.page_raster(page) - { - // Its pixels: where the whole-page raster has it. - 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); - handles(preview); - } - 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)) }); - } - } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Move, - }); + drag_preview(cx, images, view, (i, b), (start, opposite, p), Blanked { left_at, raster_old }); return true; } if allow_interaction diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index a59c10899c..533cfa7314 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Color32, FontFamily, FontId, LineEditor, Rect}; /// The look shown for a paragraph: the family and weight guessed from its PDF font name. fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> pdfcraft_engine::AddedText { diff --git a/crates/ui-egui/src/tools/fill_sign/scan.rs b/crates/ui-egui/src/tools/fill_sign/scan.rs index 2b247f53a8..6179a9ab81 100644 --- a/crates/ui-egui/src/tools/fill_sign/scan.rs +++ b/crates/ui-egui/src/tools/fill_sign/scan.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, PhotoRect, Pos2, SCAN_MAX_SIDE, SavedSig, ScanError, Sense, SigDraft, + SignatureImage, SignaturePhoto, Stroke, Tokens, Turn, image_preview, pos2, vec2, +}; /// The Scan tab's photo, the frame around the signature and how strongly faint ink is picked up. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 28494160c5..5a5ba3e1a5 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -623,6 +623,100 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() } +/// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the +/// caret where the press that opened the editor landed, and takes focus when asked. +fn settle_text_editor( + ui: &mut egui::Ui, + fx: &mut Focus, + r: &egui::Response, + laid_out: Option<(std::sync::Arc, Pos2)>, + fitted: Option<(&form_appearance::TextBox, String)>, +) { + if let Some((tb, before)) = fitted + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } + if let Some(press) = fx.press.take() + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The press went to the page, not to this editor, which did not exist yet. + let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); + st.store(ui.ctx(), r.id); + if ui.input(|i| i.pointer.primary_down()) { + ui.ctx().set_dragged_id(r.id); + } + } + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } +} + +/// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab +/// order, returning the edit to apply (the commit's, or `edit`). +fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { + commit(view, form); + let pending = view.pending_edit.take(); + // The next field that can be filled in, check boxes and radio buttons included (#2223), in + // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. + let mut order: Vec<(usize, &FormField, usize)> = form + .iter() + .filter(|x| fillable(x)) + .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) + .collect(); + order.sort_by_key(|o| o.0); + if let Some(pos) = + order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) + { + let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; + let (_, target, wi) = order[k]; + view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); + if let Some(p) = target.widgets[wi].page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + edit.or(pending) +} + +/// The arrows move to the group's next or previous button and select it, wrapping. +fn step_radio(view: &mut DocView, f: &FormField, widget: usize, forward: bool) -> Option { + let mut edit = None; + let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); + if let Some(pos) = stops.iter().position(|&i| i == widget) { + let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; + if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { + view.forms.focus = Some(open_focus(f, wi)); + edit = select_edit(f, target); + if let Some(p) = target.page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + } + edit +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. /// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor /// then takes no more than fits. @@ -714,42 +808,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - if let (Some(tb), Some(before)) = (text_box, before) - && fx.text != before - { - let (kept, caret) = tb.fitted_edit(&before, &fx.text); - if kept != fx.text { - fx.text = kept; - if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); - st.store(ui.ctx(), r.id); - } - ui.ctx().request_repaint(); - } - } - if let Some(press) = fx.press.take() - && let Some((galley, pos)) = laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); - st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { - ui.ctx().set_dragged_id(r.id); - } - } - if fx.request_focus { - r.request_focus(); - fx.request_focus = false; - if std::mem::take(&mut fx.select_all) - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - let n = fx.text.chars().count(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); - st.store(ui.ctx(), r.id); - } - } + settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -890,20 +949,7 @@ pub(crate) fn overlay( // turns the selected button off. edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) }; } else if let Some(forward) = step { - // The arrows move to the group's next or previous button and select it, wrapping. - let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); - if let Some(pos) = stops.iter().position(|&i| i == focus.widget) { - let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; - if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { - view.forms.focus = Some(open_focus(f, wi)); - edit = select_edit(f, target); - if let Some(p) = target.page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - } + edit = step_radio(view, f, focus.widget, forward); } } _ => close = true, @@ -912,31 +958,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - commit(view, form); - let pending = view.pending_edit.take(); - // The next field that can be filled in, check boxes and radio buttons included (#2223), in - // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = order - .iter() - .position(|(_, x, wi)| x.name == f.name && *wi == focus.widget) - .or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; - view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - edit = edit.or(pending); + edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 2e9e600dfc..c72e039b1b 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -166,6 +166,42 @@ impl MeasureView { self.scale = None; } } +/// Snaps `point` to the page's drawn geometry (endpoints, midpoints, intersections, paths), +/// recording in `state` why it couldn't when the content is too complex or unreadable. +fn snap_to_content( + state: &mut MeasureView, + doc: &Document, + page: usize, + view_xy: [f32; 2], + pixels_per_unit: f32, + point: Point, +) -> Option<(Point, measure::snap::SnapKind)> { + if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { + state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); + } + if let Some((_, _, Err(e))) = &state.paths { + state.error = Some(e.clone()); + } + if let Some((_, _, Ok(paths))) = &state.paths { + if paths.truncated { + state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); + } else if paths.unreadable > 0 { + state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); + } + let tolerance = doc + .measurement_to_user(page, [f64::from(view_xy[0]) + state.sensitivity / f64::from(pixels_per_unit), f64::from(view_xy[1])]) + .map(|p| measure::distance(point, p)) + .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); + if state.snaps.intersections && paths.intersection_limited(point, tolerance) { + state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); + } + if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { + return Some((hit.point, hit.kind)); + } + } + None +} + pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, @@ -204,32 +240,13 @@ pub(crate) fn page_input( let (vx, vy) = xf.screen_to_view(pos); let [x, y] = info.view_to_user(vx, vy); let mut point = [f64::from(x), f64::from(y)]; + let mut snapped = None; if state.snap_enabled { - if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { - state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); - } - let pixels_per_unit = xf.zoom().max(1e-6); - if let Some((_, _, Err(e))) = &state.paths { - state.error = Some(e.clone()); - } - if let Some((_, _, Ok(paths))) = &state.paths { - if paths.truncated { - state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); - } else if paths.unreadable > 0 { - state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); - } - let tolerance = doc - .measurement_to_user(page, [f64::from(vx) + state.sensitivity / f64::from(pixels_per_unit), f64::from(vy)]) - .map(|p| measure::distance(point, p)) - .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); - if state.snaps.intersections && paths.intersection_limited(point, tolerance) { - state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); - } - if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { - point = hit.point; - snap = Some(hit.kind); - } - } + snapped = snap_to_content(state, doc, page, [vx, vy], xf.zoom().max(1e-6), point); + } + if let Some((snapped_point, kind)) = snapped { + point = snapped_point; + snap = Some(kind); } if ui.input(|i| i.modifiers.shift) && let Some(last) = state.points.last() diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs index 5bba8fe185..c6f9bf4451 100644 --- a/crates/ui-egui/src/tools/prepare/draft.rs +++ b/crates/ui-egui/src/tools/prepare/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Calculate, FieldLook, FieldProps, FormField, FormFieldKind, Format, Validate}; // ───────────────────────────────────────────────────────────────────────── Field Properties diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index 8099d57489..f922143f6d 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -1,6 +1,6 @@ use super::draft::{FieldDraft, FieldTab}; use super::tabs::{actions_tab, calculate_tab, format_tab, validate_tab}; -use super::*; +use super::{BorderStyle, FieldFont, FormFieldKind, theme, widgets}; /// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index b74e67dd19..04831353bd 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -1,5 +1,5 @@ use super::draft::{ACTION_KINDS, ActionDraft, FieldDraft}; -use super::*; +use super::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value, theme, widgets}; /// The categories of the Format tab. #[derive(Clone, Copy, PartialEq)] diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index ad5ef4b01d..feb4fdd207 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic, + SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets, +}; /// Draw the signing dialogs; returns `true` when the dialog should close. pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { diff --git a/crates/ui-egui/src/tools/sign_ui/panel.rs b/crates/ui-egui/src/tools/sign_ui/panel.rs index af5bfd77cf..415c035f00 100644 --- a/crates/ui-egui/src/tools/sign_ui/panel.rs +++ b/crates/ui-egui/src/tools/sign_ui/panel.rs @@ -1,5 +1,5 @@ use super::dialog::title; -use super::*; +use super::{Align, Certificate, Color32, Layout, SignatureInfo, SignatureStatus, Tokens, icons, sign, theme, widgets}; /// The message bar for signed documents: (icon, colour, template, argument). /// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows