Refactor ui tools for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-27 23:12:52 +00:00
parent 2d27306a8d
commit 21cae663d1
18 changed files with 634 additions and 502 deletions
@@ -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<Edit> {
@@ -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")
+276 -240
View File
@@ -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<Pos2>,
origin: Option<Pos2>,
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()) {
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)]
@@ -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.
+190 -148
View File
@@ -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<ImageAction>,
allow_interaction: bool,
) -> bool {
let boxes: Vec<Rect> = 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<usize>,
hovered_box: Option<usize>,
/// The picture being dragged, whose outline would trail behind until the release.
moving: Option<usize>,
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<Rect>,
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>, 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<Rect> = 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<ImageAction>) -> bool {
let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx;
let boxes: Vec<Rect> = 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<Rect> = 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
@@ -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 {
+4 -1
View File
@@ -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)]
+97 -75
View File
@@ -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<egui::Galley>, 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<Edit>) -> Option<Edit> {
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<Edit> {
let mut edit = None;
let stops: Vec<usize> = 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<usize> = 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())
}
+42 -25
View File
@@ -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()
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Calculate, FieldLook, FieldProps, FormField, FormFieldKind, Format, Validate};
// ───────────────────────────────────────────────────────────────────────── Field Properties
@@ -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) {
+1 -1
View File
@@ -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)]
+4 -1
View File
@@ -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 {
+1 -1
View File
@@ -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