gpui: Introduce dash array support for `PathBuilder` (#31678)

Floyd Wang created

A simple way to draw dashed lines.


https://github.com/user-attachments/assets/2105d7b2-42d0-4d73-bb29-83a4a6bd7029

Release Notes:

- N/A

Change summary

crates/gpui/examples/painting.rs | 49 +++++++++++++++++++++++---------
crates/gpui/src/path_builder.rs  | 50 +++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+), 15 deletions(-)

Detailed changes

crates/gpui/examples/painting.rs 🔗

@@ -1,13 +1,14 @@
 use gpui::{
     Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder,
-    PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div,
-    linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size,
+    PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas,
+    div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size,
 };
 
 struct PaintingViewer {
     default_lines: Vec<(Path<Pixels>, Background)>,
     lines: Vec<Vec<Point<Pixels>>>,
     start: Point<Pixels>,
+    dashed: bool,
     _painting: bool,
 }
 
@@ -140,7 +141,7 @@ impl PaintingViewer {
             .with_line_join(lyon::path::LineJoin::Bevel);
         let mut builder = PathBuilder::stroke(px(1.)).with_style(PathStyle::Stroke(options));
         builder.move_to(point(px(40.), px(320.)));
-        for i in 0..50 {
+        for i in 1..50 {
             builder.line_to(point(
                 px(40.0 + i as f32 * 10.0),
                 px(320.0 + (i as f32 * 10.0).sin() * 40.0),
@@ -153,6 +154,7 @@ impl PaintingViewer {
             default_lines: lines.clone(),
             lines: vec![],
             start: point(px(0.), px(0.)),
+            dashed: false,
             _painting: false,
         }
     }
@@ -162,10 +164,30 @@ impl PaintingViewer {
         cx.notify();
     }
 }
+
+fn button(
+    text: &str,
+    cx: &mut Context<PaintingViewer>,
+    on_click: impl Fn(&mut PaintingViewer, &mut Context<PaintingViewer>) + 'static,
+) -> impl IntoElement {
+    div()
+        .id(SharedString::from(text.to_string()))
+        .child(text.to_string())
+        .bg(gpui::black())
+        .text_color(gpui::white())
+        .active(|this| this.opacity(0.8))
+        .flex()
+        .px_3()
+        .py_1()
+        .on_click(cx.listener(move |this, _, _, cx| on_click(this, cx)))
+}
+
 impl Render for PaintingViewer {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let default_lines = self.default_lines.clone();
         let lines = self.lines.clone();
+        let dashed = self.dashed;
+
         div()
             .font_family(".SystemUIFont")
             .bg(gpui::white())
@@ -182,17 +204,14 @@ impl Render for PaintingViewer {
                     .child("Mouse down any point and drag to draw lines (Hold on shift key to draw straight lines)")
                     .child(
                         div()
-                            .id("clear")
-                            .child("Clean up")
-                            .bg(gpui::black())
-                            .text_color(gpui::white())
-                            .active(|this| this.opacity(0.8))
                             .flex()
-                            .px_3()
-                            .py_1()
-                            .on_click(cx.listener(|this, _, _, cx| {
-                                this.clear(cx);
-                            })),
+                            .gap_x_2()
+                            .child(button(
+                                if dashed { "Solid" } else { "Dashed" },
+                                cx,
+                                move |this, _| this.dashed = !dashed,
+                            ))
+                            .child(button("Clear", cx, |this, cx| this.clear(cx))),
                     ),
             )
             .child(
@@ -202,7 +221,6 @@ impl Render for PaintingViewer {
                         canvas(
                             move |_, _, _| {},
                             move |_, _, window, _| {
-
                                 for (path, color) in default_lines {
                                     window.paint_path(path, color);
                                 }
@@ -213,6 +231,9 @@ impl Render for PaintingViewer {
                                     }
 
                                     let mut builder = PathBuilder::stroke(px(1.));
+                                    if dashed {
+                                        builder = builder.dash_array(&[px(4.), px(2.)]);
+                                    }
                                     for (i, p) in points.into_iter().enumerate() {
                                         if i == 0 {
                                             builder.move_to(p);

crates/gpui/src/path_builder.rs 🔗

@@ -27,6 +27,7 @@ pub struct PathBuilder {
     transform: Option<lyon::math::Transform>,
     /// PathStyle of the PathBuilder
     pub style: PathStyle,
+    dash_array: Option<Vec<Pixels>>,
 }
 
 impl From<lyon::path::Builder> for PathBuilder {
@@ -77,6 +78,7 @@ impl Default for PathBuilder {
             raw: lyon::path::Path::builder().with_svg(),
             style: PathStyle::Fill(FillOptions::default()),
             transform: None,
+            dash_array: None,
         }
     }
 }
@@ -100,6 +102,24 @@ impl PathBuilder {
         Self { style, ..self }
     }
 
+    /// Sets the dash array of the [`PathBuilder`].
+    ///
+    /// [MDN](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/stroke-dasharray)
+    pub fn dash_array(mut self, dash_array: &[Pixels]) -> Self {
+        // If an odd number of values is provided, then the list of values is repeated to yield an even number of values.
+        // Thus, 5,3,2 is equivalent to 5,3,2,5,3,2.
+        let array = if dash_array.len() % 2 == 1 {
+            let mut new_dash_array = dash_array.to_vec();
+            new_dash_array.extend_from_slice(dash_array);
+            new_dash_array
+        } else {
+            dash_array.to_vec()
+        };
+
+        self.dash_array = Some(array);
+        self
+    }
+
     /// Move the current point to the given point.
     #[inline]
     pub fn move_to(&mut self, to: Point<Pixels>) {
@@ -229,7 +249,7 @@ impl PathBuilder {
         };
 
         match self.style {
-            PathStyle::Stroke(options) => Self::tessellate_stroke(&path, &options),
+            PathStyle::Stroke(options) => Self::tessellate_stroke(self.dash_array, &path, &options),
             PathStyle::Fill(options) => Self::tessellate_fill(&path, &options),
         }
     }
@@ -253,9 +273,37 @@ impl PathBuilder {
     }
 
     fn tessellate_stroke(
+        dash_array: Option<Vec<Pixels>>,
         path: &lyon::path::Path,
         options: &StrokeOptions,
     ) -> Result<Path<Pixels>, Error> {
+        let path = if let Some(dash_array) = dash_array {
+            let measurements = lyon::algorithms::measure::PathMeasurements::from_path(&path, 0.01);
+            let mut sampler = measurements
+                .create_sampler(path, lyon::algorithms::measure::SampleType::Normalized);
+            let mut builder = lyon::path::Path::builder();
+
+            let total_length = sampler.length();
+            let dash_array_len = dash_array.len();
+            let mut pos = 0.;
+            let mut dash_index = 0;
+            while pos < total_length {
+                let dash_length = dash_array[dash_index % dash_array_len].0;
+                let next_pos = (pos + dash_length).min(total_length);
+                if dash_index % 2 == 0 {
+                    let start = pos / total_length;
+                    let end = next_pos / total_length;
+                    sampler.split_range(start..end, &mut builder);
+                }
+                pos = next_pos;
+                dash_index += 1;
+            }
+
+            &builder.build()
+        } else {
+            path
+        };
+
         // Will contain the result of the tessellation.
         let mut buf: VertexBuffers<lyon::math::Point, u16> = VertexBuffers::new();
         let mut tessellator = StrokeTessellator::new();