fix(comments): Add a text box takes a drag that sizes the box (part of #2669)

This commit is contained in:
Marco Arnulfo
2026-10-16 01:43:30 +02:00
parent 99941daa5a
commit b387802f2d
2 changed files with 188 additions and 11 deletions
+77 -11
View File
@@ -385,7 +385,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],
@@ -928,11 +932,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;
}
@@ -1274,6 +1310,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);
@@ -1352,7 +1391,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",
@@ -1368,7 +1407,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"),
@@ -1419,14 +1458,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;
@@ -1548,6 +1588,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))
+111
View File
@@ -488,6 +488,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());