From 180a0af17718c62057b04587903e14ee42460d7d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 11:08:03 +0300 Subject: [PATCH 1/2] fix(erd): edge label readable at any zoom, hover tolerance in screen px, Mermaid FK ends (#1156) The edge label lived inside the zoomed canvas: at the fit zoom of a large schema it shrank to an unreadable size, at 300 % it tripled. It is now drawn above the canvas in screen space and follows pans and zooms. The 6 px hover tolerance was in canvas units, so an edge was hard to hit when zoomed out and too easy to hit when zoomed in; it is now 6 screen px. Mermaid wrote every relation as zero or many; a NOT NULL foreign key is now one or many, as the screen and the SVG draw it. --- lib/features/erd/erd_export.dart | 5 +- lib/features/erd/erd_view.dart | 52 ++++++++++++--------- test/features/erd/erd_test.dart | 79 +++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 25 deletions(-) diff --git a/lib/features/erd/erd_export.dart b/lib/features/erd/erd_export.dart index 5e087cf9..5eab7087 100644 --- a/lib/features/erd/erd_export.dart +++ b/lib/features/erd/erd_export.dart @@ -79,9 +79,12 @@ class ErdExport { } b.writeln(' }'); } + // The FK end as on screen: zero or many when the column may be NULL, + // one or many otherwise. for (final r in schema.relations) { + final many = r.optional ? 'o{' : '|{'; b.writeln( - ' ${_id(r.toTable)} ||--o{ ${_id(r.fromTable)} : "${r.fromColumn.replaceAll('"', '')}"'); + ' ${_id(r.toTable)} ||--$many ${_id(r.fromTable)} : "${r.fromColumn.replaceAll('"', '')}"'); } return b.toString(); } diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 7e69183b..431b7818 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -561,10 +561,12 @@ class _ErdViewState extends material.State { } } - /// Pointer over the canvas: names the relation within 6 px of it, if any. + /// Pointer over the canvas ([p] in canvas space): names the relation within + /// 6 screen px of it, if any, whatever the zoom. void _hoverCanvas(material.Offset p) { ErdRelation? hit; - var best = 6.0; + final scale = _scale; + var best = 6.0 / (scale <= 0 ? 1 : scale); for (final r in _routes) { final d = ErdGeometry.distanceToRoute(r.points, p); if (d <= best) { @@ -826,26 +828,6 @@ class _ErdViewState extends material.State { ), ), ), - material.Positioned.fill( - child: material.IgnorePointer( - child: material.ValueListenableBuilder< - (ErdRelation, material.Offset)?>( - valueListenable: _edgeTipNotifier, - builder: (context, tip, _) => tip == null - ? const material.SizedBox.shrink() - : material.Stack( - children: [ - material.Positioned( - left: tip.$2.dx + 12, - top: tip.$2.dy + 12, - child: _edgeTipLabel( - context, tip.$1), - ), - ], - ), - ), - ), - ), ], ); }, @@ -854,6 +836,32 @@ class _ErdViewState extends material.State { ), ), ), + // The edge label sits above the zoomed canvas, in screen space, + // so it stays readable at any zoom and is never in a PNG. + material.Positioned.fill( + child: material.IgnorePointer( + child: material.ListenableBuilder( + listenable: material.Listenable.merge( + [_edgeTipNotifier, _transform]), + builder: (context, _) { + final tip = _edgeTipNotifier.value; + if (tip == null) return const material.SizedBox.shrink(); + final at = material.MatrixUtils.transformPoint( + _transform.value, tip.$2); + return material.Stack( + children: [ + material.Positioned( + key: const material.ValueKey('erd_edge_tip'), + left: at.dx + 12, + top: at.dy + 12, + child: _edgeTipLabel(context, tip.$1), + ), + ], + ); + }, + ), + ), + ), material.Positioned( left: 8, top: 8, diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index ac268328..28e2da1e 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; -import 'package:flutter/gestures.dart' show kSecondaryButton; +import 'package:flutter/gestures.dart' + show PointerDeviceKind, kSecondaryButton; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -387,7 +388,18 @@ void main() { expect(m, contains('users {')); expect(m, contains('integer id PK')); expect(m, contains('integer user_id FK')); - expect(m, contains('users ||--o{ orders : "user_id"')); + // user_id is NOT NULL here: one or many. + expect(m, contains('users ||--|{ orders : "user_id"')); + }); + + test('mermaid marks a nullable foreign key as zero or many', () { + final s = ErdSchema.fromCatalog(columnRows: [ + ['users', 'id', 'int', '1', '0'], + ['orders', 'user_id', 'int', '0', '1'], + ], fkRows: [ + ['orders', 'user_id', 'users', 'id'], + ]); + expect(ErdExport.toMermaid(s), contains('users ||--o{ orders')); }); test('mermaid sanitizes odd identifiers', () { @@ -674,6 +686,69 @@ void main() { expect(find.text('Show relations'), findsNothing); }); + testWidgets('hovering an edge names its columns at any zoom', (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: delegate(), dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + + // The same schema, layout and routes as the view draws. + final schema = ErdSchema.fromCatalog(columnRows: [ + ['users', 'id', 'INTEGER', '1'], + ['orders', 'user_id', 'INTEGER', '0'], + ], fkRows: [ + ['orders', 'user_id', 'users', 'id'], + ]); + final layout = ErdLayout.compute(schema); + final route = ErdRouter.route(schema, layout).single.points; + // Middle of the longest segment: on the edge, away from the cards. + var a = route[0], b = route[1]; + for (var i = 1; i < route.length; i++) { + if ((route[i] - route[i - 1]).distance > (b - a).distance) { + a = route[i - 1]; + b = route[i]; + } + } + final onEdge = (a + b) / 2; + + material.Offset toScreen(material.Offset canvas, double scale) { + final card = t.getTopLeft( + find.byKey(const material.ValueKey('erd_table_users'))); + return card + (canvas - layout.positions['users']!) * scale; + } + + double scale() => int.parse(t + .widget( + find.byKey(const material.ValueKey('erd_zoom_label'))) + .data! + .replaceAll('%', '')) / + 100; + + final mouse = await t.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: material.Offset.zero); + await mouse.moveTo(toScreen(onEdge, scale())); + await t.pump(); + const label = 'orders.user_id → users.id'; + expect(find.text(label), findsOneWidget); + final height = t.getSize(find.byKey(const material.ValueKey('erd_edge_tip'))).height; + + // Zoomed out, the label keeps its screen size. + await mouse.moveTo(material.Offset.zero); + await t.pump(); + await t.tap(find.byKey(const material.ValueKey('erd_zoom_out'))); + await t.pump(); + await mouse.moveTo(toScreen(onEdge, scale())); + await t.pump(); + expect(find.text(label), findsOneWidget); + expect( + t.getSize(find.byKey(const material.ValueKey('erd_edge_tip'))).height, + closeTo(height, 0.5)); + }); + testWidgets('a card can be dragged and Auto layout puts it back', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); From fedeb3ec0b72c269ac261e00e98c691ad9fdce9c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 11:11:43 +0300 Subject: [PATCH 2/2] fix(erd): the edge painter lets the pointer through to the hover layer (#1156) A CustomPaint is hit everywhere unless its painter says otherwise, and a Stack stops at the first child hit. The edge painter sat above the hover layer, so the hover layer never saw the pointer and the edge label never showed. The new hover test caught it. --- lib/features/erd/erd_view.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 431b7818..b20c89e1 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1211,6 +1211,12 @@ class _RelationPainter extends material.CustomPainter { } } + /// Edges take no pointer: a CustomPaint is hit everywhere by default, which + /// hid the pointer from the hover layer below and so the edge label never + /// showed. + @override + bool? hitTest(material.Offset position) => false; + @override bool shouldRepaint(_RelationPainter old) => old.routes != routes ||