From 06136306eabf67ed10ae0beaeb0129fa4bbd8feb Mon Sep 17 00:00:00 2001 From: Matt Fellenz Date: Wed, 26 Mar 2025 02:17:34 +0100 Subject: [PATCH] Implement jump-to-cursor for shapes --- crates/typst-ide/src/jump.rs | 96 ++++++++++++++++++--- crates/typst-library/src/visualize/curve.rs | 51 +++++++++-- 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index 8e55bbd17..b29bc4a48 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -3,7 +3,7 @@ use std::num::NonZeroUsize; use typst::layout::{Frame, FrameItem, PagedDocument, Point, Position, Size}; use typst::model::{Destination, Url}; use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind}; -use typst::visualize::Geometry; +use typst::visualize::{Curve, CurveItem, FillRule, Geometry}; use typst::WorldExt; use crate::IdeWorld; @@ -55,7 +55,7 @@ pub fn jump_from_click( FrameItem::Group(group) => { let pos = click - pos; if let Some(clip) = &group.clip { - if !clip.contains(pos) { + if !clip.contains(FillRule::NonZero, pos) { continue; } } @@ -104,9 +104,32 @@ pub fn jump_from_click( } FrameItem::Shape(shape, span) => { - let Geometry::Rect(size) = shape.geometry else { continue }; - if is_in_rect(pos, size, click) { - return Jump::from_span(world, *span); + if shape.fill.is_some() { + let within = match &shape.geometry { + Geometry::Line(..) => false, + Geometry::Rect(size) => is_in_rect(pos, *size, click), + Geometry::Curve(curve) => { + curve.contains(shape.fill_rule, click - pos) + } + }; + if within { + return Jump::from_span(world, *span); + } + } + + if let Some(stroke) = &shape.stroke { + let within = !stroke.thickness.approx_empty() && { + // This curve is rooted at (0, 0), not `pos`. + let base_curve = match &shape.geometry { + Geometry::Line(to) => &Curve(vec![CurveItem::Line(*to)]), + Geometry::Rect(size) => &Curve::rect(*size), + Geometry::Curve(curve) => curve, + }; + base_curve.stroke_contains(stroke, click - pos) + }; + if within { + return Jump::from_span(world, *span); + } } } @@ -282,34 +305,38 @@ mod tests { fn test_jump_from_click_transform_clip() { let margin = point(10.0, 10.0); test_click( - "#rect(width: 20pt, height: 20pt)", + "#rect(width: 20pt, height: 20pt, fill: black)", point(10.0, 10.0) + margin, cursor(1), ); - test_click("#rect(width: 60pt, height: 10pt)", point(5.0, 30.0) + margin, None); test_click( - "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt))", + "#rect(width: 60pt, height: 10pt, fill: black)", + point(5.0, 30.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt, fill: black))", point(5.0, 30.0) + margin, cursor(38), ); test_click( - "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt))", + "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt, fill: black))", point(20.0, 20.0) + margin, cursor(45), ); test_click( "#box(width: 10pt, height: 10pt, clip: true, scale(x: 300%, y: 300%, \ - origin: top + left, rect(width: 10pt, height: 10pt)))", + origin: top + left, rect(width: 10pt, height: 10pt, fill: black)))", point(20.0, 20.0) + margin, None, ); test_click( - "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt))", + "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt, fill: black))", point(20.0, 20.0) + margin, cursor(45), ); test_click( - "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt))", + "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt, fill: black))", point(20.0, 20.0) + margin, None, ); @@ -320,6 +347,51 @@ mod tests { ); } + #[test] + fn test_jump_from_click_shapes() { + let margin = point(10.0, 10.0); + + test_click( + "#rect(width: 30pt, height: 30pt, fill: black)", + point(15.0, 15.0) + margin, + cursor(1), + ); + + let circle = "#circle(width: 30pt, height: 30pt, fill: black)"; + test_click(circle, point(15.0, 15.0) + margin, cursor(1)); + test_click(circle, point(1.0, 1.0) + margin, None); + + let bowtie = + "#polygon(fill: black, (0pt, 0pt), (20pt, 20pt), (20pt, 0pt), (0pt, 20pt))"; + test_click(bowtie, point(1.0, 2.0) + margin, cursor(1)); + test_click(bowtie, point(2.0, 1.0) + margin, None); + test_click(bowtie, point(19.0, 10.0) + margin, cursor(1)); + + let evenodd = r#"#polygon(fill: black, fill-rule: "even-odd", + (0pt, 10pt), (30pt, 10pt), (30pt, 20pt), (20pt, 20pt), + (20pt, 0pt), (10pt, 0pt), (10pt, 30pt), (20pt, 30pt), + (20pt, 20pt), (0pt, 20pt))"#; + test_click(evenodd, point(15.0, 15.0) + margin, None); + test_click(evenodd, point(5.0, 15.0) + margin, cursor(1)); + test_click(evenodd, point(15.0, 5.0) + margin, cursor(1)); + } + + #[test] + fn test_jump_from_click_shapes_stroke() { + let margin = point(10.0, 10.0); + + let rect = + "#place(dx: 10pt, dy: 10pt, rect(width: 10pt, height: 10pt, stroke: 5pt))"; + test_click(rect, point(15.0, 15.0) + margin, None); + test_click(rect, point(10.0, 15.0) + margin, cursor(27)); + + test_click( + "#line(angle: 45deg, length: 10pt, stroke: 2pt)", + point(2.0, 2.0) + margin, + cursor(1), + ); + } + #[test] fn test_jump_from_cursor() { let s = "*Hello* #box[ABC] World"; diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index c7991817e..50944a516 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -10,6 +10,8 @@ use crate::foundations::{ use crate::layout::{Abs, Axes, BlockElem, Length, Point, Rel, Size}; use crate::visualize::{FillRule, Paint, Stroke}; +use super::FixedStroke; + /// A curve consisting of movements, lines, and Bézier segments. /// /// At any point in time, there is a conceptual pen or cursor. @@ -532,10 +534,10 @@ impl Curve { } impl Curve { - fn to_kurbo(&self) -> kurbo::BezPath { - use kurbo::{BezPath, PathEl}; + fn to_kurbo(&self) -> impl Iterator + '_ { + use kurbo::PathEl; - let path = self.0.iter().map(|item| match *item { + self.0.iter().map(|item| match *item { CurveItem::Move(point) => PathEl::MoveTo(point_to_kurbo(point)), CurveItem::Line(point) => PathEl::LineTo(point_to_kurbo(point)), CurveItem::Cubic(point, point1, point2) => PathEl::CurveTo( @@ -544,13 +546,48 @@ impl Curve { point_to_kurbo(point2), ), CurveItem::Close => PathEl::ClosePath, - }); - BezPath::from_vec(path.collect()) + }) } /// When this curve is interpreted as a clip mask, would it contain `point`? - pub fn contains(&self, needle: Point) -> bool { - kurbo::Shape::contains(&self.to_kurbo(), point_to_kurbo(needle)) + pub fn contains(&self, fill_rule: FillRule, needle: Point) -> bool { + let kurbo = kurbo::BezPath::from_vec(self.to_kurbo().collect()); + let windings = kurbo::Shape::winding(&kurbo, point_to_kurbo(needle)); + match fill_rule { + FillRule::NonZero => windings != 0, + FillRule::EvenOdd => windings % 2 != 0, + } + } + + /// When this curve is stroked with `stroke`, would the stroke contain + /// `point`? + pub fn stroke_contains(&self, stroke: &FixedStroke, needle: Point) -> bool { + let width = stroke.thickness.to_raw(); + let cap = match stroke.cap { + super::LineCap::Butt => kurbo::Cap::Butt, + super::LineCap::Round => kurbo::Cap::Round, + super::LineCap::Square => kurbo::Cap::Square, + }; + let join = match stroke.join { + super::LineJoin::Miter => kurbo::Join::Miter, + super::LineJoin::Round => kurbo::Join::Round, + super::LineJoin::Bevel => kurbo::Join::Bevel, + }; + let miter_limit = stroke.miter_limit.get(); + let mut style = kurbo::Stroke::new(width) + .with_caps(cap) + .with_join(join) + .with_miter_limit(miter_limit); + if let Some(dash) = &stroke.dash { + style = style.with_dashes( + dash.phase.to_raw(), + dash.array.iter().copied().map(Abs::to_raw), + ); + } + let opts = kurbo::StrokeOpts::default(); + let tolerance = 0.01; + let expanded = kurbo::stroke(self.to_kurbo(), &style, &opts, tolerance); + kurbo::Shape::contains(&expanded, point_to_kurbo(needle)) } }