diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 4a38efc82..c32197518 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -57,7 +57,7 @@ use crate::bbox::move_delta; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{HandleSnap, Leave, PointSnap, Targets}; use crate::meshedit::{MeshEdit, grid_of}; -use crate::pathtype::{self, BracketDrag, over_bracket}; +use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; use crate::select::{MoveSnap, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -227,6 +227,8 @@ pub struct DirectSelectionTool { /// The already selected frame anchor the press landed on without Shift: a click (no drag) /// selects it alone, as a drag moves every selected frame anchor with it. frame_click: Option<(NodeId, AnchorRef)>, + /// Where the pointer last hovered: the type on a path bracket under it draws thicker. + bracket_hover: Option, } impl DirectSelectionTool { @@ -253,6 +255,7 @@ impl DirectSelectionTool { frame: BTreeMap::new(), frame_revision: (0, 0), frame_click: None, + bracket_hover: None, } } @@ -643,6 +646,11 @@ fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { } impl Tool for DirectSelectionTool { + /// The pointer hovers nothing for the tool: no bracket is highlighted. + fn hover_ended(&mut self, _cx: &ToolContext) { + self.bracket_hover = None; + } + fn id(&self) -> &'static str { if self.group { "groupSelection" } else { "directSelection" } } @@ -703,6 +711,7 @@ impl Tool for DirectSelectionTool { } } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.bracket_hover = (ev.kind == PointerKind::Move).then_some(ev.pos); if let Some(out) = self.guide.pointer(cx, ev) { return out; } @@ -727,7 +736,7 @@ impl Tool for DirectSelectionTool { return vec![]; } if !over_path_type_anchor(cx, p) - && let Some(b) = BracketDrag::hit(cx, ev) + && let Some(b) = BracketDrag::hit_any(cx, ev) { self.state = State::Bracket(b); return vec![]; @@ -915,7 +924,8 @@ impl Tool for DirectSelectionTool { out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id))); if !self.group { out.extend(frame_overlays(cx, &self.frame_anchors(cx))); - out.extend(pathtype::overlays(cx)); + let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; + out.extend(pathtype::overlays(cx, self.bracket_hover, drag)); } if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) { out.extend(self.guides.iter().cloned()); @@ -932,7 +942,7 @@ impl Tool for DirectSelectionTool { if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) { return Cursor::CornerRadius; } - if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p) && !over_path_type_anchor(cx, p)) { + if !self.group && (matches!(self.state, State::Bracket(_)) || near_bracket(cx, p) && !over_path_type_anchor(cx, p)) { return Cursor::PathBracket; } let rest = if self.group { Cursor::GroupSelection } else { Cursor::ArrowHollow }; diff --git a/crates/tools/src/pathtype.rs b/crates/tools/src/pathtype.rs index 21fdaefc9..9f888de6c 100644 --- a/crates/tools/src/pathtype.rs +++ b/crates/tools/src/pathtype.rs @@ -3,8 +3,12 @@ //! type begins or ends along its path; dragging the centre bracket slides the type along the path //! (its brackets with it) and, dragged across the path, flips the type to the other side //! (Cmd/Ctrl held: it only slides). Each drag is one undo step of `type.pathOptions`. The tools -//! draw the brackets from the same geometry they hit-test. With more than 10,000 type on a path -//! objects selected, they neither draw nor hit-test any bracket. +//! draw the brackets from the same geometry they hit-test. A press within [`HIT_PX`] of a bracket +//! takes the nearest one, and the bracket a press would take draws thicker under the pointer. In +//! the Selection tool, which loads text from ports, the nearer of a bracket and a text port takes a +//! press (and the highlight); Direct Selection has no ports, so a bracket within reach always takes +//! it there. With more than 10,000 type on a path objects selected, they neither draw nor hit-test +//! any bracket. use serde_json::{Map, Value, json}; use vectorcraft_doc::{NodeId, NodeKind, TextKind}; @@ -20,6 +24,10 @@ const DOWN_PX: f64 = 4.0; const FOOT_PX: f64 = 4.0; /// How far past the path (screen px) the centre bracket goes before the type flips. const FLIP_PX: f64 = 6.0; +/// How near (screen px) a press must be to a bracket to take it. +const HIT_PX: f64 = 8.0; +/// The stroke width (screen px) of the bracket under the pointer; the others are hairlines. +const HOT_WIDTH: f32 = 2.5; /// The most points the tools draw for the brackets of a selection, the same number as the canvas's /// limit on the anchor points it outlines (`OVERLAY_MAX_ANCHORS` in `crates/ui-egui/src/canvas.rs`). /// Past it the tools neither draw nor hit-test any bracket. @@ -107,9 +115,14 @@ impl Brackets { } } - /// The bracket within `tol` (document units) of `p`. - fn hit(&self, p: Point, zoom: f64, tol: f64) -> Option { - Bracket::ALL.into_iter().find(|b| self.lines(*b, zoom).iter().any(|(a, c)| Line::new(*a, *c).nearest(p, 1e-9).distance_sq <= tol * tol)) + /// How far (document units) bracket `b` is from `p`. + fn distance(&self, b: Bracket, p: Point, zoom: f64) -> f64 { + self.lines(b, zoom).into_iter().map(|(a, c)| Line::new(a, c).nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + } + + /// The bracket nearest `p` within `tol` (document units), and how far it is. + fn hit(&self, p: Point, zoom: f64, tol: f64) -> Option<(Bracket, f64)> { + Bracket::ALL.into_iter().map(|b| (b, self.distance(b, p, zoom))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)) } /// The fraction of the path's length nearest document point `p`. @@ -125,29 +138,64 @@ fn too_many(cx: &ToolContext) -> bool { cx.selection.objects.iter().filter(on_path).nth(MAX_POINTS / POINTS_EACH).is_some() } -/// The selected type on a path's bracket under `p`, if any. -fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket)> { - Brackets::of(cx).into_iter().find_map(|b| { - let which = b.hit(p, cx.zoom, cx.tol(5.0))?; - Some((b, which)) - }) +/// The selected type on a path's bracket nearest `p` within [`HIT_PX`], and how far it is. +fn bracket_at(cx: &ToolContext, p: Point) -> Option<(Brackets, Bracket, f64)> { + Brackets::of(cx) + .into_iter() + .filter_map(|b| { + let (which, d) = b.hit(p, cx.zoom, cx.tol(HIT_PX))?; + Some((b, which, d)) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)) } -/// Is `p` over a bracket of the selected type on a path? +/// How far (document units) the bracket of the selected type on a path under `p` is from it; None +/// when no bracket is within reach. +pub fn bracket_distance(cx: &ToolContext, p: Point) -> Option { + if too_many(cx) { + return None; + } + bracket_at(cx, p).map(|(_, _, d)| d) +} + +/// Is `p` within reach of a bracket of the selected type on a path, ports or not (Direct +/// Selection, which takes no clicks on ports)? +pub fn near_bracket(cx: &ToolContext, p: Point) -> bool { + bracket_distance(cx, p).is_some() +} + +/// Is `p` over a bracket of the selected type on a path, nearer than any text port (a start +/// bracket and an in port sit side by side: the nearer takes the press)? For the Selection tool, +/// which loads text from ports. pub fn over_bracket(cx: &ToolContext, p: Point) -> bool { - !too_many(cx) && bracket_at(cx, p).is_some() + bracket_distance(cx, p).is_some_and(|d| crate::text::port_near(cx, p).is_none_or(|(_, _, port)| d <= port)) } -/// The brackets of the selected type on a path, in their layer's colour. -pub fn overlays(cx: &ToolContext) -> Vec { +/// The brackets of the selected type on a path, in their layer's colour: the one under `hover` +/// thicker (the caller passes only a hover that would take the bracket), or the one `drag` moves. +pub fn overlays(cx: &ToolContext, hover: Option, drag: Option<&BracketDrag>) -> Vec { if too_many(cx) { return vec![]; } + let hot = match drag { + Some(d) => Some((d.brackets.id, d.which)), + None => hover.and_then(|p| bracket_at(cx, p)).map(|(b, which, _)| (b.id, which)), + }; let mut out = vec![]; for b in Brackets::of(cx) { let color = cx.doc.layer_color(b.id); for which in Bracket::ALL { - out.extend(b.lines(which, cx.zoom).into_iter().map(|(a, b)| Overlay::Line { a, b, color, dashed: false })); + let lines = b.lines(which, cx.zoom); + if hot == Some((b.id, which)) { + let mut path = vectorcraft_geom::kurbo::BezPath::new(); + for (a, c) in lines { + path.move_to(a); + path.line_to(c); + } + out.push(Overlay::Path { path, color, width: HOT_WIDTH, dashed: false }); + } else { + out.extend(lines.into_iter().map(|(a, b)| Overlay::Line { a, b, color, dashed: false })); + } } } out @@ -165,12 +213,22 @@ pub struct BracketDrag { } impl BracketDrag { - /// Start a drag when the press `ev` is on a bracket of the selected type on a path. + /// Start a drag when the press `ev` is on a bracket of the selected type on a path, nearer than + /// any text port (the Selection tool, [`over_bracket`]). pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + if !over_bracket(cx, ev.pos) { + return None; + } + Self::hit_any(cx, ev) + } + + /// Start a drag when the press `ev` is within reach of a bracket, ports or not (Direct + /// Selection, [`near_bracket`]). + pub fn hit_any(cx: &ToolContext, ev: &PointerEvent) -> Option { if too_many(cx) { return None; } - let (brackets, which) = bracket_at(cx, ev.pos)?; + let (brackets, which, _) = bracket_at(cx, ev.pos)?; let grab = brackets.fraction_at(ev.pos)?; Some(Self { brackets, which, start: ev.pos, grab, began: false }) } @@ -271,12 +329,13 @@ mod tests { let p = paint(); let cx = cx(&d, &s, &p); let b = Brackets::of(&cx).pop().unwrap(); - assert_eq!(b.hit(Point::new(160.0, 290.0), 1.0, 2.0), Some(Bracket::Start)); - assert_eq!(b.hit(Point::new(280.0, 290.0), 1.0, 2.0), Some(Bracket::Centre)); - assert_eq!(b.hit(Point::new(400.0, 290.0), 1.0, 2.0), Some(Bracket::End)); - assert_eq!(b.hit(Point::new(220.0, 290.0), 1.0, 2.0), None); + let which = |p: Point| b.hit(p, 1.0, 2.0).map(|(w, _)| w); + assert_eq!(which(Point::new(160.0, 290.0)), Some(Bracket::Start)); + assert_eq!(which(Point::new(280.0, 290.0)), Some(Bracket::Centre)); + assert_eq!(which(Point::new(400.0, 290.0)), Some(Bracket::End)); + assert_eq!(which(Point::new(220.0, 290.0)), None); // Drawn over the type's side of the path, with feet pointing into the span. - let o = overlays(&cx); + let o = overlays(&cx, None, None); assert_eq!(o.len(), 5); assert!(o.contains(&Overlay::Line { a: Point::new(160.0, 286.0), b: Point::new(164.0, 286.0), color: d.layer_color(id), dashed: false })); // Nothing for unselected or other type. @@ -316,13 +375,13 @@ mod tests { }; { let cx = cx(&d, &s, &p); - assert_eq!(overlays(&cx).len(), 50_000); + assert_eq!(overlays(&cx, None, None).len(), 50_000); assert!(over_bracket(&cx, at) && BracketDrag::hit(&cx, &press).is_some()); assert_eq!(tools(&cx), vec![(50_000, Cursor::PathBracket); 2]); } add(&mut d, &mut s, 1); let cx = cx(&d, &s, &p); - assert!(overlays(&cx).is_empty()); + assert!(overlays(&cx, None, None).is_empty()); assert!(!over_bracket(&cx, at) && BracketDrag::hit(&cx, &press).is_none()); for (n, cursor) in tools(&cx) { assert_eq!(n, 0); @@ -364,4 +423,80 @@ mod tests { let v = preview(&drag(&cx, Point::new(400.0, 295.0), Point::new(110.0, 300.0), Mods::default())).clone(); assert!((v["end"].as_f64().unwrap() - 0.2).abs() < 1e-6, "{v}"); } + + /// A press takes a bracket within 8 screen px at any zoom, and not further. + #[test] + fn brackets_are_hit_within_eight_screen_pixels_at_any_zoom() { + let (d, id) = doc_with_path_type(); + let s = selected(id); + let p = paint(); + for zoom in [0.25, 1.0, 8.0] { + let mut cx = cx(&d, &s, &p); + cx.zoom = zoom; + let px = 1.0 / zoom; + // The start bracket's stem stands at x 160, from 4 px below the path to 14 px above it. + let near = |dx: f64| near_bracket(&cx, Point::new(160.0 - dx * px, 300.0 - 7.0 * px)); + assert!(near(7.5), "7.5 px beside the stem at zoom {zoom}"); + assert!(!near(9.0), "9 px beside the stem at zoom {zoom}"); + } + } + + /// The bracket a press would take draws thicker under the pointer; elsewhere they are all + /// hairlines. + #[test] + fn the_hovered_bracket_draws_thicker() { + let (d, id) = doc_with_path_type(); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let hot = |o: &[Overlay]| o.iter().filter(|o| matches!(o, Overlay::Path { width, .. } if *width == HOT_WIDTH)).count(); + assert_eq!(hot(&overlays(&cx, Some(Point::new(400.0, 293.0)), None)), 1); + assert_eq!(hot(&overlays(&cx, Some(Point::new(220.0, 250.0)), None)), 0); + let mut t = crate::select::SelectionTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 400.0, 293.0)); + assert_eq!(hot(&t.overlays(&cx)), 1); + t.hover_ended(&cx); + assert_eq!(hot(&t.overlays(&cx)), 0, "the highlight goes with the hover"); + } + + /// A press on a bracket takes it rather than moving the type; where the in port sits beside the + /// start bracket, the Selection tool gives a press nearer the port to the port (no bracket drag, + /// cursor or highlight), while Direct Selection, which has no ports, takes the bracket. + #[test] + fn the_nearer_of_a_bracket_and_a_port_takes_a_selection_press() { + let (d, id) = doc_with_path_type(); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = crate::select::SelectionTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 280.0, 296.0)); + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 310.0, 296.0)); + assert!(out.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "type.pathOptions")), "{out:?}"); + // A point nearer the in port with the start bracket in reach. + let mut between = None; + for i in 0..400 { + for j in 0..120 { + let q = Point::new(130.0 + i as f64 * 0.1, 282.0 + j as f64 * 0.2); + let Some((_, crate::text::Port::In, port)) = crate::text::port_near(&cx, q) else { continue }; + if bracket_distance(&cx, q).is_some_and(|b| port < b) { + between = Some(q); + } + } + } + let q = between.expect("a point nearer the in port with the start bracket in reach"); + let down = PointerEvent::new(PointerKind::Down, q.x, q.y); + let hot = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == HOT_WIDTH)); + assert!(!over_bracket(&cx, q) && BracketDrag::hit(&cx, &down).is_none()); + let mut select = crate::select::SelectionTool::default(); + select.pointer(&cx, &PointerEvent::new(PointerKind::Move, q.x, q.y)); + assert!(!hot(&select.overlays(&cx))); + assert_ne!(select.cursor(&cx, q, Mods::default()), Cursor::PathBracket); + let mut direct = crate::direct::DirectSelectionTool::new(false); + assert_eq!(direct.cursor(&cx, q, Mods::default()), Cursor::PathBracket); + direct.pointer(&cx, &PointerEvent::new(PointerKind::Move, q.x, q.y)); + assert!(hot(&direct.overlays(&cx))); + direct.pointer(&cx, &down); + let out = direct.pointer(&cx, &PointerEvent::new(PointerKind::Drag, q.x + 30.0, q.y)); + assert!(out.iter().any(|a| matches!(a, Action::Preview(c, _) if c == "type.pathOptions")), "{out:?}"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index be7222f4f..8cbade741 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -153,6 +153,8 @@ pub struct SelectionTool { /// The text the pointer was loaded with when the button went down: a dropped press loads it /// again. loaded_at_press: Option<(NodeId, bool)>, + /// Where the pointer last hovered: the type on a path bracket under it draws thicker. + bracket_hover: Option, } /// What Distance Guides measure the selection to. @@ -713,6 +715,7 @@ impl Tool for SelectionTool { self.hover = None; self.distance = None; self.alt_hover = false; + self.bracket_hover = None; } fn wants_rehover(&self) -> bool { @@ -735,6 +738,7 @@ impl Tool for SelectionTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.note_loaded_at_press(ev.kind); let hovering = ev.kind == PointerKind::Move && matches!(self.state, State::Idle); + self.bracket_hover = hovering.then_some(ev.pos); self.alt_hover = hovering && ev.mods.alt; self.distance = hovering.then(|| distance_target(cx, ev.pos, ev.mods)).flatten(); if let State::Loaded { from, before, press } = self.state @@ -930,7 +934,9 @@ impl Tool for SelectionTool { } fn overlays(&self, cx: &ToolContext) -> Vec { - let mut o = pathtype::overlays(cx); + // Highlighted only where a press would take the bracket, not a nearer port. + let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None }; + let mut o = pathtype::overlays(cx, self.bracket_hover.filter(|p| over_bracket(cx, *p)), drag); if let Some(source) = cx.isolation.and_then(|id| cx.doc.node(id)).filter(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) { for n in source.children().into_iter().flatten() { if let Some(path) = n.path_data() { @@ -1345,7 +1351,7 @@ impl SelectionTool { fn press_widgets(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point) -> Option> { // 0. A port of selected area type or type on a path: the pointer takes its text, // to thread on from the out port or back from the in port. - if let Some((from, port)) = crate::text::port_at(cx, p) { + if let Some((from, port)) = crate::text::port_at(cx, p).filter(|_| !over_bracket(cx, p)) { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index b1394c81d..646dbd638 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -51,8 +51,8 @@ mod tool; use plain::char_range_to_bytes; use plain::common_affixes; pub use plain::step_key; -pub(crate) use ports::port_at; pub use ports::{PORT_RISE_PX, PORTS_MAX, Port, PortMarks, PortState, port_centre, port_states, ported, ports_shown, push_port, thread_ports}; +pub(crate) use ports::{port_at, port_near}; /// Which of the Type tools this is. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/tools/src/text/ports.rs b/crates/tools/src/text/ports.rs index d9c027fbc..554a1cd61 100644 --- a/crates/tools/src/text/ports.rs +++ b/crates/tools/src/text/ports.rs @@ -145,15 +145,25 @@ pub fn ports_shown(d: &vectorcraft_doc::Document, selected: &[NodeId]) -> bool { /// on the in port to thread into one before it. Every such frame has both ports, threaded, /// overflowing or not, while their ports show ([`PORTS_MAX`]). pub(crate) fn port_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Port)> { + port_near(cx, p).map(|(id, port, _)| (id, port)) +} + +/// [`port_at`], with how far (document units) the port's centre is from `p`: the nearest port +/// within reach, of every selected frame (ports of overlapping frames can both be in reach). +pub(crate) fn port_near(cx: &ToolContext, p: Point) -> Option<(NodeId, Port, f64)> { if !ports_shown(cx.doc, &cx.selection.objects) { return None; } let h = cx.tol(PORT_HALF_PX + 1.0); let near = |c: Point| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h; - cx.selection.objects.iter().find_map(|id| { - let t = ported(cx.doc, *id)?; - [Port::Out, Port::In].into_iter().find(|port| port_centre(t, *port, cx.tol(1.0)).is_some_and(near)).map(|port| (*id, port)) - }) + cx.selection + .objects + .iter() + .filter_map(|id| ported(cx.doc, *id).map(|t| (*id, t))) + .flat_map(|(id, t)| [Port::Out, Port::In].into_iter().filter_map(move |port| port_centre(t, port, cx.tol(1.0)).map(|c| (id, port, c)))) + .filter(|(_, _, c)| near(*c)) + .map(|(id, port, c)| (id, port, c.distance(p))) + .min_by(|a, b| a.2.total_cmp(&b.2)) } /// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept