diff --git a/lib/features/erd/widgets/erd_relation_painter.dart b/lib/features/erd/widgets/erd_relation_painter.dart index 264189ed..a32ccbea 100644 --- a/lib/features/erd/widgets/erd_relation_painter.dart +++ b/lib/features/erd/widgets/erd_relation_painter.dart @@ -33,34 +33,58 @@ class ErdRelationPainter extends material.CustomPainter { (r.relation.fromTable == focus || r.relation.toTable == focus); } - void _label(material.Canvas canvas, String text, material.Offset at, - material.Color c) { - final tp = material.TextPainter( + /// The markers are only ever `1` and `*`, in at most three colours (others, + /// others while something is focused, highlight), so each is laid out once + /// and reused by every relation and every repaint (#1518). A theme change + /// brings new colours; past a handful of entries the cache starts over. + static final _labels = <(String, int), material.TextPainter>{}; + static const _maxLabels = 16; + + static material.TextPainter _labelPainter(String text, material.Color c) { + final key = (text, c.toARGB32()); + final cached = _labels[key]; + if (cached != null) return cached; + if (_labels.length >= _maxLabels) { + for (final old in _labels.values) { + old.dispose(); + } + _labels.clear(); + } + return _labels[key] = material.TextPainter( text: material.TextSpan( text: text, style: material.TextStyle( fontSize: 10, color: c, fontWeight: material.FontWeight.w600)), textDirection: material.TextDirection.ltr, )..layout(); + } + + /// Rounded outline of a route, built once per [ErdRoute]: a repaint that + /// only changes the focus or the picked relation reuses the same routes. + static final _paths = Expando('erdRoundedPath'); + + void _label(material.Canvas canvas, String text, material.Offset at, + material.Color c) { + final tp = _labelPainter(text, c); tp.paint(canvas, at - material.Offset(tp.width / 2, tp.height / 2)); - tp.dispose(); } @override void paint(material.Canvas canvas, material.Size size) { + // One Paint for every route; only its colour and width change per pass. + final paint = material.Paint() + ..style = material.PaintingStyle.stroke + ..strokeCap = material.StrokeCap.round + ..strokeJoin = material.StrokeJoin.round; + final dimmed = color.withValues(alpha: _anyFocus ? 0.35 : 0.85); // Others first, focused on top. - for (final pass in [false, true]) { + for (final pass in const [false, true]) { + paint + ..color = pass ? highlight : dimmed + ..strokeWidth = pass ? 2 : 1.4; for (final r in routes) { if (_focused(r) != pass || r.points.length < 2) continue; - final paint = material.Paint() - ..color = pass - ? highlight - : color.withValues(alpha: _anyFocus ? 0.35 : 0.85) - ..style = material.PaintingStyle.stroke - ..strokeWidth = pass ? 2 : 1.4 - ..strokeCap = material.StrokeCap.round - ..strokeJoin = material.StrokeJoin.round; - canvas.drawPath(roundedPath(r.points), paint); + canvas.drawPath(_paths[r] ??= roundedPath(r.points), paint); // FK end: a crow's foot ("many"), or a bar when the FK column is // unique on its own ("one", #1281). if (r.relation.oneToOne) { diff --git a/test/features/erd/erd_relation_painter_test.dart b/test/features/erd/erd_relation_painter_test.dart new file mode 100644 index 00000000..b62e98a9 --- /dev/null +++ b/test/features/erd/erd_relation_painter_test.dart @@ -0,0 +1,98 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; + +ErdRoute _route(String from, String to, {bool oneToOne = false}) => ErdRoute( + ErdRelation( + fromTable: from, + fromColumn: 'id', + toTable: to, + toColumn: 'id', + oneToOne: oneToOne, + ), + const [Offset(0, 0), Offset(40, 0), Offset(40, 60), Offset(120, 60)], + ); + +void _paint(ErdRelationPainter painter) { + final recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), const Size(200, 100)); + recorder.endRecording().dispose(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final routes = [_route('a', 'b'), _route('b', 'c', oneToOne: true)]; + + // The painter reuses one Paint, the rounded paths and the laid-out labels + // (#1518); these pin that every state still draws. + group('ErdRelationPainter', () { + test('paints with nothing focused, a focused table and a picked relation', + () { + const blue = Color(0xFF2196F3); + const orange = Color(0xFFFF9800); + _paint(ErdRelationPainter( + routes: routes, color: blue, highlight: orange, focus: null)); + _paint(ErdRelationPainter( + routes: routes, color: blue, highlight: orange, focus: 'b')); + _paint(ErdRelationPainter( + routes: routes, + color: blue, + highlight: orange, + focus: null, + picked: routes.first.relation)); + }); + + test('repeated paints of the same routes keep working', () { + final painter = ErdRelationPainter( + routes: routes, + color: Colors.grey, + highlight: Colors.red, + focus: 'a', + ); + for (var i = 0; i < 5; i++) { + _paint(painter); + } + }); + + test('more distinct colours than the label cache holds still paint', () { + for (var i = 0; i < 40; i++) { + _paint(ErdRelationPainter( + routes: routes, + color: Color(0xFF000000 + i * 0x010203), + highlight: Color(0xFF800000 + i), + focus: i.isEven ? 'a' : null, + )); + } + }); + + test('a route with fewer than two points is skipped', () { + _paint(ErdRelationPainter( + routes: [ + ErdRoute(routes.first.relation, const [Offset(1, 1)]), + ...routes, + ], + color: Colors.grey, + highlight: Colors.red, + focus: null, + )); + }); + + test('repaints only when something it draws changed', () { + ErdRelationPainter make({String? focus, List? r}) => + ErdRelationPainter( + routes: r ?? routes, + color: Colors.grey, + highlight: Colors.red, + focus: focus, + ); + expect(make().shouldRepaint(make()), isFalse); + expect(make(focus: 'a').shouldRepaint(make()), isTrue); + expect(make(r: [...routes]).shouldRepaint(make()), isTrue); + }); + }); +}