Merge pull request #5042 from dotCooCoo/fix/issue-3526-docs

Fix #3526: the bounding box follows a resize drag
This commit is contained in:
BFlat
2026-11-09 10:46:34 +08:00
committed by GitHub
6 changed files with 164 additions and 20 deletions
+4 -3
View File
@@ -253,9 +253,10 @@ pub struct Session {
/// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]).
/// Tests set it.
pub search_rules: Option<cmd::findfiles::Rules>,
/// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared
/// when the preview params change or the interaction ends.
pub(crate) deferred_overlay_cache: Option<(String, Value, Vec<vectorcraft_tools::Overlay>)>,
/// Canvas feedback cached for the pending deferred preview ([`Session::overlays`],
/// [`Session::deferred_transform_box`]); cleared when the preview params change or the
/// interaction ends.
pub(crate) deferred_overlay_cache: Option<tooling::DeferredFeedback>,
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
+48 -13
View File
@@ -766,6 +766,11 @@ impl Session {
self.tool.transforming()
}
/// Is the active tool resizing the selection by a bounding-box handle?
pub fn tool_resizing(&self) -> bool {
self.tool.resizing()
}
/// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection
/// so far: the document moves on release, and the bounding box shows at this offset meanwhile.
pub fn deferred_move(&self) -> Option<vectorcraft_geom::Vec2> {
@@ -1087,25 +1092,48 @@ impl Session {
Ok(ui)
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched, then put the document back at the interaction snapshot.
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) {
return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default();
/// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the
/// pending preview of a handle resize leaves it, measured once per preview step; `None` when no
/// such preview is pending, or when the preview fails.
pub fn deferred_transform_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
if !self.tool.resizing() {
return None;
}
let (cmd, p) = self.unapplied_interaction_preview()?;
self.deferred_feedback(&cmd, &p)?.transform_box
}
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default()
}
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
/// outlines from the objects it touched (and, during a handle resize, their transform box), then
/// put the document back at the interaction snapshot.
fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> {
if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) {
let feedback = self.measure_deferred_preview(cmd, p)?;
self.deferred_overlay_cache = Some(feedback);
}
self.deferred_overlay_cache.as_ref()
}
fn measure_deferred_preview(&mut self, cmd: &str, p: &Value) -> Option<DeferredFeedback> {
let (snapshot, selection, revision, paint, interaction_fields) = {
let Some(st) = self.active() else { return vec![] };
let Some(it) = st.interaction.as_ref() else { return vec![] };
let st = self.active()?;
let it = st.interaction.as_ref()?;
if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) {
return vec![];
return None;
}
let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone());
(st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields)
};
let overlays = if self.preview(cmd, p).is_ok() {
self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default()
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default();
let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default();
(overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None })
} else {
vec![]
(vec![], None)
};
if let Ok(st) = self.doc_mut() {
st.doc = snapshot;
@@ -1118,11 +1146,18 @@ impl Session {
}
}
self.pending_paint = paint;
self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone()));
overlays
Some(DeferredFeedback { cmd: cmd.to_string(), params: p.clone(), overlays, transform_box })
}
}
/// What the canvas shows for a deferred preview: see [`Session::deferred_transform_box`].
pub(crate) struct DeferredFeedback {
cmd: String,
params: Value,
overlays: Vec<Overlay>,
transform_box: Option<vectorcraft_doc::OrientedBox>,
}
/// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the
/// canvas's limit on the anchor points it outlines (`OVERLAY_MAX_ANCHORS` in
/// `crates/ui-egui/src/canvas/outline.rs`). Past it the selection's bounds are drawn instead.
+6 -1
View File
@@ -148,10 +148,15 @@ pub trait Tool: Send {
false
}
/// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box,
/// so only the art is seen going with the pointer)?
/// so only the art is seen going with the pointer, except during a handle resize)?
fn transforming(&self) -> bool {
false
}
/// Is the transforming drag a resize by a bounding-box handle? The canvas then keeps drawing
/// the bounding box at the changing bounds.
fn resizing(&self) -> bool {
false
}
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
/// with a stop selected takes Delete and the arrows)?
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
+4
View File
@@ -822,6 +822,10 @@ impl Tool for SelectionTool {
self.alt_hover
}
fn resizing(&self) -> bool {
matches!(self.state, State::Scaling { .. })
}
fn transforming(&self) -> bool {
match &self.state {
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
+12 -3
View File
@@ -110,9 +110,10 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
// The Selection tool's bounding box (rotated with the objects after a rotation), also shown by
// the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It
// hides while a drag moves or resizes the selection, so only the art is seen going with the
// pointer (#712).
// pointer (#712). A handle resize keeps it at the changing bounds (#3526).
// With Real-time Drawing off the art waits for the release of a move drag, and the box moves
// with the pointer instead.
let resizing = app.session.tool_resizing();
let moved = app.session.deferred_move();
let shape_tool = vectorcraft_tools::shape::shows_shape_widgets(app.session.tool_id());
// A shape tool drawing a shape shows none of this; adjusting a widget, it shows the widgets.
@@ -122,10 +123,18 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false);
let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection");
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && !app.session.tool_busy()))
&& (!app.session.tool_transforming() || moved.is_some())
&& (resizing || !app.session.tool_transforming() || moved.is_some())
&& app.ui.view.bounding_box
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
let bbox = if show_box { app.selection_box() } else { None };
// With Real-time Drawing off the document stays at the drag's snapshot, so a resize measures the
// box from the pending preview; otherwise each step's revision measures it once (`selection_box`).
let bbox = if !show_box {
None
} else if let Some(b) = resizing.then(|| app.session.deferred_transform_box()).flatten() {
Some(b)
} else {
app.selection_box()
};
let bbox = bbox.map(|b| match moved {
Some(d) => vectorcraft_doc::OrientedBox { rect: b.rect + b.to_local(d.to_point()).to_vec2(), ..b },
None => b,
+90
View File
@@ -3931,6 +3931,96 @@ fn hidden_corner_widgets_do_not_answer_the_pointer() {
assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app));
}
/// One headless canvas frame → the bounding box drawn: its outline's corners on screen (the
/// closed four-point line in the layer color; `None` when it is hidden) and the number of its
/// handles (white 6 px squares).
fn bounding_box_drawn(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option<Vec<Pos2>>, usize) {
let st = app.session.active().unwrap();
let layer = egui::epaint::ColorMode::Solid(c32(st.doc.layer_color(st.selection.objects[0])));
let drawn = shapes(app, ctx);
let outline = drawn.iter().find_map(|s| match s {
Shape::Path(ps) if ps.closed && ps.points.len() == 4 && ps.stroke.color == layer => Some(ps.points.clone()),
_ => None,
});
let handle = |s: &&Shape| matches!(s, Shape::Rect(r) if r.rect.size() == vec2(6.0, 6.0) && r.fill == Color32::WHITE);
(outline, drawn.iter().filter(handle).count())
}
/// The selection's bounding box corners on screen, as measured from the document.
fn box_corners(app: &mut VectorcraftApp) -> Vec<Pos2> {
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
app.selection_box().unwrap().corners().iter().map(|q| xf.to_screen(*q)).collect()
}
/// Do two outlines have the same corners, to 0.01 px?
fn same_corners(a: &[Pos2], b: &[Pos2]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(p, q)| p.distance(*q) < 0.01)
}
/// #3526: a resize by a bounding-box handle keeps the bounding box and its handles drawn at the
/// bounds the drag gives, with Real-time Drawing on and off (off, the document stays at the drag's
/// snapshot until the release).
#[test]
fn a_resize_drag_keeps_the_bounding_box_at_the_new_bounds() {
for real_time in [true, false] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.select_tool("selection");
let before = box_corners(&mut app);
let (outline, handles) = bounding_box_drawn(&mut app, &ctx);
assert!(outline.is_some_and(|o| same_corners(&o, &before)) && handles == 8, "real time {real_time}: the box at rest");
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 160.0), view).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 263.0, 211.0), view).unwrap();
assert!(app.session.tool_resizing(), "real time {real_time}: the handle was hit");
let (during, handles) = bounding_box_drawn(&mut app, &ctx);
assert_eq!(handles, 8, "real time {real_time}: the handles show during the resize");
app.session.pointer(&PointerEvent::new(PointerKind::Up, 263.0, 211.0), view).unwrap();
let after = box_corners(&mut app);
assert!(!same_corners(&after, &before), "real time {real_time}: the drag resized the ellipse");
assert!(during.as_ref().is_some_and(|o| same_corners(o, &after)), "real time {real_time}: {during:?}, resized to {after:?}");
let (outline, handles) = bounding_box_drawn(&mut app, &ctx);
assert!(outline.is_some_and(|o| same_corners(&o, &after)) && handles == 8, "real time {real_time}: the box after the release");
}
}
/// A move, a rotation and a corner-radius drag keep hiding the bounding box and its handles until
/// the release (#712, #1229).
#[test]
fn a_move_rotate_or_corner_radius_drag_hides_the_bounding_box() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.select_tool("selection");
let drag = |app: &mut VectorcraftApp, from: Point, to: Point| {
let view = app.view_info();
app.session.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), view).unwrap();
app.session.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), view).unwrap();
assert!(app.session.tool_transforming() && !app.session.tool_resizing());
let during = bounding_box_drawn(app, &ctx);
app.session.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), view).unwrap();
during
};
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8);
assert_eq!(drag(&mut app, Point::new(150.0, 150.0), Point::new(160.0, 160.0)), (None, 0), "a move");
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the move");
let offset = 10.0 / app.view_info().zoom;
assert_eq!(drag(&mut app, Point::new(210.0 + offset, 210.0 + offset), Point::new(260.0, 160.0)), (None, 0), "a rotation");
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the rotation");
let corner = {
let st = app.session.active().unwrap();
vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap()
};
assert_eq!(drag(&mut app, corner, corner + vectorcraft_geom::Vec2::new(10.0, 10.0)), (None, 0), "a corner-radius drag");
assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the corner-radius drag");
}
/// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection
/// tool then answers neither: a double-click on the widget's place leaves point type as it is,
/// and a drag from a corner handle's place does not scale. With edges shown, both answer.