Merge PR #3788: M5: drag a line's ends (and a polygon's corners) to change its length and angle

ROADMAP.md: union of progress-log lines.
crates/ui-egui/src/i18n/*.tsv (20 catalogs): union of appended keys.
This commit is contained in:
aiongg
2026-10-22 01:45:21 +00:00
35 changed files with 419 additions and 15 deletions
+1
View File
@@ -175,6 +175,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
+52
View File
@@ -1788,6 +1788,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
apply_text_box(doc, r, rect, meta, false)
}
/// At most this many points reshape a comment (a drawn polygon has a handful).
const MAX_POINTS: usize = 100_000;
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
/// points with room for the line's width and endings, as when the comment was drawn, and the
/// appearance is redrawn. A measurement keeps the points it measured.
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.contains(b"Measure") {
return Err(AnnotError::Invalid("a measurement's points are where it measured".into()));
}
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
let style = Style { width: border_width_of(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
("Polygon", _) => {
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
}
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
};
// Checks the points too: finite, and far enough apart to draw.
let rect = rect_for(&shape, &style)?;
let before = d;
doc.update_dict(r, |d| {
d.set(key.to_vec(), num_array(&points.concat()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
@@ -2096,6 +2138,9 @@ pub struct Summary {
pub intent: Option<String>,
/// A Fill & Sign mark ([`is_fill_sign`]).
pub fill_sign: bool,
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -2150,6 +2195,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
}
}
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
// Keep in step with `pdfcraft_render::inspect`.
let points = match subtype.as_str() {
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
_ => Vec::new(),
};
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
out.push(Summary {
page,
@@ -2169,6 +2220,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
points,
});
}
}
+47
View File
@@ -1405,3 +1405,50 @@ fn free_text_properties_show_legacy_or_absent_border_width() {
.unwrap();
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
}
/// A line's ends and the corners of connected lines and polygons can be moved after drawing:
/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is
/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape.
#[test]
fn lines_and_polygons_are_reshaped_by_their_points() {
let mut doc = fixture();
let l = add_annotation(
&mut doc,
&new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }),
&meta("l"),
)
.unwrap();
let before = ap_content(&doc, &list(&doc, 0)[l]);
set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[l];
let ends: Vec<f64> = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]);
let r = rect(d);
assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}");
assert!(r[2] - r[0] < 40.0, "and no more: {r:?}");
assert_ne!(ap_content(&doc2, d), before, "drawn anew");
assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]);
// Two ends exactly, far enough apart.
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
// A polygon's corner.
let g = add_annotation(
&mut doc,
&new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }),
&meta("g"),
)
.unwrap();
set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap();
let d = &list(&doc, 0)[g];
let v: Vec<f64> = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(v.len(), 8);
assert!(rect(d)[2] > 450.0);
// Locked: refused, and nothing changes.
set_locked(&mut doc, 0, l, true).unwrap();
assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err());
// Other comments have no points to move.
let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap();
assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_))));
}
+25 -3
View File
@@ -174,7 +174,7 @@ impl Automation {
let all = self.comments(a)?;
let view = |c: &Annotation| -> Value {
let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))];
json!({
let mut v = json!({
"id": c.name,
"page": c.page + 1,
"index": c.index + 1,
@@ -184,7 +184,14 @@ impl Automation {
"modified": c.modified,
"rect": rect_to_view(p, c.rect),
"color": c.color.map(hex),
})
});
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
v["points"] = json!(c.points.iter().map(|q| p.user_to_view(q[0], q[1]).map(round)).collect::<Vec<_>>());
}
v
};
let roots: Vec<Value> = all
.iter()
@@ -632,8 +639,23 @@ impl Automation {
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
}
if let Some(pts) = a.get("points") {
let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into());
let points = pts
.as_array()
.ok_or_else(wrong)?
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
edits.push(Edit::ReshapeAnnotation { page, index, points });
}
if edits.is_empty() {
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
return Err(ToolError::InvalidArgs(
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
));
}
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
self.apply(a, edit)
+2 -1
View File
@@ -790,7 +790,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@@ -800,6 +800,7 @@ pub fn tools() -> Vec<ToolDef> {
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
})),
&["doc"],
)),
+18
View File
@@ -6973,3 +6973,21 @@ fn text_box_and_callout_fill_and_border_through_tools() {
assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None);
}
}
/// `comment_edit points` reshapes a line, connected lines or a polygon, in one undo step, and
/// `comment_list` reports the points it takes.
#[test]
fn comments_are_reshaped_through_the_tool() {
let dir = workdir("reshape");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 40] }));
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [120.0, 40.0]]));
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "points": [[20, 40], [20, 140]] }));
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [20.0, 140.0]]));
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Reshape comment");
assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends");
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_))));
}
+13
View File
@@ -263,6 +263,7 @@ fn scope_of(edit: &Edit) -> Scope {
| Edit::ReplaceText { .. }
| Edit::EraseInk { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -1442,6 +1443,13 @@ pub enum Edit {
dx: f64,
dy: f64,
},
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's
/// two ends, the others' corners).
ReshapeAnnotation {
page: usize,
index: usize,
points: Vec<[f64; 2]>,
},
/// Resize a rectangle, oval or text box.
ResizeAnnotation {
page: usize,
@@ -1887,6 +1895,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Lock comment",
"Unlock comment",
"Move comment",
"Reshape comment",
"Resize comment",
"Change comment properties",
"Fill in {name}",
@@ -2012,6 +2021,7 @@ impl Edit {
Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"),
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
Label::of("Change comment properties")
@@ -2168,6 +2178,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::EraseInk { .. }
| Edit::LockAnnotation { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -2434,6 +2445,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
}
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
@@ -2771,6 +2783,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
points: s.points,
})
.collect()
}
+14
View File
@@ -386,6 +386,9 @@ pub struct Annotation {
/// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture
/// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`).
pub fill_sign: bool,
/// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a
/// cloud (their corners, `/Vertices`), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
}
impl Annotation {
@@ -1149,6 +1152,16 @@ impl<'a> Inspector<'a> {
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
let fill_sign = self.is_fill_sign(d, &subtype);
// Keep in step with `pdfcraft_annot::summaries`.
let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" };
let points = match d.get(key).map(|o| self.resolve(o)) {
Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => {
let v: Vec<f32> = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec();
if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts }
}
_ => Vec::new(),
};
info.annotations.push(Annotation {
page,
subtype,
@@ -1167,6 +1180,7 @@ impl<'a> Inspector<'a> {
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
intent: self.name(d, b"IT"),
fill_sign,
points,
});
}
}
+1
View File
@@ -5414,6 +5414,7 @@ mod tests {
locked: false,
intent: None,
fill_sign,
points: Vec::new(),
}
}
+137 -11
View File
@@ -385,6 +385,9 @@ pub enum Gesture {
Move { page: usize, index: usize, from: Pos2 },
/// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move.
Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2, aspect_ratio: Option<f32> },
/// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points`
/// before the drag, in user space) to where the pointer is.
Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> },
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -713,6 +716,42 @@ fn resizable(a: &Annotation) -> bool {
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout")
}
/// Lines, connected lines, polygons and clouds: their ends and corners have handles of their own
/// (measurements keep the points they measured, and a locked comment its shape).
fn reshapable(a: &Annotation) -> bool {
matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon")
&& !a.points.is_empty()
&& !a.locked
&& !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension"))
}
/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools.
/// The drawing tools draw from where they are pressed, so a new line starts at the end of the
/// one just drawn rather than dragging it.
fn grabs_points(tool: QuickTool) -> bool {
!matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points())
}
/// The point of `a` (an end or a corner) whose handle is at `p` on screen.
fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option<usize> {
a.points.iter().position(|q| cx.to_screen([f64::from(q[0]), f64::from(q[1])]).distance(p) <= 7.0)
}
/// `points` with point `i` moved to `to`; with Shift, held to the nearest 45° from the point
/// before it (a line's other end; the first corner turns about the second).
pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> {
let mut out = points.to_vec();
let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) };
let to = match anchor {
Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to),
_ => to,
};
if let Some(p) = out.get_mut(i) {
*p = to;
}
out
}
const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 {
@@ -1025,7 +1064,7 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool
return false;
}
let cv = &view.comments;
if matches!(cv.gesture, Some(Gesture::Resize { page, .. }) if page == cx.page) {
if matches!(cv.gesture, Some(Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) {
return true;
}
// A polygon being clicked out keeps its clicks, even near an old shape's handle.
@@ -1041,7 +1080,8 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati
if cx.hidden {
return false;
}
if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page)
{
return true;
}
let Some(p) = press_point(ui, cx) else { return false };
@@ -1057,12 +1097,10 @@ fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option<Pos2> {
/// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`.
fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation) -> bool) -> bool {
view.comments
.selected
.filter(|(page, _)| *page == cx.page)
.and_then(|(_, i)| cx.get(i))
.filter(|a| pick(a) && cx.allowed && resizable(a))
.is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0))
view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed).is_some_and(|a| {
(resizable(a) && HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0))
|| (grabs_points(cx.tool) && reshapable(a) && point_handle(cx, a, p).is_some())
})
}
fn clamp_to(r: Rect, p: Pos2) -> Pos2 {
@@ -1087,9 +1125,14 @@ fn select_input(
let r = cx.screen_rect(a);
HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0)
};
// Ends and corners of a selected line, connected lines, polygon or cloud.
let point_at =
|p: Pos2| -> Option<usize> { selected.filter(|a| cx.allowed && grabs_points(cx.tool) && reshapable(a)).and_then(|a| point_handle(cx, a, p)) };
let mut consumed = false;
if let Some(p) = pointer {
if let Some(h) = handle_at(p) {
if point_at(p).is_some() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
} else if let Some(h) = handle_at(p) {
ui.ctx().set_cursor_icon(match h {
(0, _) => egui::CursorIcon::ResizeVertical,
(_, 0) => egui::CursorIcon::ResizeHorizontal,
@@ -1105,7 +1148,11 @@ fn select_input(
&& pressed_here
&& let Some(o) = origin
{
if let (Some(h), Some(a)) = (handle_at(o), selected) {
if let (Some(point), Some(a)) = (point_at(o), selected) {
let points = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect();
cv.gesture = Some(Gesture::Reshape { page: cx.page, index: a.index, point, points });
consumed = true;
} else if let (Some(h), Some(a)) = (handle_at(o), selected) {
// On screen, the image is also turned by the view's rotation.
let turned = !cx.xf.rot.is_multiple_of(180);
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
@@ -1127,7 +1174,7 @@ fn select_input(
consumed = true;
}
}
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) {
consumed = true;
}
if resp.drag_stopped()
@@ -1154,6 +1201,14 @@ fn select_input(
}
}
}
Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => {
cv.gesture = None;
let shift = ui.input(|i| i.modifiers.shift);
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
if moved {
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
}
}
_ => {}
}
}
@@ -1264,6 +1319,9 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
return paint_gesture(painter, cx, view);
}
let shift = ui.input(|i| i.modifiers.shift);
if cx.allowed && grabs_points(cx.tool) && reshapable(a) {
return paint_points(painter, cx, view, a, pointer, shift);
}
let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0);
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
if cx.allowed && resizable(a) {
@@ -1276,6 +1334,56 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
paint_gesture(painter, cx, view);
}
/// A selected line, connected lines, polygon or cloud: a handle on each end or corner and, while
/// one is dragged (or its edit is on its way), the new shape over the old.
/// The points a selected line, polygon or cloud is drawn with while it changes: an end or corner
/// being dragged, the whole comment being moved by its body, or the edit just released (until the
/// page is drawn again). `None` while it isn't changing.
fn changing_points(
gesture: Option<&Gesture>,
pending: Option<&Edit>,
(page, index): (usize, usize),
points: &[[f64; 2]],
to_user: impl Fn(Pos2) -> [f64; 2],
pointer: Option<Pos2>,
shift: bool,
) -> Option<Vec<[f64; 2]>> {
let shifted = |dx: f64, dy: f64| points.iter().map(|q| [q[0] + dx, q[1] + dy]).collect();
match (gesture, pending) {
(Some(Gesture::Reshape { page: p, index: i, point, points: from }), _) if (*p, *i) == (page, index) => {
pointer.map(|at| reshaped(from, *point, to_user(at), shift))
}
(Some(Gesture::Move { page: p, index: i, from }), _) if (*p, *i) == (page, index) => pointer.map(|at| {
let (a, b) = (to_user(*from), to_user(at));
shifted(b[0] - a[0], b[1] - a[1])
}),
(_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()),
(_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
_ => None,
}
}
fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option<Pos2>, shift: bool) {
let cv = &view.comments;
let points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect();
let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, shift);
let changed = changing.is_some();
let points = changing.unwrap_or(points);
let screen: Vec<Pos2> = points.iter().map(|q| cx.to_screen(*q)).collect();
if changed {
let stroke = Stroke::new(1.5, SELECT_BLUE);
painter.add(if a.subtype == "Polygon" {
egui::Shape::closed_line(screen.clone(), stroke)
} else {
egui::Shape::line(screen.clone(), stroke)
});
}
for c in screen {
painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
}
paint_gesture(painter, cx, view);
}
fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
let now = painter.ctx().input(|i| i.time);
for s in view.comments.settling.iter().filter(|s| s.page == cx.page && now - s.since < SETTLE_SECS) {
@@ -1984,6 +2092,24 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32)
#[cfg(test)]
mod tests {
use super::*;
/// A selected line is drawn where it is going: moved by its body (both ends follow the
/// pointer), with an end dragged (that end alone), and as released until the page is drawn.
#[test]
fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() {
let line = [[10.0, 10.0], [50.0, 10.0]];
let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)];
let moving = Gesture::Move { page: 0, index: 3, from: pos2(20.0, 10.0) };
let at = Some(pos2(25.0, 30.0));
assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, false), None, "another comment's move");
let released = Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 };
assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
let dragging = Gesture::Reshape { page: 0, index: 3, point: 1, points: line.to_vec() };
let to = Some(pos2(60.0, 10.0));
assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]]));
assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing");
}
use pdfcraft_render::TextGlyph;
/// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn
+1
View File
@@ -972,6 +972,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
| Edit::ReplyToAnnotation { page, .. }
| Edit::SetAnnotationStatus { page, .. }
| Edit::MoveAnnotation { page, .. }
| Edit::ReshapeAnnotation { page, .. }
| Edit::ResizeAnnotation { page, .. }
| Edit::StyleAnnotation { page, .. }
| Edit::FillAnnotation { page, .. }
+2
View File
@@ -2757,3 +2757,5 @@ certificate Version الإصدار
Paragraph spacing تباعد الفقرات
Less أقل
Other options خيارات أخرى
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment إعادة تشكيل التعليق
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2444,3 +2444,5 @@ animation effects Off Изключени
Paragraph spacing Разстояние между абзаците
Less По-малко
Other options Други опции
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Промяна на формата на коментара
Can't render this file because it contains an unexpected character in line 8 and column 16.
+2
View File
@@ -2555,3 +2555,5 @@ animation effects Off Vypnuto
Paragraph spacing Mezera mezi odstavci
Less Méně
Other options Další možnosti
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Změnit tvar komentáře
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2482,3 +2482,5 @@ animation effects Off Aus
Paragraph spacing Absatzabstand
Less Weniger
Other options Weitere Optionen
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Kommentar umformen
Can't render this file because it contains an unexpected character in line 6 and column 51.
+2
View File
@@ -2487,3 +2487,5 @@ animation effects Off Ανενεργά
Paragraph spacing Απόσταση παραγράφων
Less Λιγότερα
Other options Άλλες επιλογές
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Αλλαγή σχήματος σχολίου
Can't render this file because it contains an unexpected character in line 477 and column 9.
+2
View File
@@ -2621,3 +2621,5 @@ animation effects Off Desactivados
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Cambiar la forma del comentario
Can't render this file because it contains an unexpected character in line 6 and column 60.
+2
View File
@@ -2481,3 +2481,5 @@ animation effects Off Désactivés
Paragraph spacing Espacement des paragraphes
Less Moins
Other options Autres options
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Modifier la forme du commentaire
Can't render this file because it contains an unexpected character in line 6 and column 39.
+2
View File
@@ -2480,3 +2480,5 @@ animation effects Off Ki
Paragraph spacing Bekezdéstávolság
Less Kevesebb
Other options Egyéb beállítások
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Megjegyzés alakjának módosítása
Can't render this file because it contains an unexpected character in line 7 and column 44.
+2
View File
@@ -2693,3 +2693,5 @@ certificate SHA-256 digest Impronta SHA-256
Paragraph spacing Spaziatura paragrafi
Less Meno
Other options Altre opzioni
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Modifica la forma del commento
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2481,3 +2481,5 @@ animation effects Off オフ
Paragraph spacing 段落間隔
Less 折りたたむ
Other options その他のオプション
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment コメントの形状を変更
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2479,3 +2479,5 @@ animation effects Off Өшірулі
Paragraph spacing Абзац аралығы
Less Азырақ
Other options Басқа параметрлер
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Пікір пішінін өзгерту
Can't render this file because it contains an unexpected character in line 856 and column 9.
+2
View File
@@ -2731,3 +2731,5 @@ theme Appearance Išvaizda
Paragraph spacing Tarpas tarp pastraipų
Less Mažiau
Other options Kitos parinktys
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Keisti komentaro formą
Can't render this file because it contains an unexpected character in line 404 and column 9.
+2
View File
@@ -2457,3 +2457,5 @@ animation effects Off Wyłączone
Paragraph spacing Odstęp między akapitami
Less Mniej
Other options Inne opcje
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Zmień kształt komentarza
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2495,3 +2495,5 @@ animation effects Off Desativados
Paragraph spacing Espaçamento entre parágrafos
Less Menos
Other options Outras opções
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Alterar a forma do comentário
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2394,3 +2394,5 @@ animation effects Off Выкл
Paragraph spacing Интервал между абзацами
Less Меньше
Other options Другие параметры
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Изменить форму комментария
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2486,3 +2486,5 @@ animation effects Off Izklopljeni
Paragraph spacing Razmik med odstavki
Less Manj
Other options Druge možnosti
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Spremeni obliko komentarja
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2390,3 +2390,5 @@ animation effects Off ఆఫ్
Paragraph spacing పేరా అంతరం
Less తక్కువ
Other options ఇతర ఎంపికలు
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment వ్యాఖ్య ఆకారాన్ని మార్చు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+2
View File
@@ -2477,3 +2477,5 @@ animation effects Off Kapalı
Paragraph spacing Paragraf aralığı
Less Daha az
Other options Diğer seçenekler
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Yorumun şeklini değiştir
Can't render this file because it contains an unexpected character in line 855 and column 9.
+2
View File
@@ -2503,3 +2503,5 @@ animation effects Off Вимкнено
Paragraph spacing Інтервал між абзацами
Less Менше
Other options Інші параметри
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Змінити форму коментаря
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2733,3 +2733,5 @@ key Space Space
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment Đổi hình dạng nhận xét
Can't render this file because it contains an unexpected character in line 491 and column 12.
+2
View File
@@ -2445,3 +2445,5 @@ animation effects Off 关
Paragraph spacing 段落间距
Less 收起
Other options 其他选项
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment 更改注释形状
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -2431,3 +2431,5 @@ animation effects Off 關
Paragraph spacing 段落間距
Less 收起
Other options 其他選項
# Lines, connected lines and polygons reshaped by their ends and corners (M5).
Reshape comment 變更註解形狀
Can't render this file because it contains an unexpected character in line 9 and column 16.
+52
View File
@@ -645,6 +645,58 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
assert_eq!(comments(&h).len(), 1);
}
/// A line's ends have handles: dragging one with the Select tool changes the line's length and
/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a
/// new line starts there rather than dragging the one just drawn.
#[test]
fn line_ends_are_dragged_to_change_length_and_angle() {
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
drag_pt(&mut h, (40.0, 100.0), (140.0, 100.0));
let line = comments(&h).into_iter().find(|a| a.subtype == "Line").expect("a line");
assert_eq!(line.points.len(), 2);
let ends = |h: &Harness<'static, PdfCraftApp>| comments(h).into_iter().find(|a| a.index == line.index).unwrap().points;
// With the line tool, a drag from the end just drawn draws another line.
let first = ends(&h);
drag_pt(&mut h, (first[1][0], first[1][1]), (140.0, 40.0));
assert_eq!(comments(&h).iter().filter(|a| a.subtype == "Line").count(), 2, "a second line");
assert_eq!(ends(&h), first, "the first one stays");
h.state_mut().set_option("quick", "select").unwrap();
click_pt(&mut h, (90.0, 100.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, line.index)));
// Drag the right end up and further right: longer, and turned.
let [_, end] = [line.points[0], line.points[1]];
drag_pt(&mut h, (end[0], end[1]), (200.0, 160.0));
let p = ends(&h);
assert!((p[0][0] - 40.0).abs() < 1.0 && (p[0][1] - 100.0).abs() < 1.0, "the other end stays: {p:?}");
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}");
let id = h.state().views[0].id;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment"));
// Shift: held to the nearest 45° from the other end (39° here).
let a = at(&h, 200.0, 160.0);
let b = at(&h, 150.0, 190.0);
h.hover_at(a);
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.drag_at(a);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(b);
h.run_steps(3);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(1);
let p = ends(&h);
let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]);
assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}");
// Undo puts the line back as it was before the drag.
h.state_mut().undo();
h.run_steps(2);
let p = ends(&h);
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}");
}
#[test]
fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() {
// #2338: the new shape showed its handles, but only the Select tool answered them.
+12
View File
@@ -3959,6 +3959,18 @@ tools = ["comment_delete"]
evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"]
notes = "deletes the pop-up and the reply thread"
[[feature]]
id = "comment.reshape"
title = "Change a line's ends and a polygon's corners after drawing"
area = "E"
tier = "P1"
milestone = "M5"
status = "shipped"
commands = []
tools = ["comment_edit"]
evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/automation.rs::comments_are_reshaped_through_the_tool"]
notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner."
[[feature]]
id = "comment.edit-comment-text"
title = "Edit comment text"