🎉 Add dash and gap inputs for dashed strokes (#9765)

*  Add dash and gap customization for dashed strokes

Signed-off-by: eureka0928 <meobius123@gmail.com>

* ♻️ Change old numeric-inputs for new components

---------

Signed-off-by: eureka0928 <meobius123@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: eureka0928 <meobius123@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
This commit is contained in:
Eva Marco 2026-05-21 13:52:01 +02:00 committed by GitHub
parent 05fa8af479
commit 2c453e4a00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 170 additions and 10 deletions

View File

@ -137,6 +137,8 @@
[:stroke-style {:optional true}
[::sm/one-of #{:solid :dotted :dashed :mixed}]]
[:stroke-width {:optional true} ::sm/safe-number]
[:stroke-dash {:optional true} ::sm/safe-number]
[:stroke-gap {:optional true} ::sm/safe-number]
[:stroke-alignment {:optional true}
[::sm/one-of #{:center :inner :outer}]]
[:stroke-cap-start {:optional true}

View File

@ -45,6 +45,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -151,6 +153,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -206,6 +210,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -261,6 +267,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -315,6 +323,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -433,6 +443,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -488,6 +500,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file

View File

@ -19,14 +19,14 @@
[cuerdas.core :as str]))
(defn- calculate-dasharray
[style width]
[style width dash gap]
(let [w+5 (+ 5 width)
w+1 (+ 1 width)
w+10 (+ 10 width)]
(case style
:mixed (str/concat "" w+5 "," w+5 "," w+1 "," w+5)
:dotted (str/concat "0," w+5)
:dashed (str/concat "" w+10 "," w+10)
:dashed (str/concat "" (or dash w+10) "," (or gap w+10))
"")))
(defn get-border-props
@ -97,6 +97,8 @@
(let [style (:stroke-style data :solid)]
(when-not (= style :none)
(let [width (:stroke-width data 1)
dash (:stroke-dash data)
gap (:stroke-gap data)
gradient (:stroke-color-gradient data)
color (:stroke-color data)
opacity (:stroke-opacity data)]
@ -114,7 +116,7 @@
(obj/set! attrs "strokeOpacity" opacity))
(when (not= style :svg)
(obj/set! attrs "strokeDasharray" (calculate-dasharray style width)))
(obj/set! attrs "strokeDasharray" (calculate-dasharray style width dash gap)))
;; For simple line caps we use svg stroke-line-cap attribute. This
;; only works if all caps are the same and we are not using the tricks

View File

@ -333,6 +333,8 @@
:penpot:stroke-opacity (d/name (:stroke-opacity stroke))
:penpot:stroke-style (d/name (:stroke-style stroke))
:penpot:stroke-width (d/name (:stroke-width stroke))
:penpot:stroke-dash ((d/nilf str) (:stroke-dash stroke))
:penpot:stroke-gap ((d/nilf str) (:stroke-gap stroke))
:penpot:stroke-alignment (d/name (:stroke-alignment stroke))
:penpot:stroke-cap-start (d/name (:stroke-cap-start stroke))
:penpot:stroke-cap-end (d/name (:stroke-cap-end stroke))}]))]))))

View File

@ -30,6 +30,8 @@
:stroke-style
:stroke-alignment
:stroke-width
:stroke-dash
:stroke-gap
:stroke-color
:stroke-color-ref-id
:stroke-color-ref-file
@ -113,6 +115,18 @@
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (dc/change-stroke-attrs ids {:stroke-width value} index))))
on-stroke-dash-change
(fn [index value]
(when-not (str/empty? value)
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (dc/change-stroke-attrs ids {:stroke-dash value} index))))
on-stroke-gap-change
(fn [index value]
(when-not (str/empty? value)
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(st/emit! (dc/change-stroke-attrs ids {:stroke-gap value} index))))
open-caps-select
(fn [caps-state]
(fn [event]
@ -226,6 +240,8 @@
:on-color-change on-color-change
:on-color-detach on-color-detach
:on-stroke-width-change on-stroke-width-change
:on-stroke-dash-change on-stroke-dash-change
:on-stroke-gap-change on-stroke-gap-change
:on-stroke-style-change on-stroke-style-change
:on-stroke-alignment-change on-stroke-alignment-change
:open-caps-select open-caps-select

View File

@ -35,6 +35,8 @@
on-color-detach
on-remove
on-stroke-width-change
on-stroke-dash-change
on-stroke-gap-change
on-stroke-style-change
on-stroke-alignment-change
on-stroke-cap-start-change
@ -106,6 +108,23 @@
ids
#{:stroke-width})))
;; The SVG renderer defaults dash and gap to `stroke-width + 10` when
;; unset. Showing that value as placeholder makes the override obvious.
default-dash-gap (when (number? stroke-width) (+ 10 stroke-width))
stroke-gap (or (:stroke-gap stroke) default-dash-gap)
stroke-dash (or (:stroke-dash stroke) default-dash-gap)
on-dash-change
(mf/use-fn
(mf/deps index on-stroke-dash-change)
#(on-stroke-dash-change index %))
on-gap-change
(mf/use-fn
(mf/deps index on-stroke-gap-change)
#(on-stroke-gap-change index %))
stroke-alignment (or (:stroke-alignment stroke) :center)
stroke-alignment-options
@ -294,6 +313,30 @@
:disabled hidden?
:on-change on-style-change}]])])
;; Stroke Dash / Gap (only visible for dashed style)
(when (= stroke-style :dashed)
[:div {:class (stl/css :stroke-dash-options)
:data-testid "stroke.dash-options"}
[:> numeric-input-wrapper* {:on-change on-dash-change
:text-icon "DASH"
:min 0
:on-focus on-focus
:on-blur on-blur
:attr :stroke-dash
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-dash")
:value stroke-dash}]
[:> numeric-input-wrapper* {:on-change on-gap-change
:text-icon "GAP"
:min 0
:on-focus on-focus
:on-blur on-blur
:attr :stroke-gap
:tooltip-placement "top-left"
:class (stl/css :numeric-input-wrapper)
:property (tr "workspace.options.stroke-gap")
:value stroke-gap}]])
;; Stroke Caps
(when show-caps
[:div {:class (stl/css :stroke-caps-options)}

View File

@ -77,6 +77,12 @@
column-gap: var(--sp-xs);
}
.stroke-dash-options {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: var(--sp-xs);
}
.stroke-options-tokens {
@include sidebar.option-grid-structure;

View File

@ -724,13 +724,18 @@
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
;; Sentinel -1 means "unset" on the Rust side — keeps the
;; FFI signature flat while letting the renderer fall back
;; to its default dash pattern when no override is stored.
dash (or (:stroke-dash stroke) -1)
gap (or (:stroke-gap stroke) -1)
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(case align
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end))
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
(cond
(some? gradient)

View File

@ -7560,6 +7560,14 @@ msgstr "Center"
msgid "workspace.options.stroke.dashed"
msgstr "Dashed"
#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
msgid "workspace.options.stroke-dash"
msgstr "Dash"
#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs
msgid "workspace.options.stroke-gap"
msgstr "Gap"
#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138
msgid "workspace.options.stroke.dotted"
msgstr "Dotted"

View File

@ -41,6 +41,10 @@ pub struct Stroke {
pub cap_end: Option<StrokeCap>,
pub cap_start: Option<StrokeCap>,
pub kind: StrokeKind,
// Dash and gap overrides for the `Dashed` style. `None` falls back to the
// default `width + 10` pattern to keep existing designs visually identical.
pub dash: Option<f32>,
pub gap: Option<f32>,
}
impl Stroke {
@ -72,6 +76,8 @@ impl Stroke {
style: StrokeStyle,
cap_start: Option<StrokeCap>,
cap_end: Option<StrokeCap>,
dash: Option<f32>,
gap: Option<f32>,
) -> Self {
Stroke {
fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)),
@ -80,6 +86,8 @@ impl Stroke {
cap_end,
cap_start,
kind: StrokeKind::Center,
dash,
gap,
}
}
@ -88,6 +96,8 @@ impl Stroke {
style: StrokeStyle,
cap_start: Option<StrokeCap>,
cap_end: Option<StrokeCap>,
dash: Option<f32>,
gap: Option<f32>,
) -> Self {
Stroke {
fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)),
@ -96,6 +106,8 @@ impl Stroke {
cap_end,
cap_start,
kind: StrokeKind::Inner,
dash,
gap,
}
}
@ -104,6 +116,8 @@ impl Stroke {
style: StrokeStyle,
cap_start: Option<StrokeCap>,
cap_end: Option<StrokeCap>,
dash: Option<f32>,
gap: Option<f32>,
) -> Self {
Stroke {
fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)),
@ -112,11 +126,19 @@ impl Stroke {
cap_end,
cap_start,
kind: StrokeKind::Outer,
dash,
gap,
}
}
pub fn scale_content(&mut self, value: f32) {
self.width *= value;
if let Some(dash) = self.dash {
self.dash = Some(dash * value);
}
if let Some(gap) = self.gap {
self.gap = Some(gap * value);
}
}
/// Returns the clip operation for dotted inner/outer strokes.
@ -256,7 +278,9 @@ impl Stroke {
)
}
StrokeStyle::Dashed => {
skia::PathEffect::dash(&[self.width + 10., self.width + 10.], 0.)
let dash = self.dash.unwrap_or(self.width + 10.);
let gap = self.gap.unwrap_or(self.width + 10.);
skia::PathEffect::dash(&[dash, gap], 0.)
}
StrokeStyle::Mixed => skia::PathEffect::dash(
&[

View File

@ -68,8 +68,26 @@ impl TryFrom<RawStrokeCap> for StrokeCap {
}
}
// A negative value means "unset" — the renderer falls back to its default
// dash pattern. We use a sentinel instead of passing a bool because adding
// two f32 params keeps the FFI signature flat and allocation-free.
fn decode_optional(value: f32) -> Option<f32> {
if value.is_finite() && value >= 0.0 {
Some(value)
} else {
None
}
}
#[no_mangle]
pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) {
pub extern "C" fn add_shape_center_stroke(
width: f32,
style: u8,
cap_start: u8,
cap_end: u8,
dash: f32,
gap: f32,
) {
let stroke_style = RawStrokeStyle::from(style);
let cap_start = RawStrokeCap::from(cap_start);
let cap_end = RawStrokeCap::from(cap_end);
@ -80,12 +98,21 @@ pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8,
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
decode_optional(dash),
decode_optional(gap),
));
});
}
#[no_mangle]
pub extern "C" fn add_shape_inner_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) {
pub extern "C" fn add_shape_inner_stroke(
width: f32,
style: u8,
cap_start: u8,
cap_end: u8,
dash: f32,
gap: f32,
) {
let stroke_style = RawStrokeStyle::from(style);
let cap_start = RawStrokeCap::from(cap_start);
let cap_end = RawStrokeCap::from(cap_end);
@ -96,12 +123,21 @@ pub extern "C" fn add_shape_inner_stroke(width: f32, style: u8, cap_start: u8, c
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
decode_optional(dash),
decode_optional(gap),
));
});
}
#[no_mangle]
pub extern "C" fn add_shape_outer_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) {
pub extern "C" fn add_shape_outer_stroke(
width: f32,
style: u8,
cap_start: u8,
cap_end: u8,
dash: f32,
gap: f32,
) {
let stroke_style = RawStrokeStyle::from(style);
let cap_start = RawStrokeCap::from(cap_start);
let cap_end = RawStrokeCap::from(cap_end);
@ -112,6 +148,8 @@ pub extern "C" fn add_shape_outer_stroke(width: f32, style: u8, cap_start: u8, c
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
decode_optional(dash),
decode_optional(gap),
));
});
}