Path type brackets: 8 px reach, hover highlight, and the nearest of a bracket and a port takes the press

Squashed from #5332 onto main.
This commit is contained in:
ham9zah
2026-11-03 16:03:40 +07:00
committed by bflatastic
parent 2b6cbfda5d
commit 2bc284aa25
5 changed files with 197 additions and 36 deletions
+14 -4
View File
@@ -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<Point>,
}
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<SegmentHit> {
}
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<Action> {
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 };
+160 -25
View File
@@ -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> {
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<f64> {
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<Overlay> {
/// 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<Point>, drag: Option<&BracketDrag>) -> Vec<Overlay> {
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<Self> {
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<Self> {
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:?}");
}
}
+8 -2
View File
@@ -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<Point>,
}
/// 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<Action> {
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<Overlay> {
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<Vec<Action>> {
// 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![]);
}
+1 -1
View File
@@ -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)]
+14 -4
View File
@@ -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