mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 06:01:18 +00:00
Fix #3526: the bounding box follows a resize drag
With View > Show Bounding Box on, a resize by a bounding-box handle keeps the box and its handles drawn at the bounds the drag gives. A move, a rotation and a corner-radius drag still hide the box until the release (#712). With Real-time Drawing off the document stays at the drag's snapshot, so the box is measured from the drag's pending preview, through the same cache as the deferred outlines.
This commit is contained in:
@@ -208,9 +208,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>,
|
||||
|
||||
@@ -572,6 +572,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()
|
||||
}
|
||||
|
||||
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
|
||||
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
@@ -833,25 +838,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;
|
||||
@@ -864,11 +892,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>,
|
||||
}
|
||||
|
||||
fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec<Overlay> {
|
||||
selection
|
||||
.objects
|
||||
|
||||
@@ -139,10 +139,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 {
|
||||
|
||||
@@ -665,6 +665,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,
|
||||
|
||||
@@ -91,12 +91,22 @@ fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f
|
||||
|
||||
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
|
||||
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
|
||||
// a drag moves or rotates the selection, so only the art is seen going with the pointer (#712).
|
||||
// A handle resize keeps it at the changing bounds (#3526).
|
||||
let resizing = app.session.tool_resizing();
|
||||
let show_box = app.session.tool_id() == "selection"
|
||||
&& !app.session.tool_transforming()
|
||||
&& (resizing || !app.session.tool_transforming())
|
||||
&& 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 the box is measured
|
||||
// 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 st = app.session.active();
|
||||
let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
|
||||
let big_bounds = if big { app.selection_bounds() } else { None };
|
||||
|
||||
@@ -3638,6 +3638,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.
|
||||
|
||||
Reference in New Issue
Block a user