Repository: slint-ui/slint (language: rust)

Issue:
Add support for caps on Path strokes
### Discussed in https://github.com/slint-ui/slint/discussions/4671

<div type='discussions-op-text'>

<sup>Originally posted by **Vadoola** February 22, 2024</sup>
I thought about asking this a while back, but never did, and I was recently reminded by discussion #4656.

When I initially started work on [Tomotroid ](https://github.com/Vadoola/Tomotroid) I had intended to use Slint paths to make the circular progress bar. I ended up dropping that idea and switching to loading an SVG because the Slint paths don't have support for line caps (specifically rounded line caps).

While this method works, it would be nice to handle this within Slint if possible, remove some of the Rust code, and keep the UI a bit more encapsulated. Right now in order make the progress circle move smoothly I need to be having a short timer to wake up and tick over the progress SVG, or have a longer time and the progress circle moves in big ticks.

If I was able to create this progress circle within Slint I could animate the progress property and have my timer wake up less often to update the progress circle. I **assume** this would be more efficient resource wise, but I haven't actually tested this.

So are there any plans to bring line cap support to Slint Paths? I searched through the Issues and Discussions, and couldn't find anything about it one way or the other.</div>


Discussion / hints:

Fix patch:
diff --git a/docs/astro/src/content/docs/reference/elements/path.mdx b/docs/astro/src/content/docs/reference/elements/path.mdx
index e6fcf693b80..d701bc0fbb0 100644
--- a/docs/astro/src/content/docs/reference/elements/path.mdx
+++ b/docs/astro/src/content/docs/reference/elements/path.mdx
@@ -42,6 +42,11 @@ The color for drawing the outline of the path.
 The width of the outline.
 </SlintProperty>
 
+### stroke-line-cap
+<SlintProperty propName="stroke-line-cap" typeName="enum" enumName="LineCap" defaultValue='butt'>
+The appearance of the ends of the path's outline.
+</SlintProperty>
+
 ### width
 <SlintProperty propName="width" typeName="length">
 If non-zero, the path will be scaled to fit into the specified width.
diff --git a/internal/backends/qt/qt_window.rs b/internal/backends/qt/qt_window.rs
index 1ad54962aeb..e929f84563d 100644
--- a/internal/backends/qt/qt_window.rs
+++ b/internal/backends/qt/qt_window.rs
@@ -17,8 +17,9 @@ use i_slint_core::item_rendering::{
 };
 use i_slint_core::item_tree::{ItemTreeRc, ItemTreeRef};
 use i_slint_core::items::{
-    self, ColorScheme, FillRule, ImageRendering, ItemRc, ItemRef, Layer, MouseCursor, Opacity,
-    PointerEventButton, PopupClosePolicy, RenderingResult, TextOverflow, TextStrokeStyle, TextWrap,
+    self, ColorScheme, FillRule, ImageRendering, ItemRc, ItemRef, Layer, LineCap, MouseCursor,
+    Opacity, PointerEventButton, PopupClosePolicy, RenderingResult, TextOverflow, TextStrokeStyle,
+    TextWrap,
 };
 use i_slint_core::layout::Orientation;
 use i_slint_core::lengths::{
@@ -989,6 +990,11 @@ impl ItemRenderer for QtItemRenderer<'_> {
         let fill_brush: qttypes::QBrush = into_qbrush(path.fill(), rect.width, rect.height);
         let stroke_brush: qttypes::QBrush = into_qbrush(path.stroke(), rect.width, rect.height);
         let stroke_width: f32 = path.stroke_width().get();
+        let stroke_pen_cap_style: i32 = match path.stroke_line_cap() {
+            LineCap::Butt => 0x00,
+            LineCap::Round => 0x20,
+            LineCap::Square => 0x10,
+        };
         let pos = qttypes::QPoint { x: offset.x as _, y: offset.y as _ };
         let mut painter_path = QPainterPath::default();
 
@@ -1034,11 +1040,12 @@ impl ItemRenderer for QtItemRenderer<'_> {
                 fill_brush as "QBrush",
                 stroke_brush as "QBrush",
                 stroke_width as "float",
+                stroke_pen_cap_style as "int",
                 anti_alias as "bool"] {
             (*painter)->save();
             auto cleanup = qScopeGuard([&] { (*painter)->restore(); });
             (*painter)->translate(pos);
-            (*painter)->setPen(stroke_width > 0 ? QPen(stroke_brush, stroke_width) : Qt::NoPen);
+            (*painter)->setPen(stroke_width > 0 ? QPen(stroke_brush, stroke_width, Qt::SolidLine, Qt::PenCapStyle(stroke_pen_cap_style)) : Qt::NoPen);
             (*painter)->setBrush(fill_brush);
             (*painter)->setRenderHint(QPainter::Antialiasing, anti_alias);
             (*painter)->drawPath(painter_path);
diff --git a/internal/common/enums.rs b/internal/common/enums.rs
index a6dcc496882..5fda9d96009 100644
--- a/internal/common/enums.rs
+++ b/internal/common/enums.rs
@@ -447,6 +447,16 @@ macro_rules! for_each_enums {
                 /// Does not close the `PopupWindow` automatically when user clicks.
                 NoAutoClose,
             }
+
+            /// This enum describes the appearance of the ends of stroked paths.
+            enum LineCap {
+                /// The stroke ends with a flat edge that is perpendicular to the path.
+                Butt,
+                /// The stroke ends with a rounded edge.
+                Round,
+                /// The stroke ends with a square projection beyond the path.
+                Square,
+            }
         ];
     };
 }
diff --git a/internal/compiler/builtins.slint b/internal/compiler/builtins.slint
index e6efa702cb4..daa45945f2e 100644
--- a/internal/compiler/builtins.slint
+++ b/internal/compiler/builtins.slint
@@ -400,6 +400,7 @@ export component Path {
     in property <FillRule> fill-rule;
     in property <brush> stroke;
     in property <length> stroke-width;
+    in property <LineCap> stroke-line-cap;
     in property <string> commands;  // 'fake' hardcoded in typeregister.rs
     in property <float> viewbox-x;
     in property <float> viewbox-y;
diff --git a/internal/core/items/path.rs b/internal/core/items/path.rs
index 5add282bb6d..d303e95a4e9 100644
--- a/internal/core/items/path.rs
+++ b/internal/core/items/path.rs
@@ -8,7 +8,7 @@ When adding an item or a property, it needs to be kept in sync with different pl
 Lookup the [`crate::items`] module documentation.
 */
 
-use super::{FillRule, Item, ItemConsts, ItemRc, ItemRendererRef, RenderingResult};
+use super::{FillRule, Item, ItemConsts, ItemRc, ItemRendererRef, LineCap, RenderingResult};
 use crate::graphics::{Brush, PathData, PathDataIterator};
 use crate::input::{
     FocusEvent, FocusEventResult, InputEventFilterResult, InputEventResult, KeyEvent,
@@ -40,6 +40,7 @@ pub struct Path {
     pub fill_rule: Property<FillRule>,
     pub stroke: Property<Brush>,
     pub stroke_width: Property<LogicalLength>,
+    pub stroke_line_cap: Property<LineCap>,
     pub viewbox_x: Property<f32>,
     pub viewbox_y: Property<f32>,
     pub viewbox_width: Property<f32>,
diff --git a/internal/renderers/femtovg/itemrenderer.rs b/internal/renderers/femtovg/itemrenderer.rs
index b0e5145ad16..2c992766eef 100644
--- a/internal/renderers/femtovg/itemrenderer.rs
+++ b/internal/renderers/femtovg/itemrenderer.rs
@@ -637,6 +637,11 @@ impl<'a> ItemRenderer for GLItemRenderer<'a> {
 
         let border_paint = self.brush_to_paint(path.stroke(), &femtovg_path).map(|mut paint| {
             paint.set_line_width((path.stroke_width() * self.scale_factor).get());
+            paint.set_line_cap(match path.stroke_line_cap() {
+                items::LineCap::Butt => femtovg::LineCap::Butt,
+                items::LineCap::Round => femtovg::LineCap::Round,
+                items::LineCap::Square => femtovg::LineCap::Square,
+            });
             paint.set_anti_alias(anti_alias);
             paint
         });
diff --git a/internal/renderers/skia/itemrenderer.rs b/internal/renderers/skia/itemrenderer.rs
index 54778e00861..10cecf43419 100644
--- a/internal/renderers/skia/itemrenderer.rs
+++ b/internal/renderers/skia/itemrenderer.rs
@@ -740,6 +740,11 @@ impl<'a> ItemRenderer for SkiaItemRenderer<'a> {
         {
             border_paint.set_anti_alias(anti_alias);
             border_paint.set_stroke_width((path.stroke_width() * self.scale_factor).get());
+            border_paint.set_stroke_cap(match path.stroke_line_cap() {
+                i_slint_core::items::LineCap::Butt => skia_safe::PaintCap::Butt,
+                i_slint_core::items::LineCap::Round => skia_safe::PaintCap::Round,
+                i_slint_core::items::LineCap::Square => skia_safe::PaintCap::Square,
+            });
             border_paint.set_stroke(true);
             self.canvas.draw_path(&skpath, &border_paint);
         }


Test patch:
diff --git a/tests/manual/path-stroke-cap.slint b/tests/manual/path-stroke-cap.slint
new file mode 100644
index 00000000000..e9dda44519d
--- /dev/null
+++ b/tests/manual/path-stroke-cap.slint
@@ -0,0 +1,68 @@
+// Copyright © SixtyFPS GmbH <info@slint.dev>
+// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
+
+import { Palette } from "std-widgets.slint";
+
+component Line {
+    in property <LineCap> stroke-line-cap <=> p.stroke-line-cap;
+
+    p := Path {
+        viewbox-width: 6;
+        viewbox-height: 6;
+
+        stroke: Palette.foreground;
+        stroke-width: 20px;
+
+        MoveTo {
+            x: 1;
+            y: 1;
+        }
+
+        LineTo {
+            x: 5;
+            y: 1;
+        }
+    }
+}
+
+export component TestCase inherits Window {
+    preferred-width: 300px;
+    preferred-height: 300px;
+
+    Line {
+        x: 10px;
+        y: 10px;
+        width: parent.width - 20px;
+        height: 50px;
+
+        stroke-line-cap: butt;
+    }
+
+    Line {
+        x: 10px;
+        y: 50px;
+        width: parent.width - 20px;
+        height: 50px;
+
+        stroke-line-cap: round;
+    }
+
+    Line {
+        x: 10px;
+        y: 100px;
+        width: parent.width - 20px;
+        height: 50px;
+
+        stroke-line-cap: square;
+    }
+
+    /*
+      <line x1="1" y1="1" x2="5" y2="1" stroke="black" stroke-linecap="butt" />
+
+  <!-- Effect of the "round" value -->
+  <line x1="1" y1="3" x2="5" y2="3" stroke="black" stroke-linecap="round" />
+
+  <!-- Effect of the "square" value -->
+  <line x1="1" y1="5" x2="5" y2="5" stroke="black" stroke-linecap="square" />
+*/
+}