Placement crosshair: kept with its page and document, gone with a pointer placement, kept when the same tool is chosen again

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-02 05:08:41 -05:00
1 parent 367f188b34
commit 56ad0ba14f
7 files changed
+73 -12

No files matched your search

+5 -2
View File
@@ -590,9 +590,12 @@ pub struct ToolState {
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates.
/// Placing, Escape and a tool change take it away.
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
/// Placing, Escape and a change of tool take it away.
pub place_at: Option<Point>,
/// The document (uid) and page the crosshair was shown on.
pub(crate) place_on: Option<(u64, NodeId)>,
}
impl ToolState {
+2
View File
@@ -209,6 +209,8 @@ impl Session {
crate::normalize(ds.doc_mut(), &before);
}
self.commit_interaction()?;
// A placement by the pointer takes the placement crosshair with it, as Enter does.
self.tool.place_at = None;
if !self.prefs.keep_tool {
self.set_tool(ToolId::Move);
}
+15 -4
View File
@@ -86,7 +86,8 @@ impl Session {
}
// The crosshair the arrows moved goes with the placement, which lands on it when no point
// is given.
let crosshair = self.tool.place_at.take();
let crosshair = self.crosshair();
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();
@@ -114,8 +115,11 @@ impl Session {
self.tool.overlay = Overlay::default();
return self.cancel_interaction();
}
// The placement crosshair goes first; the tool stays.
if self.tool.place_at.take().is_some() {
// The placement crosshair goes first; the tool stays. One left on another page or
// document goes quietly.
let shown = self.crosshair().is_some();
self.tool.place_at = None;
if shown {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
@@ -184,7 +188,14 @@ impl Session {
/// Snapshot of tool state for the UI / inspect.
pub fn tool_info(&self) -> serde_json::Value {
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
let crosshair = self.tool.place_at.map(|p| [p.x, p.y]);
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
}
/// The placement crosshair, while the document and page it was shown on are the ones in
/// front (`ToolState::place_at`).
pub fn crosshair(&self) -> Option<Point> {
let here = self.ds().ok().map(|d| (d.uid, d.page));
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
}
}
+4 -1
View File
@@ -76,10 +76,13 @@ impl Session {
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
let _ = crate::vectoredit::end(self);
}
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
if self.tool.tool != t {
self.tool.place_at = None;
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.pick_only = false;
self.tool.place_at = None;
// The tool's cursor shows before the pointer moves.
self.tool.overlay =
Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() };
+9 -4
View File
@@ -534,13 +534,18 @@ 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 v = &s.ds()?.view;
// "It will appear in the middle of the viewport": the first arrow shows it there.
let at = match s.tool.place_at {
let (uid, page, centre) = {
let ds = s.ds()?;
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy))
};
// "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).
let at = match s.crosshair() {
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
None => figcraft_geom::Point::new(v.cx, v.cy),
None => centre,
};
s.tool.place_at = Some(at);
s.tool.place_on = Some((uid, page));
if let Ok(ds) = s.ds_mut() {
ds.revision += 1;
}
@@ -7,6 +7,7 @@
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
#[test]
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
@@ -43,3 +44,39 @@ fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
s.execute("tool.ellipse", &json!({})).unwrap();
assert_eq!(s.tool.place_at, None);
}
#[test]
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
// A click places too (the tool kept by the preference) and takes the crosshair with it.
s.prefs.keep_tool = true;
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
// take, and the next arrow starts over in the middle of this page's view.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
s.execute("page.add", &json!({})).unwrap();
s.execute("page.select", &json!({"index": 1})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
assert_eq!(s.crosshair(), None);
assert!(s.tool_info()["crosshair"].is_null());
s.escape().unwrap();
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
s.execute("tool.rectangle", &json!({})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
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");
}
+1 -1
View File
@@ -419,7 +419,7 @@ fn attach_points(app: &FigApp, painter: &egui::Painter, points: &[([f64; 2], boo
/// The placement crosshair the arrows showed and moved: where Enter puts the next shape or frame.
fn crosshair_overlay(app: &FigApp, painter: &egui::Painter) {
let Some(p) = app.session.tool.place_at.filter(|_| app.session.tool.tool.draws_shape()) else { return };
let Some(p) = app.session.crosshair().filter(|_| app.session.tool.tool.draws_shape()) else { return };
let c = app.to_screen(p.x, p.y);
let col = accent(painter);
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {