Skip to content

Commit 49ea281

Browse files
committed
Dashed strokes draw round and square caps along the path; zero patterns no longer hang
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik
1 parent 9059aad commit 49ea281

2 files changed

Lines changed: 314 additions & 1 deletion

File tree

‎lib/src/transformers/utils/stroke_painter.dart‎

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,17 @@ class StrokePainter extends CustomPainter {
8888

8989
// If the stroke is dotted, we take a completely different rendering
9090
// route.
91+
// A pattern that never advances would loop forever, and draws nothing.
92+
if (dashPattern.isNotEmpty &&
93+
dashPattern.fold<double>(0, (sum, dash) => sum + max(0, dash)) <= 0) {
94+
return;
95+
}
9196
if (dashPattern.isNotEmpty && strokeSide != StrokeSide.all) {
9297
_paintDashedSide(canvas, size, paint);
98+
} else if (dashPattern.isNotEmpty &&
99+
strokeCap != StrokeCapEnum.none &&
100+
strokeWidth > 0) {
101+
_paintCappedDashes(canvas, size, paint, isLine, isHorizLine, isVertLine);
93102
} else if (dashPattern.isNotEmpty) {
94103
final Path path = Path();
95104

@@ -136,7 +145,9 @@ class StrokePainter extends CustomPainter {
136145
while (totalDistance < metric.length) {
137146
final double dashDistance = dashArray.nextValue();
138147

139-
if (draw) {
148+
// A butt-capped dash of no length covers nothing (a stroke width of
149+
// zero would paint a hairline instead).
150+
if (draw && dashDistance > 0) {
140151
// Now that we're about to draw a singular dash, we need to do it
141152
// manually. We do this with tangent calculations. Luckily for us,
142153
// Flutter has a convenient method for that!
@@ -239,6 +250,21 @@ class StrokePainter extends CustomPainter {
239250
};
240251
final double length = (end - start).distance;
241252
if (length <= 0 || dashPattern.every((dash) => dash <= 0)) return;
253+
if (strokeCap != StrokeCapEnum.none && strokeWidth > 0) {
254+
// Round and square caps extend every dash by half the stroke width,
255+
// so even a dash of zero length draws a dot.
256+
paint
257+
..style = PaintingStyle.stroke
258+
..strokeWidth = strokeWidth;
259+
_paintDashesAlong(
260+
canvas,
261+
Path()
262+
..moveTo(start.dx, start.dy)
263+
..lineTo(end.dx, end.dy),
264+
paint,
265+
);
266+
return;
267+
}
242268
final LoopingList<double> dashes = LoopingList(dashPattern);
243269
final Offset direction = (end - start) / length;
244270
paint
@@ -261,6 +287,123 @@ class StrokePainter extends CustomPainter {
261287
}
262288
}
263289

290+
/// Paints a dashed stroke on every side whose dashes have round or square
291+
/// caps, as SVG and Figma draw them: each dash runs along the stroke's
292+
/// center line and its caps extend it by half the stroke width at both
293+
/// ends, so a dash of zero length is a dot as wide as the stroke.
294+
///
295+
/// Dashes without caps are painted as "needles" across the stroke (see
296+
/// [paint]) instead; caps on those would stick out of the stroke's band.
297+
void _paintCappedDashes(
298+
Canvas canvas,
299+
Size size,
300+
Paint paint,
301+
bool isLine,
302+
bool isHorizLine,
303+
bool isVertLine,
304+
) {
305+
final Path path = Path();
306+
if (isLine) {
307+
path
308+
..moveTo(
309+
isHorizLine ? strokeWidth / 2 : 0,
310+
isVertLine ? strokeWidth / 2 : 0,
311+
)
312+
..lineTo(
313+
isHorizLine ? strokeWidth / 2 : size.width,
314+
isVertLine ? strokeWidth / 2 : size.height,
315+
);
316+
} else {
317+
// The center line of the stroke: half its width inside the box for an
318+
// inside stroke, on the edge for a centered one, and half its width
319+
// outside for an outside one.
320+
final double grow = switch (strokeAlign) {
321+
StrokeAlignC.inside => -strokeWidth / 2,
322+
StrokeAlignC.center => 0,
323+
StrokeAlignC.outside => strokeWidth / 2,
324+
};
325+
if (boxShape == BoxShape.circle) {
326+
final double diameter = min(size.width, size.height) + grow * 2;
327+
if (diameter <= 0) return;
328+
path.addOval(
329+
Rect.fromCenter(
330+
center: Offset(size.width / 2, size.height / 2),
331+
width: diameter,
332+
height: diameter,
333+
),
334+
);
335+
} else {
336+
final RRect box = borderRadius
337+
.toRRect(Rect.fromLTWH(0, 0, size.width, size.height))
338+
.inflate(grow);
339+
if (box.width <= 0 || box.height <= 0) return;
340+
path.addRRect(box);
341+
}
342+
}
343+
paint
344+
..style = PaintingStyle.stroke
345+
..strokeWidth = strokeWidth;
346+
_paintDashesAlong(canvas, path, paint);
347+
}
348+
349+
/// Paints [dashPattern] along [path] with [paint], whose cap is round or
350+
/// square. Dashes shorter than [_dotLength] are painted as dots: the cap
351+
/// alone, which a stroke of (almost) no length may not draw.
352+
void _paintDashesAlong(Canvas canvas, Path path, Paint paint) {
353+
final LoopingList<double> dashes = LoopingList(dashPattern);
354+
final Paint dotPaint = Paint()
355+
..color = paint.color
356+
..style = PaintingStyle.fill
357+
..isAntiAlias = paint.isAntiAlias;
358+
for (final PathMetric metric in path.computeMetrics()) {
359+
double distance = 0;
360+
bool draw = true;
361+
while (distance < metric.length) {
362+
final double dash = max(0.0, dashes.nextValue());
363+
if (draw) {
364+
final double end = min(distance + dash, metric.length);
365+
if (end - distance < _dotLength) {
366+
final Tangent? tangent = metric.getTangentForOffset(distance);
367+
if (tangent != null) _paintDot(canvas, tangent, dotPaint);
368+
} else {
369+
canvas.drawPath(metric.extractPath(distance, end), paint);
370+
}
371+
}
372+
distance += dash;
373+
draw = !draw;
374+
}
375+
}
376+
}
377+
378+
/// Dashes shorter than this are painted as a dot of the cap's shape.
379+
static const double _dotLength = 0.01;
380+
381+
/// Paints a dash of no length at [tangent]: a circle as wide as the
382+
/// stroke for a round cap, a square turned along the path for a square
383+
/// one.
384+
void _paintDot(Canvas canvas, Tangent tangent, Paint paint) {
385+
final double radius = strokeWidth / 2;
386+
if (strokeCap == StrokeCapEnum.round) {
387+
canvas.drawCircle(tangent.position, radius, paint);
388+
return;
389+
}
390+
final double length = tangent.vector.distance;
391+
final Offset along = length == 0
392+
? Offset(radius, 0)
393+
: tangent.vector / length * radius;
394+
final Offset across = Offset(-along.dy, along.dx);
395+
final Offset center = tangent.position;
396+
canvas.drawPath(
397+
Path()
398+
..moveTo((center - along - across).dx, (center - along - across).dy)
399+
..lineTo((center + along - across).dx, (center + along - across).dy)
400+
..lineTo((center + along + across).dx, (center + along + across).dy)
401+
..lineTo((center - along + across).dx, (center - along + across).dy)
402+
..close(),
403+
paint,
404+
);
405+
}
406+
264407
@override
265408
bool shouldRepaint(covariant StrokePainter oldDelegate) =>
266409
oldDelegate.borderRadius != borderRadius ||

‎test/dashed_stroke_caps_test.dart‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import 'dart:typed_data';
2+
import 'dart:ui' as ui;
3+
4+
import 'package:codelessly_api/codelessly_api.dart';
5+
import 'package:codelessly_sdk/codelessly_sdk.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
import 'stroke_side_test.dart' show pumpNode, strokePaint;
10+
11+
/// A 100x100 circle (a fully rounded box) with a stroke of tiny dashes, like
12+
/// the strobe dots on a turntable platter.
13+
RectangleNode platter({
14+
required StrokeCapEnum cap,
15+
List<double> dashPattern = const [0, 10],
16+
StrokeSide side = StrokeSide.all,
17+
}) => RectangleNode(
18+
id: 'platter',
19+
name: 'Platter',
20+
basicBoxLocal: NodeBox(0, 0, 100, 100),
21+
cornerRadius: const CornerRadius.all(RadiusModel.circular(50)),
22+
strokes: [PaintModel.solid(id: 'stroke', color: ColorRGB.fromHex('#000000'))],
23+
strokeWeight: 4,
24+
strokeAlign: StrokeAlignC.inside,
25+
strokeCap: cap,
26+
strokeSide: side,
27+
dashPattern: dashPattern,
28+
)..parentID = kRootNode;
29+
30+
/// Paints [painter] into a [size] image and returns how many pixels have
31+
/// any ink, so tests need no golden files.
32+
Future<int> inkedPixels(WidgetTester tester, CustomPainter painter,
33+
{Size size = const Size(100, 100)}) async {
34+
final int? count = await tester.runAsync(() async {
35+
final ui.PictureRecorder recorder = ui.PictureRecorder();
36+
painter.paint(Canvas(recorder), size);
37+
final ui.Image image = await recorder.endRecording().toImage(
38+
size.width.toInt(),
39+
size.height.toInt(),
40+
);
41+
final ByteData bytes = (await image.toByteData())!;
42+
int inked = 0;
43+
for (int i = 3; i < bytes.lengthInBytes; i += 4) {
44+
if (bytes.getUint8(i) > 0) inked++;
45+
}
46+
return inked;
47+
});
48+
return count!;
49+
}
50+
51+
StrokePainter painterFor({
52+
required StrokeCapEnum cap,
53+
List<double> dashPattern = const [0, 10],
54+
StrokeSide side = StrokeSide.all,
55+
}) => StrokePainter(
56+
strokeWidth: 4,
57+
dashPattern: dashPattern,
58+
strokeCap: cap,
59+
strokeSide: side,
60+
borderRadius: BorderRadius.circular(50),
61+
);
62+
63+
void main() {
64+
testWidgets('Zero-length dashes with round caps paint a dot each, inside '
65+
'the stroke band', (tester) async {
66+
await pumpNode(tester, platter(cap: StrokeCapEnum.round));
67+
expect(tester.takeException(), isNull);
68+
// The first dash sits at the start of the stroke's center line, which is
69+
// half the stroke width inside the box.
70+
expect(
71+
strokePaint(),
72+
paints
73+
..circle(radius: 2)
74+
..circle(radius: 2)
75+
..circle(radius: 2),
76+
);
77+
// A circle of center line radius 48 is ~301.6 long: 31 dots, 10 apart.
78+
final int inked = await inkedPixels(
79+
tester,
80+
painterFor(cap: StrokeCapEnum.round),
81+
);
82+
// Each dot is a circle of radius 2 (~12.6 px).
83+
expect(inked, greaterThan(31 * 8));
84+
});
85+
86+
testWidgets('Zero-length dashes with square caps paint a square each', (
87+
tester,
88+
) async {
89+
await pumpNode(tester, platter(cap: StrokeCapEnum.square));
90+
expect(tester.takeException(), isNull);
91+
expect(strokePaint(), paints..path()..path()..path());
92+
expect(
93+
await inkedPixels(tester, painterFor(cap: StrokeCapEnum.square)),
94+
greaterThan(31 * 10),
95+
);
96+
});
97+
98+
testWidgets('Dashes without caps and no length still paint nothing', (
99+
tester,
100+
) async {
101+
expect(
102+
await inkedPixels(tester, painterFor(cap: StrokeCapEnum.none)),
103+
0,
104+
);
105+
});
106+
107+
testWidgets('Tiny round dashes stay within the stroke band of the box', (
108+
tester,
109+
) async {
110+
final int? outside = await tester.runAsync(() async {
111+
final ui.PictureRecorder recorder = ui.PictureRecorder();
112+
painterFor(
113+
cap: StrokeCapEnum.round,
114+
dashPattern: const [0.001, 6],
115+
).paint(Canvas(recorder), const Size(100, 100));
116+
final ui.Image image = await recorder.endRecording().toImage(120, 120);
117+
final ByteData bytes = (await image.toByteData())!;
118+
int count = 0;
119+
for (int y = 0; y < 120; y++) {
120+
for (int x = 0; x < 120; x++) {
121+
final double dx = x + 0.5 - 50, dy = y + 0.5 - 50;
122+
final double r2 = dx * dx + dy * dy;
123+
// Ink outside the circle, or deeper inside than the stroke reaches.
124+
if ((r2 > 51 * 51 || r2 < 45 * 45) &&
125+
bytes.getUint8((y * 120 + x) * 4 + 3) > 0) {
126+
count++;
127+
}
128+
}
129+
}
130+
return count;
131+
});
132+
expect(outside, 0);
133+
});
134+
135+
testWidgets('Round dashes with length paint along the stroke', (
136+
tester,
137+
) async {
138+
final int dots = await inkedPixels(
139+
tester,
140+
painterFor(cap: StrokeCapEnum.round),
141+
);
142+
final int dashes = await inkedPixels(
143+
tester,
144+
painterFor(cap: StrokeCapEnum.round, dashPattern: const [4, 6]),
145+
);
146+
expect(dashes, greaterThan(dots));
147+
});
148+
149+
testWidgets('A dashed side with round caps draws dots too', (tester) async {
150+
expect(
151+
await inkedPixels(
152+
tester,
153+
painterFor(cap: StrokeCapEnum.round, side: StrokeSide.top),
154+
),
155+
greaterThan(0),
156+
);
157+
});
158+
159+
testWidgets('A pattern of zeros paints nothing and does not hang', (
160+
tester,
161+
) async {
162+
expect(
163+
await inkedPixels(
164+
tester,
165+
painterFor(cap: StrokeCapEnum.round, dashPattern: const [0, 0]),
166+
),
167+
0,
168+
);
169+
});
170+
}

0 commit comments

Comments
 (0)