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..b20c89e1 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, @@ -1203,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 || 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));