diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 66f9ac8a2..d262c484a 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -393,7 +393,11 @@ pub enum Gesture { #[derive(Clone, Copy, Debug, PartialEq)] pub enum ComposerKind { Note, - TextBox, + /// A text box; `size` is the box dragged out (displayed width and height, in user space + /// units), `None` after a click: then the box fits its text. + TextBox { + size: Option<[f64; 2]>, + }, /// A callout whose arrow touches `point`; the composer's anchor is the text box's top-left. Callout { point: [f64; 2], @@ -1022,11 +1026,43 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, 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 }; + 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; } @@ -1437,6 +1473,9 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin painter.circle_stroke(*first, 5.0, Stroke::new(1.0, SELECT_BLUE)); } } + CommentTool::TextBox if pts.len() == 2 => { + painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Inside); + } CommentTool::Callout if pts.len() == 2 => { painter.line_segment([pts[0], pts[1]], stroke); painter.rect_stroke(Rect::from_min_size(pts[1], vec2(160.0, 40.0) * zoom), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside); @@ -1515,7 +1554,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let c = view.comments.composer.as_mut()?; let title = match c.kind { ComposerKind::Note => "Sticky note", - ComposerKind::TextBox => "Text box", + ComposerKind::TextBox { .. } => "Text box", ComposerKind::Callout { .. } => "Callout", ComposerKind::Caret { .. } => "Inserted text", ComposerKind::Replace => "Replacement text", @@ -1531,7 +1570,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, }); ui.add_space(6.0); let hint = match c.kind { - ComposerKind::TextBox | ComposerKind::Callout { .. } => tl!("Type text"), + ComposerKind::TextBox { .. } | ComposerKind::Callout { .. } => tl!("Type text"), ComposerKind::Caret { .. } => tl!("Text to insert"), ComposerKind::Replace => tl!("Replacement text"), _ => tl!("Add a comment"), @@ -1582,14 +1621,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, view.comments.tool_done = true; Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text)) } - ComposerKind::TextBox => { + ComposerKind::TextBox { size } => { view.comments.tool_done = true; - Some(new_comment( - &cx, - CommentTool::TextBox, - Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0, page_rotation(info, page)), font_size: 12.0 }, - text, - )) + let rotation = page_rotation(info, page); + let border = prefs.style(CommentTool::TextBox).width; + let rect = match (size, info.pages.get(page)) { + (Some(size), Some(p)) => drawn_text_box_rect(p, c.at, size, &text, 12.0, border), + _ => text_box_rect(c.at, &text, 12.0, rotation), + }; + Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect, font_size: 12.0 }, text)) } ComposerKind::Callout { point } => { view.comments.tool_done = true; @@ -1711,6 +1751,32 @@ pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64 pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) } +/// The box dragged between user-space points `a` and `b`: its displayed top-left corner (user +/// space) and its displayed width and height. Measured in the page's view space, so the view's +/// own rotation and zoom (View > Rotate View) do not turn or scale it. +fn drawn_box(page: &PageInfo, a: [f64; 2], b: [f64; 2]) -> ([f64; 2], [f64; 2]) { + let (a, b) = (page.user_to_view(a[0] as f32, a[1] as f32), page.user_to_view(b[0] as f32, b[1] as f32)); + let at = page.view_to_user(a[0].min(b[0]), a[1].min(b[1])); + ([f64::from(at[0]), f64::from(at[1])], [f64::from((a[0] - b[0]).abs()), f64::from((a[1] - b[1]).abs())]) +} + +/// A text box dragged out `size` (displayed width and height) from its displayed top-left corner +/// `at`, with a border `border` wide: the text wraps to its width inside the border, as its +/// appearance lays it out. When the text needs more lines the box grows down, and moves up to stay +/// on the page; text that cannot fit even so keeps the drawn height and is clipped. +pub fn drawn_text_box_rect(page: &PageInfo, at: [f64; 2], size: [f64; 2], text: &str, font_size: f64, border: f64) -> [f64; 4] { + use pdfcraft_engine::annot_text::wrap; + let pad = 2.0 + border.max(0.0); + let [w, drawn] = size; + let lines = wrap(text, font_size, (w - 2.0 * pad).max(1.0)).len().max(1); + let needed = lines as f64 * font_size * 1.2 + 2.0 * pad + 2.0; + let [vx, vy] = page.user_to_view(at[0] as f32, at[1] as f32).map(f64::from); + let bottom = f64::from(page.height); + let (top, h) = if needed <= drawn || needed > bottom { (vy, drawn) } else { (vy.min(bottom - needed).max(0.0), needed) }; + let at = page.view_to_user(vx as f32, top as f32).map(f64::from); + pdfcraft_engine::text_box_at(i64::from(page.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) +} + /// The `/Rotate` of `page` (0 when it is unknown). pub(crate) fn page_rotation(info: &DocInfo, page: usize) -> i64 { info.pages.get(page).map_or(0, |p| i64::from(p.rotation)) diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index a16ccdc58..36878ca35 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -623,6 +623,117 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// The rectangle of the comment whose text starts with `text`. +fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { + let c = comments(h); + c.iter().find(|a| a.contents.as_deref().is_some_and(|t| t.starts_with(text))).unwrap_or_else(|| panic!("no comment {text:?}: {c:?}")).rect +} + +/// Post `text` in the open text box composer. +fn post_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + field(h, "Type text").type_text(text); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); +} + +fn near(r: [f32; 4], want: [f32; 4]) -> bool { + r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1.5) +} + +/// #2669: a drag with Add a text box draws the box at that size, and the text wraps inside it; +/// a click (or a drag of a few pixels) still makes a box sized to its text. +#[test] +fn a_text_box_is_sized_by_dragging() { + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + let (from, to) = (at(&h, 40.0, 150.0), at(&h, 200.0, 100.0)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(to); + h.run_steps(2); + assert!( + matches!(&h.state().views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { points, .. }) if points.len() == 2), + "the box shows while it is dragged" + ); + h.drop_at(to); + h.run_steps(3); + post_text(&mut h, "Drawn"); + let r = rect_of(&h, "Drawn"); + assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "{r:?}"); + + // Too much text for the drawn box: it keeps its width and grows down, moving up so that it + // stays on the page (whose bottom is at 0). + h.state_mut().set_option("quick", "freetext").unwrap(); + drag_pt(&mut h, (20.0, 80.0), (120.0, 70.0)); + post_text(&mut h, &"word ".repeat(20)); + let r = rect_of(&h, "word word"); + assert!((r[0] - 20.0).abs() < 1.5 && (r[2] - 120.0).abs() < 1.5 && r[1].abs() < 0.5 && r[3] > 90.0, "{r:?}"); + + // More text than the page can hold: the box keeps the drawn size and the text is clipped. + h.state_mut().set_option("quick", "freetext").unwrap(); + drag_pt(&mut h, (150.0, 190.0), (250.0, 180.0)); + post_text(&mut h, &"longerwords ".repeat(16)); + let r = rect_of(&h, "longerwords"); + assert!(near(r, [150.0, 180.0, 250.0, 190.0]), "{r:?}"); + + // Drags of two and four pixels are clicks: the box fits its text. + let fitted = pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], "Hi", 12.0, 0); + for (text, x, d) in [("Hi", 200.0, 2.0), ("Hi again", 270.0, 4.0)] { + h.state_mut().set_option("quick", "freetext").unwrap(); + let p = at(&h, x, 40.0); + drag(&mut h, p, p + egui::vec2(d, d)); + post_text(&mut h, text); + let r = rect_of(&h, text).map(f64::from); + let fitted = if text == "Hi" { fitted } else { pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], text, 12.0, 0) }; + assert!((r[2] - r[0] - (fitted[2] - fitted[0])).abs() < 1.0 && (r[3] - r[1] - (fitted[3] - fitted[1])).abs() < 1.0, "{text}: {r:?}"); + } +} + +/// #2669: with the view turned (View > Rotate View) the box lands where it was drawn, not +/// transposed or scaled by the turned page's other side. +#[test] +fn a_dragged_text_box_follows_a_rotated_view() { + for rotation in [90u16, 270] { + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + while h.state().views[0].rotation != rotation { + h.state_mut().views[0].rotate_view(true); + } + h.state_mut().set_option("zoom", "150").unwrap(); + h.run_steps(3); + settle(&mut h); + let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let (u, v) = (x / 300.0, (200.0 - y) / 200.0); + let (a, b) = if rotation == 90 { (1.0 - v, u) } else { (v, 1.0 - u) }; + pos2(r.left() + a * r.width(), r.top() + b * r.height()) + }; + let (from, to) = (screen(&h, 40.0, 150.0), screen(&h, 200.0, 100.0)); + drag(&mut h, from, to); + post_text(&mut h, "Turned"); + let r = rect_of(&h, "Turned"); + assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "view turned {rotation}: {r:?}"); + } +} + +/// #2669: the text of a drawn box wraps inside its border, as its appearance draws it, so a thick +/// border does not cut off the last line. +#[test] +fn a_dragged_text_box_wraps_inside_its_border() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|app| { + app.set_option("quick", "freetext").unwrap(); + app.comment_prefs.set_width(CommentTool::TextBox, 3.0); + }); + // Wide enough for "word word" inside a 2 pt padding, not inside 2 + 3 pt. + let w = pdfcraft_engine::annot_text::text_width("word word", 12.0) as f32 + 6.0; + drag_pt(&mut h, (20.0, 190.0), (20.0 + w, 180.0)); + post_text(&mut h, "word word"); + let r = rect_of(&h, "word word"); + assert!((r[3] - r[1] - (2.0 * 14.4 + 2.0 * 5.0 + 2.0)).abs() < 1.0, "two lines inside the border: {r:?}"); +} + #[test] fn comments_are_selected_moved_and_deleted_with_the_select_tool() { let mut h = harness(|app| app.set_option("quick", "square").unwrap());