diff --git a/assets/dart-ui/canvas_draw_arc.png b/assets/dart-ui/canvas_draw_arc.png new file mode 100644 index 00000000..4448beb3 Binary files /dev/null and b/assets/dart-ui/canvas_draw_arc.png differ diff --git a/assets/dart-ui/path_add_arc.png b/assets/dart-ui/path_add_arc.png new file mode 100644 index 00000000..867351eb Binary files /dev/null and b/assets/dart-ui/path_add_arc.png differ diff --git a/assets/dart-ui/path_add_arc_ccw.png b/assets/dart-ui/path_add_arc_ccw.png new file mode 100644 index 00000000..489dbed9 Binary files /dev/null and b/assets/dart-ui/path_add_arc_ccw.png differ diff --git a/bin/generate.dart b/bin/generate.dart index b56b9689..f56ff9eb 100644 --- a/bin/generate.dart +++ b/bin/generate.dart @@ -313,8 +313,9 @@ class DiagramGenerator { .where((File input) => path.basename(input.path) == 'error.log') .toList(); - if (errorFiles.length != 1) + if (errorFiles.length != 1) { throw GeneratorException('Subprocess did not complete cleanly!'); + } print('Processing ${inputFiles.length - 1} files...'); diff --git a/bin/pubspec.yaml b/bin/pubspec.yaml index 14704e2b..109710c5 100644 --- a/bin/pubspec.yaml +++ b/bin/pubspec.yaml @@ -8,9 +8,11 @@ dependencies: process_runner: ">=4.0.0 <5.0.0" dev_dependencies: + args: ^2.3.1 analyzer: ^4.1.0 test: ^1.16.8 + path: ^1.8.0 + process: ^4.2.1 environment: sdk: ">=2.17.0-0 <3.0.0" - diff --git a/bin/test/fake_process_manager.dart b/bin/test/fake_process_manager.dart index cf4c9b8b..1dbab9a3 100644 --- a/bin/test/fake_process_manager.dart +++ b/bin/test/fake_process_manager.dart @@ -243,7 +243,7 @@ class StringStreamConsumer implements StreamConsumer> { }), ); subscriptions.last.onDone(() => completers.last.complete(null)); - return Future.value(null); + return Future.value(); } @override @@ -254,6 +254,6 @@ class StringStreamConsumer implements StreamConsumer> { completers.clear(); streams.clear(); subscriptions.clear(); - return Future.value(null); + return Future.value(); } } diff --git a/packages/diagram_generator/lib/main.dart b/packages/diagram_generator/lib/main.dart index 16a9841c..8ccc316c 100644 --- a/packages/diagram_generator/lib/main.dart +++ b/packages/diagram_generator/lib/main.dart @@ -94,6 +94,7 @@ Future main(List args) async { AlignDiagramStep(controller), AnimationStatusValueDiagramStep(controller), AppBarDiagramStep(controller), + ArcDiagramStep(controller), BlendModeDiagramStep(controller), BottomNavigationBarDiagramStep(controller), BoxDecorationDiagramStep(controller), diff --git a/packages/diagrams/lib/diagrams.dart b/packages/diagrams/lib/diagrams.dart index cc7ccfbe..945c1aac 100644 --- a/packages/diagrams/lib/diagrams.dart +++ b/packages/diagrams/lib/diagrams.dart @@ -10,6 +10,7 @@ export 'src/align.dart'; export 'src/animation_diagram.dart'; export 'src/animation_status_value.dart'; export 'src/app_bar.dart'; +export 'src/arc_diagram.dart'; export 'src/blend_mode.dart'; export 'src/bottom_navigation_bar.dart'; export 'src/box_decoration.dart'; diff --git a/packages/diagrams/lib/src/arc_diagram.dart b/packages/diagrams/lib/src/arc_diagram.dart new file mode 100644 index 00000000..f62d6f3a --- /dev/null +++ b/packages/diagrams/lib/src/arc_diagram.dart @@ -0,0 +1,661 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:vector_math/vector_math_64.dart' hide Colors; + +import 'diagram_step.dart'; + +class Palette { + const Palette({ + required this.primary, + required this.text, + required this.subtitle, + required this.background, + required this.method, + required this.operator, + required this.literal, + required this.comment, + }); + + final Color primary; + final Color text; + final Color subtitle; + final Color background; + final Color method; + final Color operator; + final Color literal; + final Color comment; +} + +const Palette palette = Palette( + primary: Color(0xff447bef), + text: Color(0xff383a42), + subtitle: Color(0xff9c9cb1), + background: Colors.white, + method: Color(0xff447bef), + operator: Color(0xff1485ba), + literal: Color(0xffe2574e), + comment: Color(0xffa0a1a7), +); + +enum SpanType { + text, + method, + comment, + literal, + operator, +} + +class CodeSpan { + const CodeSpan(this.type, this.text); + + final SpanType type; + final String text; +} + +class CodeStyles { + CodeStyles({ + required this.palette, + required this.fontSize, + required this.fontFamily, + this.package, + }); + + final Palette palette; + final double fontSize; + final String fontFamily; + final String? package; + + late final TextStyle baseStyle = TextStyle( + color: palette.text, + fontSize: fontSize, + fontFamily: fontFamily, + package: package, + ); + + late final Map styles = { + SpanType.text: baseStyle, + SpanType.method: baseStyle.copyWith(color: palette.method), + SpanType.comment: baseStyle.copyWith(color: palette.comment), + SpanType.literal: baseStyle.copyWith(color: palette.literal), + SpanType.operator: baseStyle.copyWith(color: palette.operator), + }; + + TextSpan highlight(List spans) { + return TextSpan( + children: [ + for (final CodeSpan span in spans) + TextSpan( + style: styles[span.type], + text: span.text, + ), + ], + ); + } +} + +final CodeStyles codeStyles = CodeStyles( + palette: palette, + fontSize: 16.0, + fontFamily: 'Ubuntu Mono', +); + +/// Paints text around an arc on the given [canvas]. +/// +/// This works best when [rect] is a perfect square. +void paintTextArc( + Canvas canvas, + String text, { + bool inside = true, + bool clockwise = false, + required Rect rect, + required double theta, + required double alignment, + required TextStyle style, + double? letterSpacing = 4.0, +}) { + style = style.copyWith(letterSpacing: letterSpacing); + + if (text.isEmpty) { + return; + } + + final TextPainter textPainter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + ); + + // Lay out the full text and step through each character to get their offsets. + textPainter.layout(); + final List letterOffsets = []; + for (int i = 0; i < text.length + 1; i++) { + letterOffsets.add( + textPainter.getOffsetForCaret(TextPosition(offset: i), Rect.zero).dx, + ); + } + + // Calculate the baseline / middle radius, adjusting for whether or not the + // text is on the inside of the circle or running clockwise. + final LineMetrics lineMetrics = textPainter.computeLineMetrics().single; + final Rect baselineRect = rect.inflate(inside + ? (clockwise ? lineMetrics.descent : -lineMetrics.height) + : (clockwise ? lineMetrics.height : 0.0)); + final Rect centerRect = + rect.inflate(inside ? lineMetrics.height / -2 : lineMetrics.height / 2); + + // Calculate the aligned start offset (in pixels) and then the ratio of pixels + // to radians. + final double endOffset = letterOffsets.last; + final double textOffset = (-endOffset / 2) + (alignment * endOffset / 2); + final double circumference = (centerRect.width + centerRect.height) * 2; + final double thetaPixels = pi * 2 / circumference; + + for (int i = 0; i < text.length; i++) { + // Calculate the offset and angle of the center of the letter, using the + // center is important because the text looks weird if we rotate it from + // the top left. + final String letter = text[i]; + final double letterOffset = letterOffsets[i]; + final double letterWidth = letterOffsets[i + 1] - letterOffset; + final double offset = (letterOffset + textOffset) + letterWidth / 2; + final double arcTheta = + theta + offset * (clockwise ? thetaPixels : -thetaPixels); + final Offset arcOffset = Offset( + cos(arcTheta) * baselineRect.width / 2, + sin(arcTheta) * baselineRect.height / 2, + ) + + baselineRect.center; + + // Finally paint the letter. + canvas.save(); + canvas.translate(arcOffset.dx, arcOffset.dy); + canvas.rotate(arcTheta + pi / (clockwise ? 2 : -2)); + textPainter.text = TextSpan(text: letter, style: style); + textPainter.layout(); + textPainter.paint(canvas, Offset(letterWidth / -2, 0)); + canvas.restore(); + } +} + +/// Paints [span] to [canvas] with a given offset and alignment. +void paintSpan( + Canvas canvas, + TextSpan span, { + required Offset offset, + Alignment alignment = Alignment.center, + TextAlign textAlign = TextAlign.center, +}) { + final TextPainter result = TextPainter( + textDirection: TextDirection.ltr, + text: span, + textAlign: textAlign, + ); + result.layout(); + result.paint( + canvas, + Offset( + offset.dx + (result.width / -2) + (alignment.x * result.width / 2), + offset.dy + (result.height / -2) + (alignment.y * result.height / 2), + ), + ); +} + +void paintArrowHead( + Canvas canvas, + Offset center, + double angle, + Color color, { + double length = 7.0, + double thickness = 3.0, + bool bottomOnly = false, +}) { + final Matrix2 matrix = Matrix2.rotation(angle); + final Vector2 topVec = matrix.transform(Vector2(-length, length)); + final Vector2 bottomVec = matrix.transform(Vector2(-length, -length)); + + final Path path = Path() + ..moveTo(center.dx + bottomVec.x, center.dy + bottomVec.y) + ..lineTo(center.dx, center.dy); + + if (!bottomOnly) { + path.lineTo(center.dx + topVec.x, center.dy + topVec.y); + } + + canvas.drawPath( + path, + Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = color + ..strokeWidth = thickness, + ); +} + +/// Similar to [paintSpan] but provides a default text style. +void paintLabel( + Canvas canvas, + String label, { + required Offset offset, + FontStyle style = FontStyle.normal, + FontWeight fontWeight = FontWeight.normal, + Color color = Colors.black45, + double fontSize = 14.0, + Alignment alignment = Alignment.center, + TextAlign textAlign = TextAlign.center, +}) { + paintSpan( + canvas, + TextSpan( + text: label, + style: TextStyle( + color: color, + fontWeight: fontWeight, + fontStyle: style, + fontSize: fontSize, + ), + ), + offset: offset, + alignment: alignment, + textAlign: textAlign, + ); +} + +class ArcDiagramPainter extends CustomPainter { + const ArcDiagramPainter({ + required this.startAngle, + required this.sweepAngle, + this.startLabelAlignment = 0.5, + this.sweepLabelAlignment = 0.5, + }); + + final double startAngle; + final double sweepAngle; + final double startLabelAlignment; + final double sweepLabelAlignment; + + @override + void paint(Canvas canvas, Size size) { + final Color startArcColor = palette.primary; + final Color sweepArcColor = palette.text; + const double arcRectMargin = 32.0; + const double rectLabelMargin = 8.0; + final Rect rect = Rect.fromLTRB( + arcRectMargin, + arcRectMargin + rectLabelMargin, + size.height - arcRectMargin, + (size.height - arcRectMargin) + rectLabelMargin, + ); + const double arcLineThickness = 4.0; + final bool overlaps = startAngle >= 0 != sweepAngle >= 0; + final double overlapNudge = overlaps ? 5 : 0.0; + final Rect arcRect = rect.deflate(4.0 + overlapNudge); + + final Offset nudgedArcStart = arcRect.center + + Offset( + cos(startAngle) * (arcRect.width / 2 + overlapNudge), + sin(startAngle) * (arcRect.height / 2 + overlapNudge), + ); + + final Offset arcEnd = arcRect.center + + Offset( + cos(startAngle + sweepAngle) * arcRect.width / 2, + sin(startAngle + sweepAngle) * arcRect.height / 2, + ); + + final Paint paint = Paint() + ..color = palette.subtitle + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + // Unit circle, 8 angles at 45 degree increments + for (int i = 0; i < 8; i++) { + final double theta = (i / 8) * pi * 2; + + const double rayLength = 16.0; + const double rayStart = -32.0; + const double labelStart = -86.0; + + final Offset arcIn = Offset( + cos(theta) * ((rayStart - rayLength) + arcRect.width / 2), + sin(theta) * ((rayStart - rayLength) + arcRect.height / 2), + ); + + final Offset arcOut = Offset( + cos(theta) * (rayStart + arcRect.width / 2), + sin(theta) * (rayStart + arcRect.height / 2), + ); + + // Draw spokes of unit circle + canvas.drawLine( + arcRect.center + arcIn, + arcRect.center + arcOut, + paint, + ); + + final Offset labelOffset = arcRect.center + + Offset( + cos(theta) * (labelStart + arcRect.width / 2), + sin(theta) * (labelStart + arcRect.height / 2), + ); + + // Label text for each angle + paintSpan( + canvas, + TextSpan( + children: [ + TextSpan( + text: const [ + '0°, 360°', + '45°', + '90°', + '135°', + '180°', + '225°', + '270°', + '315°', + ][i], + ), + const TextSpan(text: '\n'), + TextSpan( + text: const [ + '0, 2π', + 'π/4', + 'π/2', + '3π/4', + 'π', + '5π/4', + '3π/2', + '7π/4', + ][i], + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + style: TextStyle( + color: palette.subtitle, + ), + ), + offset: labelOffset, + ); + } + + // Draw rect + label text + paint + ..color = palette.subtitle + ..strokeWidth = 3.0; + canvas.drawRect(rect, paint); + paintLabel( + canvas, + 'rect', + offset: rect.topLeft - const Offset(0, 4), + alignment: Alignment.topRight, + color: palette.subtitle, + fontSize: 18.0, + fontWeight: FontWeight.bold, + ); + + // Draw arrow at sweepAngle + paintArrowHead( + canvas, + arcEnd, + startAngle + sweepAngle + pi / (startAngle > sweepAngle ? -2 : 2), + sweepArcColor, + thickness: arcLineThickness, + bottomOnly: overlaps, + ); + + // Draw indicator for startAngle + paint + ..color = startArcColor + ..strokeWidth = arcLineThickness; + canvas.drawArc(arcRect.inflate(overlapNudge), 0, startAngle, false, paint); + paintTextArc( + canvas, + 'startAngle', + rect: arcRect.deflate(overlaps ? 4.0 : 4.0), + style: TextStyle( + color: startArcColor, + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + alignment: 0.0, + theta: startAngle * startLabelAlignment, + ); + + // Draw indicator for sweepAngle + paint + ..color = sweepArcColor + ..strokeWidth = arcLineThickness; + canvas.drawArc(arcRect, startAngle, sweepAngle, false, paint); + paintTextArc( + canvas, + 'sweepAngle', + rect: arcRect.deflate(4.0), + style: TextStyle( + color: sweepArcColor, + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + alignment: 0.0, + theta: lerpDouble( + startAngle, + startAngle + sweepAngle, + sweepLabelAlignment, + )!, + ); + + // Draw arrow at startAngle + paintArrowHead( + canvas, + nudgedArcStart, + startAngle + pi / 2, + startArcColor, + thickness: arcLineThickness, + bottomOnly: overlaps, + ); + } + + @override + bool shouldRepaint(ArcDiagramPainter oldDelegate) { + return false; + } +} + +abstract class ArcDiagram extends StatelessWidget implements DiagramMetadata { + const ArcDiagram({super.key}); +} + +class CanvasDrawArcDiagram extends ArcDiagram { + const CanvasDrawArcDiagram({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: palette.background, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 400, + height: 400, + child: CustomPaint( + foregroundPainter: ArcDiagramPainter( + startAngle: pi / 2, + sweepAngle: 3 * pi / 4, + ), + ), + ), + SizedBox( + width: 300, + child: Text.rich( + codeStyles.highlight(const [ + CodeSpan(SpanType.text, 'canvas.'), + CodeSpan(SpanType.method, 'drawArc'), + CodeSpan(SpanType.text, '('), + CodeSpan(SpanType.text, '\n rect,'), + CodeSpan(SpanType.text, '\n pi '), + CodeSpan(SpanType.operator, '/ '), + CodeSpan(SpanType.literal, '2'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.comment, ' // 90° startAngle'), + CodeSpan(SpanType.literal, '\n 3 '), + CodeSpan(SpanType.operator, '* '), + CodeSpan(SpanType.text, 'pi '), + CodeSpan(SpanType.operator, '/ '), + CodeSpan(SpanType.literal, '4'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.comment, ' // 135° sweepAngle'), + CodeSpan(SpanType.literal, '\n false'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.text, '\n paint,'), + CodeSpan(SpanType.text, '\n);'), + ]), + ), + ) + ], + ), + ); + } + + @override + String get name => 'canvas_draw_arc'; +} + +class PathAddArcDiagram extends ArcDiagram { + const PathAddArcDiagram({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: palette.background, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 400, + height: 400, + child: CustomPaint( + foregroundPainter: ArcDiagramPainter( + startAngle: pi / 2, + sweepAngle: 3 * pi / 4, + ), + ), + ), + SizedBox( + width: 300, + child: Text.rich( + codeStyles.highlight(const [ + CodeSpan(SpanType.comment, '// clockwise'), + CodeSpan(SpanType.text, '\npath.'), + CodeSpan(SpanType.method, 'addArc'), + CodeSpan(SpanType.text, '('), + CodeSpan(SpanType.text, '\n rect,'), + CodeSpan(SpanType.text, '\n pi '), + CodeSpan(SpanType.operator, '/ '), + CodeSpan(SpanType.literal, '2'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.comment, ' // 90° startAngle'), + CodeSpan(SpanType.literal, '\n 3 '), + CodeSpan(SpanType.operator, '* '), + CodeSpan(SpanType.text, 'pi '), + CodeSpan(SpanType.operator, '/ '), + CodeSpan(SpanType.literal, '4'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.comment, ' // 135° sweepAngle'), + CodeSpan(SpanType.text, '\n);'), + ]), + ), + ) + ], + ), + ); + } + + @override + String get name => 'path_add_arc'; +} + +class PathAddArcCCWDiagram extends ArcDiagram { + const PathAddArcCCWDiagram({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: palette.background, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 400, + height: 400, + child: CustomPaint( + foregroundPainter: ArcDiagramPainter( + startAngle: 5 * pi / 4, + sweepAngle: -3 * pi / 4, + startLabelAlignment: 1 / 5, + ), + ), + ), + SizedBox( + width: 300, + child: Text.rich( + codeStyles.highlight(const [ + CodeSpan(SpanType.comment, '// counter-clockwise'), + CodeSpan(SpanType.text, '\npath.'), + CodeSpan(SpanType.method, 'addArc'), + CodeSpan(SpanType.text, '('), + CodeSpan(SpanType.text, '\n rect,'), + CodeSpan(SpanType.literal, '\n 5 '), + CodeSpan(SpanType.operator, '* '), + CodeSpan(SpanType.text, 'pi '), + CodeSpan(SpanType.operator, '/ '), + CodeSpan(SpanType.literal, '4'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.comment, ' // 225° startAngle'), + CodeSpan(SpanType.literal, '\n -3 '), + CodeSpan(SpanType.operator, '* '), + CodeSpan(SpanType.text, 'pi '), + CodeSpan(SpanType.operator, '/ '), + CodeSpan(SpanType.literal, '4'), + CodeSpan(SpanType.text, ','), + CodeSpan(SpanType.comment, ' // -135° sweepAngle'), + CodeSpan(SpanType.text, '\n);'), + ]), + ), + ) + ], + ), + ); + } + + @override + String get name => 'path_add_arc_ccw'; +} + +class ArcDiagramStep extends DiagramStep { + ArcDiagramStep(super.controller); + + @override + final String category = 'dart-ui'; + + @override + Future> get diagrams async => const [ + CanvasDrawArcDiagram(), + PathAddArcDiagram(), + PathAddArcCCWDiagram(), + ]; + + @override + Future generateDiagram(ArcDiagram diagram) async { + controller.builder = (BuildContext context) => diagram; + return controller.drawDiagramToFile(File('${diagram.name}.png')); + } +} diff --git a/packages/diagrams/pubspec.yaml b/packages/diagrams/pubspec.yaml index 3899d211..0ce0cc8c 100644 --- a/packages/diagrams/pubspec.yaml +++ b/packages/diagrams/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: sdk: flutter path_provider: ^2.0.1 process: ^4.2.1 + vector_math: ^2.1.2 dev_dependencies: mockito: ^5.0.3