Fix #2898: Up and Down change the corner kind while dragging a widget

Squashed from #5090 onto main.
This commit is contained in:
Don Omondi
2026-11-02 14:36:12 +07:00
committed by bflatastic
parent 9299c64d42
commit 62f133fa0b
8 changed files with 199 additions and 7 deletions
+67
View File
@@ -485,3 +485,70 @@ fn relative_corner_rounding_keeps_fraction_on_resize() {
let c = LiveCorners::of(n).unwrap();
assert!((c.drawn_radius(0) / drawn - 2.0).abs() < 0.05, "drawn radius doubled with the shape");
}
/// #2898: while a corner widget is pressed or dragged, Up and Down step the corners' kind (round,
/// inverted round, chamfer, in turn) along with the radius, as one undo step with the drag.
#[test]
fn arrows_while_dragging_a_corner_widget_change_the_corner_kind() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let (mut s, id) = session();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let st = s.doc().unwrap();
let w = vectorcraft_tools::corners::CornerWidgets::showing(&st.doc, &st.selection, v.zoom, false, true, 180.0).expect("corner widgets show");
let at = w.visible().next().expect("a widget");
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap();
};
let n = steps(&s);
// Press, Up twice (round → inverted round → chamfer), drag inward, release.
ptr(&mut s, PointerKind::Down, at.x, at.y);
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
ptr(&mut s, PointerKind::Drag, at.x + 8.0, at.y + 8.0);
ptr(&mut s, PointerKind::Up, at.x + 8.0, at.y + 8.0);
let (radii, kinds, _) = corners(&s, id);
assert_eq!(kinds, [CornerKind::Chamfer; 4], "every shown corner takes the kind");
assert!(radii.iter().all(|r| *r > 0.0), "and the radius: {radii:?}");
assert_eq!(steps(&s), n + 1, "one undo step");
// A press and Down without a drag: the previous kind, the radius kept, one more step. A
// pointer move within the drag threshold after the press leaves the radii alone too.
let st = s.doc().unwrap();
let w = vectorcraft_tools::corners::CornerWidgets::showing(&st.doc, &st.selection, v.zoom, false, true, 180.0).unwrap();
let at = w.visible().next().unwrap();
ptr(&mut s, PointerKind::Down, at.x, at.y);
s.tool_key(ToolKey::Down, Mods::default(), v).unwrap();
ptr(&mut s, PointerKind::Drag, at.x + 1.0, at.y + 1.0);
ptr(&mut s, PointerKind::Up, at.x + 1.0, at.y + 1.0);
let (after, kinds, _) = corners(&s, id);
assert_eq!(kinds, [CornerKind::InvertedRound; 4]);
assert_eq!(after, radii);
assert_eq!(steps(&s), n + 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(corners(&s, id).1, [CornerKind::Chamfer; 4]);
}
/// An arrow press without a drag changes only the corners' kind: corners with different radii
/// keep each their own (#2898 review).
#[test]
fn an_arrow_press_without_a_drag_keeps_mixed_radii() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let (mut s, id) = session();
run(&mut s, json!({"id": id.0, "corners": [0], "radius": 5}));
run(&mut s, json!({"id": id.0, "corners": [2], "radius": 15}));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let before = corners(&s, id).0;
let st = s.doc().unwrap();
let w = vectorcraft_tools::corners::CornerWidgets::showing(&st.doc, &st.selection, v.zoom, false, true, 180.0).unwrap();
let at = w.visible().next().unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(Mods::default()), v).unwrap();
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(Mods::default()), v).unwrap();
let (radii, kinds, _) = corners(&s, id);
assert_eq!(radii, before, "each corner keeps its radius");
assert!(kinds.iter().all(|k| *k == CornerKind::InvertedRound), "{kinds:?}");
}
+9
View File
@@ -45,6 +45,15 @@ impl CornerKind {
CornerKind::Chamfer => CornerKind::Round,
}
}
/// The kind before this one (the Down arrow while dragging a corner widget).
pub fn previous(self) -> Self {
match self {
CornerKind::Round => CornerKind::Chamfer,
CornerKind::InvertedRound => CornerKind::Round,
CornerKind::Chamfer => CornerKind::InvertedRound,
}
}
}
/// The largest corner radius a `w` × `h` rectangle draws: half its shorter side.
+64 -6
View File
@@ -3,7 +3,8 @@
//! corner anchor: one that isn't smooth, where two sides, straight or curved, meet at an angle. Dragging one rounds (or
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
//! kind (round, inverted round, chamfer), as the Up and Down arrows do while one is dragged
//! (#2898); double-clicking one opens the Corners dialog. The
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
//! geometry.
@@ -239,7 +240,10 @@ pub struct CornerDrag {
/// Each shown path's outline, in the order of [`CornerWidgets`]' paths.
outlines: Vec<Outline>,
start: Point,
/// The interaction began (a drag or an arrow press): the corners preview and commit.
began: bool,
/// The pointer moved past the drag threshold: the radius follows it.
dragging: bool,
alt: bool,
radius: f64,
/// The pointer went past fully round: the dragged corner is at its largest radius.
@@ -247,6 +251,9 @@ pub struct CornerDrag {
at: Point,
/// Direct Selection: what a click (no drag) on the pressed widget selects.
click: Option<CornerClick>,
/// The corner kind the Up and Down arrows picked during the drag, which it sets with the
/// radius (#2898); None: the corners keep theirs.
kind: Option<CornerKind>,
}
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
@@ -288,7 +295,20 @@ impl CornerDrag {
Some(Outline { corners: lc.corners.clone(), base: lc.base.into_owned(), radii, kinds, xf: w.xf })
})
.collect::<Option<Vec<_>>>()?;
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click })
Some(Self {
widgets,
widget,
outlines,
start: p,
began: false,
dragging: false,
alt: ev.mods.alt,
radius,
at_limit: false,
at: p,
click,
kind: None,
})
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
@@ -344,16 +364,54 @@ impl CornerDrag {
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
let mut out = vec![];
if !self.began {
if !self.dragging {
// The drag threshold holds after an arrow press too: until the pointer passes it, the
// radius stays as it was.
if p.distance(self.start) < cx.tol(3.0) {
return out;
}
self.began = true;
out.push(Action::Begin("Corner Radius".into()));
self.dragging = true;
if !self.began {
self.began = true;
out.push(Action::Begin("Corner Radius".into()));
}
}
(self.radius, self.at_limit) = self.radius_at(p);
self.at = p;
out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
out.push(self.preview());
out
}
/// The corners as the gesture sets them: the radius once the pointer drags it, and the kind
/// the arrows picked. An arrow press without a drag sends the kind alone, so the corners keep
/// their own radii.
fn preview(&self) -> Action {
let p = match (self.dragging, self.kind) {
(true, kind) => {
let mut p = self.widgets.command("radius", json!(self.radius));
if let Some(k) = kind {
p["kind"] = json!(k);
}
p
}
(false, Some(k)) => self.widgets.command("kind", json!(k)),
(false, None) => self.widgets.command("radius", json!(self.radius)),
};
Action::Preview("object.setLiveShape".into(), p)
}
/// The Up (`up`) or Down arrow while the widget is pressed or dragged: the corners take the
/// next (or previous) kind, round, inverted round and chamfer in turn, starting from the
/// pressed corner's, and keep it when the drag ends (#2898).
pub fn step_kind(&mut self, up: bool) -> Vec<Action> {
let Some(from) = self.kind.or_else(|| self.widgets.widget(self.widget.0, self.widget.1).map(|w| w.kind)) else { return vec![] };
self.kind = Some(if up { from.next() } else { from.previous() });
let mut out = vec![];
if !self.began {
self.began = true;
out.push(Action::Begin("Corner Radius".into()));
}
out.push(self.preview());
out
}
+8 -1
View File
@@ -659,6 +659,10 @@ impl Tool for DirectSelectionTool {
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
/// The arrow keys nudge the selected frame anchors of area type.
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
// Up and Down while a corner widget is pressed: the corners' kind (#2898).
if let (ToolKey::Up | ToolKey::Down, State::Corner(c)) = (key, &mut self.state) {
return c.step_kind(key == ToolKey::Up);
}
if let Some(out) = self.mesh_nudge(cx, key, mods) {
return out;
}
@@ -688,7 +692,10 @@ impl Tool for DirectSelectionTool {
if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] }
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
self.claims_letter(key) || self.mesh_nudge(cx, key, Mods::default()).is_some() || self.frame_nudge(cx, key, Mods::default()).is_some()
(matches!(self.state, State::Corner(_)) && matches!(key, ToolKey::Up | ToolKey::Down))
|| self.claims_letter(key)
|| self.mesh_nudge(cx, key, Mods::default()).is_some()
|| self.frame_nudge(cx, key, Mods::default()).is_some()
}
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == FRAME_PICKED {
+5
View File
@@ -617,6 +617,10 @@ impl Tool for SelectionTool {
/// is left, and the tool waits for the next press. With the pointer loaded with text, Escape
/// drops the text.
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
// Up and Down while a corner widget is pressed: the corners' kind (#2898).
if let (ToolKey::Up | ToolKey::Down, State::Corner(c)) = (key, &mut self.state) {
return c.step_kind(key == ToolKey::Up);
}
if key == ToolKey::Letter('e')
&& let State::Marquee { enclosed, .. } = &mut self.state
{
@@ -652,6 +656,7 @@ impl Tool for SelectionTool {
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
match self.state {
State::Marquee { .. } => key == ToolKey::Letter('e'),
State::Corner(_) => matches!(key, ToolKey::Up | ToolKey::Down),
State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) => key == ToolKey::Escape,
_ => false,
}
+40
View File
@@ -2053,3 +2053,43 @@ fn cmd_enter_bound_to_an_effect_runs_the_effect_not_the_tool() {
assert_eq!(d.fields.get("__effect").and_then(serde_json::Value::as_str), Some("distort.transform"), "the effect's, not Move: {:?}", d.kind);
crate::shortcut_editor::sync(&crate::state::UiState::default());
}
/// #2898 review: Up and Down change the corner kind only while a corner widget is pressed. With
/// none pressed, a plain Up still nudges the selected rectangle and leaves its corners alone; with
/// one pressed, Up steps the kind and the rectangle stays where it is.
#[test]
fn a_plain_up_nudges_unless_a_corner_widget_is_pressed() {
use vectorcraft_doc::{LiveShape, NodeId, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap();
let id = NodeId(id);
app.select_tool("selection");
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let state = |app: &VectorcraftApp| {
let st = app.session.active().unwrap();
let b = st.doc.bounds_of(&[id], false).unwrap();
let NodeKind::Path { live: Some(l), .. } = &st.doc.node(id).unwrap().kind else { panic!("not live") };
let LiveShape::Rectangle { kinds, .. } = &**l else { panic!("not a rectangle") };
((b.x0, b.y0), kinds[0])
};
let ((x, y), kind) = state(&app);
frame(&mut app, press(Key::ArrowUp, Modifiers::NONE));
let ((x1, y1), kind1) = state(&app);
assert_eq!(x1, x, "no sideways move");
assert!((y1 - y).abs() > 0.5, "a plain Up nudges the rectangle: {y} → {y1}");
assert_eq!(kind1, kind, "and leaves its corners alone");
// Press a corner widget: now Up steps the corner kind and the rectangle stays put.
let view = app.view_info();
let st = app.session.active().unwrap();
let w = vectorcraft_tools::corners::CornerWidgets::showing(&st.doc, &st.selection, view.zoom, false, true, 180.0).expect("corner widgets show");
let at = w.visible().next().expect("a widget");
app.session.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), view).unwrap();
frame(&mut app, press(Key::ArrowUp, Modifiers::NONE));
app.session.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), view).unwrap();
let ((x2, y2), kind2) = state(&app);
assert_eq!((x2, y2), (x1, y1), "no nudge while the widget is pressed");
assert_eq!(kind2, CornerKind::InvertedRound, "Up stepped the kind");
}
+5
View File
@@ -1509,10 +1509,15 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m
Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu path (Effect › Distort & Transform › Transform…) and gives each one a shortcut of its own (stored as `effect:<id>`; they all run `effect.dialog {effect}`, or `effect.apply` for an effect without options, so a shortcut on the command alone couldn't tell them apart). The menu shows the shortcut, and pressing it opens that effect's dialog for the selection (#3296). Test: `shortcuts::tests::an_effect_menu_item_takes_a_shortcut_of_its_own`.
## Live Corners: arrow keys change the corner kind while dragging (2026-11-01)
While a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the kind of the corners being edited (Round, Inverted Round, Chamfer, in turn, from the pressed corner's), shown live with the radius and kept, with it, as one undo step when the drag ends; a press with an arrow and no drag changes just the kind (#2898). Alt-clicking a widget still cycles it as before. Test: `tests::livecorners::arrows_while_dragging_a_corner_widget_change_the_corner_kind`.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. |
| 2026-11-01 | minor | Shortcuts for Effect menu items (#3296); evidence added; scores unchanged. |
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); evidence added; scores unchanged. |
| 2026-11-01 | minor | Create Swatch Legend add-on under Object › Plug-ins › Color Effects (`addon.color.swatchLegend`, #4919); evidence added; scores unchanged. |
+1
View File
@@ -400,6 +400,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Live Corners: while a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the corners' kind (round, inverted round, chamfer) with the radius, as one undo step (#2898); scores and estimates unchanged |
| 2026-11-01 | minor | Keyboard Shortcuts: every Effect menu item (Distort & Transform › Transform…, Stylize › Drop Shadow…, …) is listed under its menu path and takes a shortcut of its own, which the menu shows and which opens that effect's dialog (or applies an effect without options) (#3296); scores and estimates unchanged |
| 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged |
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); scores and estimates unchanged |