Crosshair: the board Shapes tool places on it, and the view pans to keep it in sight

draws_shape includes BoardShape (tool.place lets the tool centre its own shape); a nudge past an edge of the view pans by the overshoot. Tests; docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-02 07:23:33 -05:00
1 parent 41ee4584cb
commit 90bec33322
5 files changed
+52 -4

No files matched your search

+1
View File
@@ -201,6 +201,7 @@ impl ToolId {
| ToolId::Star
| ToolId::Line
| ToolId::Arrow
| ToolId::BoardShape
)
}
}
+2 -1
View File
@@ -90,7 +90,8 @@ impl Session {
self.tool.place_at = None;
let v = &self.ds()?.view;
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
let (w, h) = tool.click_size();
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
if r.is_err() {
+8 -2
View File
@@ -541,9 +541,10 @@ pub fn specs() -> Vec<CommandSpec> {
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
let (dx, dy) = (dir("dx"), dir("dy"));
let (uid, page, centre) = {
let (uid, page, centre, half) = {
let ds = s.ds()?;
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy))
let z = ds.view.zoom.max(1e-6);
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
};
// "It will appear in the middle of the viewport": the first arrow shows it there (a
// crosshair left on another page or document starts over).
@@ -554,6 +555,11 @@ pub fn specs() -> Vec<CommandSpec> {
s.tool.place_at = Some(at);
s.tool.place_on = Some((uid, page));
if let Ok(ds) = s.ds_mut() {
// "Move the crosshair around the canvas": nudged past an edge of the view, the
// view pans by the overshoot to keep it in sight.
let v = &mut ds.view;
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
ds.revision += 1;
}
Ok(json!({"x": at.x, "y": at.y}))
@@ -80,3 +80,43 @@ fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_sa
let v = &s.ds().unwrap().view;
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
}
#[test]
fn the_view_pans_to_keep_the_crosshair_in_sight() {
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
// of the view, the view follows it by the overshoot; inside it, the view stays put.
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
for _ in 0..6 {
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
for _ in 0..5 {
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
}
#[test]
fn the_board_shapes_tool_places_on_the_crosshair_too() {
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
// on it (the tool centres its own 160 px shape on a press).
let mut s = crate::board_tests::board();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
assert_eq!(s.tool.place_at, None);
}
+1 -1
View File
@@ -177,7 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |