diff --git a/lib/features/erd/erd_geometry.dart b/lib/core/erd/erd_geometry.dart similarity index 100% rename from lib/features/erd/erd_geometry.dart rename to lib/core/erd/erd_geometry.dart diff --git a/lib/core/erd/erd_group_ops.dart b/lib/core/erd/erd_group_ops.dart new file mode 100644 index 00000000..75c62436 --- /dev/null +++ b/lib/core/erd/erd_group_ops.dart @@ -0,0 +1,97 @@ +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; + +/// Operations on the user's table groups as functions of a list (#1362): each +/// returns a new list and never touches its argument, so the rules can be +/// tested without a widget tree. +abstract final class ErdGroupOps { + /// The group [table] belongs to, if any. + static ErdGroup? groupOf(List groups, String table) { + for (final g in groups) { + if (g.tables.contains(table)) return g; + } + return null; + } + + /// [tables] out of every group but [except]; a group left empty goes. + static List ungroupTables( + List groups, + Set tables, { + String? except, + }) { + final out = List.of(groups); + for (var i = out.length - 1; i >= 0; i--) { + final g = out[i]; + if (g.id == except || !g.tables.any(tables.contains)) continue; + final left = [for (final t in g.tables) if (!tables.contains(t)) t]; + if (left.isEmpty) { + out.removeAt(i); + } else { + out[i] = g.copyWith(tables: left); + } + } + return out; + } + + /// The number for the next default group name, `Group `. + static int nextNameNumber(List groups) { + var n = groups.length + 1; + while (groups.any((g) => g.name == 'Group $n')) { + n++; + } + return n; + } + + /// The number for the next group id, `g`; it also picks the colour. + static int nextIdNumber(List groups) { + var id = 1; + while (groups.any((g) => g.id == 'g$id')) { + id++; + } + return id; + } + + /// [groups] with [tables] taken out of their groups and put in a new group. + /// [tableNames] is every table of the diagram in its order, so the group + /// keeps that order. + static List withNewGroup( + List groups, { + required Set tables, + required Iterable tableNames, + required String name, + String? note, + }) { + final id = nextIdNumber(groups); + return [ + ...ungroupTables(groups, tables), + ErdGroup( + id: 'g$id', + name: name, + note: note, + color: erdHeaderSlots[(id - 1) % erdHeaderSlots.length], + tables: [ + for (final t in tableNames) + if (tables.contains(t)) t, + ], + ), + ]; + } + + /// [groups] with [tables] taken out of the other groups and added to the + /// group [id]. Unchanged apart from the ungrouping when [id] is unknown. + static List addTables( + List groups, + String id, + Set tables, + ) { + final out = ungroupTables(groups, tables, except: id); + final i = out.indexWhere((g) => g.id == id); + if (i < 0) return out; + final g = out[i]; + out[i] = g.copyWith(tables: [ + ...g.tables, + for (final t in tables) + if (!g.tables.contains(t)) t, + ]); + return out; + } +} diff --git a/lib/features/erd/erd_layout.dart b/lib/core/erd/erd_layout.dart similarity index 100% rename from lib/features/erd/erd_layout.dart rename to lib/core/erd/erd_layout.dart diff --git a/lib/core/erd/erd_layout_engine.dart b/lib/core/erd/erd_layout_engine.dart new file mode 100644 index 00000000..6826c765 --- /dev/null +++ b/lib/core/erd/erd_layout_engine.dart @@ -0,0 +1,125 @@ +import 'dart:math' show max, min; + +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; + +/// The pure, widget-free part of the diagram's logic (#1362): what is visible, +/// how saved positions merge with a computed layout, which tables are related +/// and which match a search. No Flutter widgets, so it is unit-testable +/// without a widget tester. +abstract final class ErdLayoutEngine { + /// The focused table as named in [schema]: `public.orders` falls back to + /// `orders` when the current schema names it without a prefix. + static String? focusIn(ErdSchema schema, String? focusTable) { + final f = focusTable; + if (f == null) return null; + if (schema.tables.any((t) => t.name == f)) return f; + final bare = f.contains('.') ? f.substring(f.indexOf('.') + 1) : f; + return schema.tables.any((t) => t.name == bare) ? bare : null; + } + + /// The schema as drawn: [hidden] tables gone, columns cut to keys in + /// keys-only mode, and [collapsed] cards without columns. + static ErdSchema visibleOf( + ErdSchema schema, { + required Set hidden, + required Set collapsed, + required ErdDetail detail, + }) { + final tables = [ + for (final t in schema.tables) + if (!hidden.contains(t.name)) + ErdTable( + name: t.name, + comment: t.comment, + columns: collapsed.contains(t.name) || detail == ErdDetail.names + ? const [] + : [ + for (final c in t.columns) + if (detail == ErdDetail.all || + c.isPrimaryKey || + c.isForeignKey) + c, + ], + ), + ]; + final names = {for (final t in tables) t.name}; + return ErdSchema( + tables: tables, + relations: [ + for (final r in schema.relations) + if (names.contains(r.fromTable) && names.contains(r.toTable)) r, + ], + ); + } + + /// [computed] with the positions [saved] holds. Tables the saved layout + /// does not know (new in the database) keep their computed arrangement, + /// moved right of the saved cards so nothing overlaps. + static ErdLayout withSaved(ErdLayout computed, ErdSavedLayout? saved) { + if (saved == null || saved.positions.isEmpty) return computed; + var layout = computed; + var savedRight = 0.0; + double? newLeft; + for (final e in computed.positions.entries) { + final p = saved.positions[e.key]; + if (p != null) { + savedRight = max(savedRight, p.dx + computed.widthFor(e.key)); + } else { + newLeft = newLeft == null ? e.value.dx : min(newLeft, e.value.dx); + } + } + final shift = + newLeft == null ? 0.0 : savedRight + ErdLayout.layerGap - newLeft; + for (final e in computed.positions.entries) { + final p = saved.positions[e.key]; + layout = layout.withPosition(e.key, p ?? e.value.translate(shift, 0)); + } + return layout; + } + + /// For a many-to-many link table: the tables it links, `users and roles`. + static Map junctionMap(ErdSchema schema) => { + for (final t in schema.tables) + if (t.isJunction) + t.name: { + for (final r in schema.relations) + if (r.fromTable == t.name) r.toTable, + }.join(' and '), + }; + + /// Tables related to each table by a foreign key, either way. Built once per + /// build, so the focus test is a set lookup per card. + static Map> neighbourMap(ErdSchema schema) { + final out = >{}; + for (final r in schema.relations) { + out.putIfAbsent(r.fromTable, () => {}).add(r.toTable); + out.putIfAbsent(r.toTable, () => {}).add(r.fromTable); + } + return out; + } + + /// Whether [table] is the focus or related to it. + static bool isFocusedIn( + Map> neighbours, + String? focus, + String table, + ) { + if (focus == null) return false; + return focus == table || (neighbours[focus]?.contains(table) ?? false); + } + + /// The tables whose name contains [query], at most [limit]. + static List matches( + ErdSchema schema, + String query, { + int limit = 8, + }) { + final q = query.toLowerCase(); + return [ + for (final t in schema.tables) + if (t.name.toLowerCase().contains(q)) t, + ].take(limit).toList(); + } +} diff --git a/lib/features/erd/erd_router.dart b/lib/core/erd/erd_router.dart similarity index 99% rename from lib/features/erd/erd_router.dart rename to lib/core/erd/erd_router.dart index e62f76e6..4774a46a 100644 --- a/lib/features/erd/erd_router.dart +++ b/lib/core/erd/erd_router.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; /// One routed relation: an orthogonal polyline from the FK column row of diff --git a/lib/features/erd/erd_card_measure.dart b/lib/features/erd/erd_card_measure.dart index 48972af7..ff0501e4 100644 --- a/lib/features/erd/erd_card_measure.dart +++ b/lib/features/erd/erd_card_measure.dart @@ -1,7 +1,7 @@ import 'package:flutter/painting.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; /// Measures what a table card shows with the card's own fonts, so the card is /// exactly as wide as its header and its widest `name type` row (#1276). diff --git a/lib/features/erd/erd_export.dart b/lib/features/erd/erd_export.dart index 96cd52ad..3b1b9d08 100644 --- a/lib/features/erd/erd_export.dart +++ b/lib/features/erd/erd_export.dart @@ -4,10 +4,10 @@ import 'dart:ui' show Offset, Rect, Size; import 'package:querya_desktop/core/erd/erd_note_text.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart' show ErdGroup, ErdNote; -import 'package:querya_desktop/features/erd/erd_geometry.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_geometry.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; -import 'package:querya_desktop/features/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; /// Colours of the SVG export as `#rrggbb`. The defaults are a light theme; /// the diagram view passes the colours of the current theme, so the file looks diff --git a/lib/features/erd/erd_export_data.dart b/lib/features/erd/erd_export_data.dart new file mode 100644 index 00000000..dcdfa99f --- /dev/null +++ b/lib/features/erd/erd_export_data.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/erd/erd_export.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +/// Turns what the diagram shows on screen (header tints, notes, group frames, +/// theme colours) into the plain values the SVG / DBML export takes (#1362). +/// +/// Everything is a function of its arguments: the view state passes its data +/// and the theme lookups in, nothing here reads widget state. +abstract final class ErdExportData { + static material.Color _fromHex(String hex) => + material.Color(0xFF000000 | int.parse(hex.substring(1), radix: 16)); + + /// Header bands for the SVG: the screen's tint over the export's card colour. + static Map headerFills( + ErdSchema schema, + String cardHex, { + required Map headerColors, + required material.Color Function(String table) headerColor, + }) { + final card = _fromHex(cardHex); + return { + for (final t in schema.tables) + if (erdHeaderSlot(t.name, headerColors) != null) + t.name: ErdSvgColors.hex(material.Color.alphaBlend( + headerColor(t.name).withValues(alpha: 0.14), card) + .toARGB32()), + }; + } + + /// Notes for the SVG, tinted as on screen over the export background. + static List notes( + List notes, + ErdSvgColors colors, { + required material.Color Function(String slot) slotColor, + required double noteTint, + }) { + final background = _fromHex(colors.background); + final muted = _fromHex(colors.muted); + return [ + for (final n in notes) + ErdSvgNote( + text: n.text, + rect: material.Rect.fromLTWH(n.x, n.y, n.width, n.height), + fill: ErdSvgColors.hex(material.Color.alphaBlend( + (n.color == null ? muted : slotColor(n.color!)) + .withValues(alpha: n.color == null ? noteTint : 0.18), + background) + .toARGB32()), + stroke: n.color == null + ? colors.border + : ErdSvgColors.hex(slotColor(n.color!).toARGB32()), + ), + ]; + } + + /// Group frames for the SVG: the screen's tint over the export background. + static List groups( + List groups, + ErdLayout layout, + String backgroundHex, { + required material.Color Function(String slot) slotColor, + }) { + final background = _fromHex(backgroundHex); + return [ + for (final g in groups) + if (layout.frameOf(g.tables) case final frame?) + ErdSvgGroup( + name: g.name, + frame: frame, + fill: ErdSvgColors.hex(material.Color.alphaBlend( + slotColor(g.color).withValues(alpha: 0.06), background) + .toARGB32()), + stroke: ErdSvgColors.hex(slotColor(g.color).toARGB32()), + ), + ]; + } + + /// Colours of the current theme for the SVG export, as the screen draws the + /// cards and edges. + static ErdSvgColors themeColors(material.BuildContext context) { + final wb = context.workbench; + final palette = context.semanticPalette; + String hex(material.Color c) => ErdSvgColors.hex(c.toARGB32()); + return ErdSvgColors( + background: hex(wb.surface), + card: hex(wb.surface), + border: hex(wb.borderSubtle), + header: hex(material.Color.alphaBlend( + wb.accent.withValues(alpha: 0.10), wb.surface)), + text: hex(shadcn.Theme.of(context).colorScheme.foreground), + muted: hex(wb.mutedForeground), + edge: hex(wb.mutedForeground), + primaryKey: hex(palette.type1), + foreignKey: hex(palette.type2), + ); + } +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 58b71816..57dfab09 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -11,34 +11,48 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; -import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; import 'package:querya_desktop/features/erd/erd_card_measure.dart'; import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:querya_desktop/features/erd/erd_geometry.dart'; +import 'package:querya_desktop/features/erd/erd_export_data.dart'; +import 'package:querya_desktop/core/erd/erd_geometry.dart'; import 'package:querya_desktop/core/erd/erd_note_text.dart'; import 'package:querya_desktop/features/erd/erd_group_dialog.dart'; import 'package:querya_desktop/features/erd/erd_note_dialog.dart'; import 'package:querya_desktop/features/erd/erd_view_name_dialog.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_group_ops.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout_engine.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; -import 'package:querya_desktop/features/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_edge_tip_label.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_group_frame.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_search_panel.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_status_body.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_table_menu.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_views_menu.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_table_card.dart'; import 'package:querya_desktop/core/export/image_pdf.dart'; import 'package:querya_desktop/core/export/svg_png.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; -import 'package:querya_desktop/shared/widgets/querya_empty_state.dart'; -import 'package:querya_desktop/shared/widgets/querya_search_field.dart'; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/querya_action_button.dart'; import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +// Moved out of this file (#1362); re-exported for existing importers. +export 'package:querya_desktop/features/erd/widgets/erd_relation_painter.dart' + show roundedPath; +export 'package:querya_desktop/features/erd/widgets/erd_table_card.dart' + show erdCardBuilds; + /// Saves an exported diagram file; replaceable in tests. typedef ErdFileSaver = Future Function(String name, Uint8List bytes); @@ -55,30 +69,6 @@ Future defaultErdFileSaver(String name, Uint8List bytes) async { } /// Interactive entity-relationship diagram of the connected database. -/// Entries of the diagram's Export menu. -enum _ExportAction { - mermaid, - svg, - png, - pdfA4, - pdfA3, - dbml, - copyMermaid, - copyDbml, - toggleTheme, -} - -/// A card's focus: related to the focused table, and faded while another -/// table is picked and this one is not related to it. -/// A card's share of the focus: highlighted, faded, and the column of a -/// picked relation on it, if any. -typedef _CardFocus = ({bool highlighted, bool faded, String? column}); - -/// Card builds so far. A test seam: hovering one card must not rebuild the -/// others. -@visibleForTesting -int erdCardBuilds = 0; - class ErdView extends material.StatefulWidget { const ErdView({ super.key, @@ -150,7 +140,7 @@ class _ErdViewState extends material.State { /// [_cardStates]; [_syncFocus] moves only the ones whose focus changed, so a /// hover rebuilds the cards it touches and not the whole canvas. final _focus = material.ValueNotifier(null); - final _cardStates = >{}; + final _cardStates = >{}; Map> _neighbours = const {}; /// Density: only PK and FK columns, cards collapsed to their header, and @@ -295,12 +285,12 @@ class _ErdViewState extends material.State { } setState(() { _schema = schema; - _neighbours = _neighbourMap(schema); - _junctions = _junctionMap(schema); + _neighbours = ErdLayoutEngine.neighbourMap(schema); + _junctions = ErdLayoutEngine.junctionMap(schema); _pickedRelation = null; if (saved != null) _applyState(saved, colours: true); _marked.clear(); - _setLayout(_withSaved(_compute(_visibleOf(schema)), saved)); + _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; }); if (schema.truncated) { @@ -326,7 +316,7 @@ class _ErdViewState extends material.State { } else { _fit(); } - final picked = _focusIn(schema); + final picked = ErdLayoutEngine.focusIn(schema, widget.focusTable); if (picked != null) _selectTable(_visibleOf(schema), picked); }); } catch (e) { @@ -384,52 +374,29 @@ class _ErdViewState extends material.State { ]; } - ErdGroup? _groupOf(String table) { - for (final g in _groups) { - if (g.tables.contains(table)) return g; - } - return null; - } + ErdGroup? _groupOf(String table) => ErdGroupOps.groupOf(_groups, table); - /// [tables] out of every group but [except]; a group left empty goes. - void _ungroupTables(Set tables, {String? except}) { - for (var i = _groups.length - 1; i >= 0; i--) { - final g = _groups[i]; - if (g.id == except || !g.tables.any(tables.contains)) continue; - final left = [for (final t in g.tables) if (!tables.contains(t)) t]; - if (left.isEmpty) { - _groups.removeAt(i); - } else { - _groups[i] = g.copyWith(tables: left); - } - } + void _replaceGroups(List groups) { + final next = List.of(groups); + _groups + ..clear() + ..addAll(next); } /// Asks for a name and groups [tables], taking them out of other groups. Future _newGroup(Set tables) async { if (tables.isEmpty) return; - var n = _groups.length + 1; - while (_groups.any((g) => g.name == 'Group $n')) { - n++; - } + final n = ErdGroupOps.nextNameNumber(_groups); final text = await showErdGroupDialog(context, title: 'New group', action: 'Create', name: 'Group $n'); if (text == null || !mounted) return; - var id = 1; - while (_groups.any((g) => g.id == 'g$id')) { - id++; - } setState(() { - _ungroupTables(tables); - _groups.add(ErdGroup( - id: 'g$id', + _replaceGroups(ErdGroupOps.withNewGroup( + _groups, + tables: tables, + tableNames: [for (final t in _schema?.tables ?? const []) t.name], name: text.name, note: text.note, - color: erdHeaderSlots[(id - 1) % erdHeaderSlots.length], - tables: [ - for (final t in _schema?.tables ?? const []) - if (tables.contains(t.name)) t.name, - ], )); _marked.clear(); }); @@ -458,16 +425,8 @@ class _ErdViewState extends material.State { void _addToGroup(String id, Set tables) { setState(() { - _ungroupTables(tables, except: id); - final i = _groups.indexWhere((g) => g.id == id); - if (i < 0) return; - final g = _groups[i]; - _groups[i] = g.copyWith(tables: [ - ...g.tables, - for (final t in tables) - if (!g.tables.contains(t)) t, - ]); - _marked.clear(); + _replaceGroups(ErdGroupOps.addTables(_groups, id, tables)); + if (_groups.any((g) => g.id == id)) _marked.clear(); }); _gatherIfCovering(id); _scheduleSave(); @@ -497,7 +456,7 @@ class _ErdViewState extends material.State { } void _removeFromGroup(String table) { - setState(() => _ungroupTables({table})); + setState(() => _replaceGroups(ErdGroupOps.ungroupTables(_groups, {table}))); _scheduleSave(); } @@ -527,7 +486,7 @@ class _ErdViewState extends material.State { _pickedRelation = null; _marked.clear(); _applyState(target, colours: false); - _setLayout(_withSaved(_compute(_visibleOf(schema)), target)); + _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), target)); }); _syncFocus(); material.WidgetsBinding.instance.addPostFrameCallback((_) { @@ -940,33 +899,6 @@ class _ErdViewState extends material.State { } } - /// [computed] with the saved cards back where the user left them. Tables - /// the saved layout does not know (new in the database) keep their computed - /// arrangement, moved right of the saved cards so nothing overlaps. - ErdLayout _withSaved(ErdLayout computed, ErdSavedLayout? saved) { - if (saved == null || saved.positions.isEmpty) return computed; - var layout = computed; - var savedRight = 0.0; - double? newLeft; - for (final e in computed.positions.entries) { - final p = saved.positions[e.key]; - if (p != null) { - savedRight = max(savedRight, p.dx + computed.widthFor(e.key)); - } else { - newLeft = newLeft == null ? e.value.dx : min(newLeft, e.value.dx); - } - } - final shift = newLeft == null - ? 0.0 - : savedRight + ErdLayout.layerGap - newLeft; - for (final e in computed.positions.entries) { - final p = saved.positions[e.key]; - layout = layout.withPosition( - e.key, p ?? e.value.translate(shift, 0)); - } - return layout; - } - /// Saves the layout once the changes pause. void _scheduleSave() { if (!_persists || _restoring || _schema == null || _layout == null) { @@ -1049,45 +981,13 @@ class _ErdViewState extends material.State { : ErdRouter.route(_visibleOf(schema), layout); } - /// The focused table as named in [schema]: `public.orders` falls back to - /// `orders` when the current schema names it without a prefix. - String? _focusIn(ErdSchema schema) { - final f = widget.focusTable; - if (f == null) return null; - if (schema.tables.any((t) => t.name == f)) return f; - final bare = f.contains('.') ? f.substring(f.indexOf('.') + 1) : f; - return schema.tables.any((t) => t.name == bare) ? bare : null; - } - - /// The schema as drawn: hidden tables gone, columns cut to keys in keys-only - /// mode, and collapsed cards without columns. - ErdSchema _visibleOf(ErdSchema schema) { - final tables = [ - for (final t in schema.tables) - if (!_hidden.contains(t.name)) - ErdTable( - name: t.name, - comment: t.comment, - columns: _collapsed.contains(t.name) || _detail == ErdDetail.names - ? const [] - : [ - for (final c in t.columns) - if (_detail == ErdDetail.all || - c.isPrimaryKey || - c.isForeignKey) - c, - ], - ), - ]; - final names = {for (final t in tables) t.name}; - return ErdSchema( - tables: tables, - relations: [ - for (final r in schema.relations) - if (names.contains(r.fromTable) && names.contains(r.toTable)) r, - ], - ); - } + /// The schema as drawn, for the current density and visibility. + ErdSchema _visibleOf(ErdSchema schema) => ErdLayoutEngine.visibleOf( + schema, + hidden: _hidden, + collapsed: _collapsed, + detail: _detail, + ); /// Recomputes the layout for the current density and visibility. /// @@ -1102,7 +1002,7 @@ class _ErdViewState extends material.State { setState(() { _setLayout(arrange || current == null ? computed - : _withSaved(computed, ErdSavedLayout(positions: current.positions))); + : ErdLayoutEngine.withSaved(computed, ErdSavedLayout(positions: current.positions))); if (current != null) _followTables(current); }); _scheduleSave(); @@ -1325,13 +1225,8 @@ class _ErdViewState extends material.State { } } - List _matches(ErdSchema schema) { - final q = _query.toLowerCase(); - return [ - for (final t in schema.tables) - if (t.name.toLowerCase().contains(q)) t, - ].take(8).toList(); - } + List _matches(ErdSchema schema) => + ErdLayoutEngine.matches(schema, _query); /// Keyboard shortcuts for both Ctrl (Linux, Windows) and Cmd (macOS). Map _bindings() { @@ -1355,41 +1250,11 @@ class _ErdViewState extends material.State { return out; } - static Map _junctionMap(ErdSchema schema) => { - for (final t in schema.tables) - if (t.isJunction) - t.name: { - for (final r in schema.relations) - if (r.fromTable == t.name) r.toTable, - }.join(' and '), - }; - - /// Tables related to each table by a foreign key, either way. Built once per - /// build, so the focus test is a set lookup per card. - static Map> _neighbourMap(ErdSchema schema) { - final out = >{}; - for (final r in schema.relations) { - out.putIfAbsent(r.fromTable, () => {}).add(r.toTable); - out.putIfAbsent(r.toTable, () => {}).add(r.fromTable); - } - return out; - } - - /// Whether [table] is the focus or related to it. - static bool _isFocusedIn( - Map> neighbours, - String? focus, - String table, - ) { - if (focus == null) return false; - return focus == table || (neighbours[focus]?.contains(table) ?? false); - } - /// The table the diagram focuses: the dragged one, then the picked one, then /// the one under the mouse. String? _currentFocus() => _dragging ?? _selected ?? _hovered.value; - _CardFocus _cardFocusFor(String table, String? focus) { + ErdCardFocus _cardFocusFor(String table, String? focus) { final p = _pickedRelation; if (p != null) { final on = table == p.fromTable || table == p.toTable; @@ -1398,7 +1263,7 @@ class _ErdViewState extends material.State { : (table == p.toTable ? p.toColumn : null); return (highlighted: on, faded: !on, column: column); } - final highlighted = _isFocusedIn(_neighbours, focus, table); + final highlighted = ErdLayoutEngine.isFocusedIn(_neighbours, focus, table); return ( highlighted: highlighted, faded: _selected != null && !highlighted, @@ -1407,7 +1272,7 @@ class _ErdViewState extends material.State { } /// The notifier of card [table], made on first use with its current focus. - material.ValueNotifier<_CardFocus> _cardState(String table) => + material.ValueNotifier _cardState(String table) => _cardStates.putIfAbsent( table, () => material.ValueNotifier(_cardFocusFor(table, _currentFocus())), @@ -1608,95 +1473,25 @@ class _ErdViewState extends material.State { } /// Header bands for the SVG: the screen's tint over the export's card colour. - Map _headerFills(ErdSchema schema, String cardHex) { - final card = material.Color( - 0xFF000000 | int.parse(cardHex.substring(1), radix: 16)); - return { - for (final t in schema.tables) - if (erdHeaderSlot(t.name, _headerColors) != null) - t.name: ErdSvgColors.hex(material.Color.alphaBlend( - _headerColor(t.name).withValues(alpha: 0.14), card) - .toARGB32()), - }; - } - - /// [name] cut to 28 characters with an ellipsis, for menus and the toolbar. - static String _shortName(String name) => - name.length <= 28 ? name : '${name.substring(0, 27)}…'; + Map _headerFills(ErdSchema schema, String cardHex) => + ErdExportData.headerFills( + schema, + cardHex, + headerColors: _headerColors, + headerColor: _headerColor, + ); /// The toolbar's view switcher: All tables, the saved views, and the /// commands to save, rename and delete one. - material.Widget _viewsMenu() { - final wb = context.workbench; - final active = _viewById(_activeView); - return QueryaActionMenu( - items: [ - // A mark on every row, so the labels line up. - QueryaActionMenuItem( - value: 'all', - label: 'All tables', - icon: active == null - ? material.Icons.radio_button_checked_rounded - : material.Icons.radio_button_unchecked_rounded, - ), - for (final v in _views) - QueryaActionMenuItem( - value: 'view:${v.id}', - label: '${_shortName(v.name)} (${v.tables.length})', - icon: v.id == active?.id - ? material.Icons.radio_button_checked_rounded - : material.Icons.radio_button_unchecked_rounded, - ), - const QueryaActionMenuItem( - value: 'new', - label: 'Save as new view…', - icon: material.Icons.bookmark_add_outlined, - ), - if (active != null) ...[ - const QueryaActionMenuItem( - value: 'rename', - label: 'Rename view…', - icon: material.Icons.edit_outlined, - ), - const QueryaActionMenuItem( - value: 'delete', - label: 'Delete view', - icon: material.Icons.delete_outline_rounded, - ), - ], - ], - onSelected: (v) { - switch (v) { - case 'all': - _switchView(null); - case 'new': - unawaited(_newView()); - case 'rename': - unawaited(_renameView()); - case 'delete': - _deleteView(); - default: - if (v.startsWith('view:')) _switchView(v.substring(5)); - } - }, - child: material.Padding( - key: const material.ValueKey('erd_views'), - padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon(material.Icons.bookmarks_outlined, - size: 16, color: wb.mutedForeground), - const material.SizedBox(width: 6), - Text(active == null ? 'All tables' : _shortName(active.name)), - const material.SizedBox(width: 4), - material.Icon(material.Icons.expand_more_rounded, - size: 16, color: wb.mutedForeground), - ], - ), - ), - ); - } + material.Widget _viewsMenu() => ErdViewsMenu( + views: _views, + active: _viewById(_activeView), + onShowAll: () => _switchView(null), + onSwitch: _switchView, + onNew: () => unawaited(_newView()), + onRename: () => unawaited(_renameView()), + onDelete: _deleteView, + ); /// Group entries of a card's menu: group it (with the marked tables), add /// it to a group, take it out of its group. @@ -1739,205 +1534,73 @@ class _ErdViewState extends material.State { } /// Notes for the SVG, tinted as on screen over the export background. - List _svgNotes(ErdSvgColors colors) { - material.Color parse(String hex) => - material.Color(0xFF000000 | int.parse(hex.substring(1), radix: 16)); - final background = parse(colors.background); - final muted = parse(colors.muted); - return [ - for (final n in _notes) - ErdSvgNote( - text: n.text, - rect: material.Rect.fromLTWH(n.x, n.y, n.width, n.height), - fill: ErdSvgColors.hex(material.Color.alphaBlend( - (n.color == null ? muted : _slotColor(n.color!)) - .withValues(alpha: n.color == null ? _noteTint : 0.18), - background) - .toARGB32()), - stroke: n.color == null - ? colors.border - : ErdSvgColors.hex(_slotColor(n.color!).toARGB32()), - ), - ]; - } + List _svgNotes(ErdSvgColors colors) => ErdExportData.notes( + _notes, + colors, + slotColor: _slotColor, + noteTint: _noteTint, + ); /// Group frames for the SVG: the screen's tint over the export background. - List _svgGroups(ErdLayout layout, String backgroundHex) { - final background = material.Color( - 0xFF000000 | int.parse(backgroundHex.substring(1), radix: 16)); - return [ - for (final g in _allGroups()) - if (layout.frameOf(g.tables) case final frame?) - ErdSvgGroup( - name: g.name, - frame: frame, - fill: ErdSvgColors.hex(material.Color.alphaBlend( - _slotColor(g.color).withValues(alpha: 0.06), background) - .toARGB32()), - stroke: ErdSvgColors.hex(_slotColor(g.color).toARGB32()), - ), - ]; - } + List _svgGroups(ErdLayout layout, String backgroundHex) => + ErdExportData.groups( + _allGroups(), + layout, + backgroundHex, + slotColor: _slotColor, + ); /// A group's frame: a tinted box that takes no pointer, and its title, /// which drags the whole group and has the group's menu. - List _groupFrame(ErdGroup g, ErdLayout layout) { - final frame = layout.frameOf(g.tables); - if (frame == null) return const []; - final color = _slotColor(g.color); - final title = material.MouseRegion( - cursor: _draggingGroup == g.id - ? material.SystemMouseCursors.grabbing - : material.SystemMouseCursors.grab, - child: material.GestureDetector( - key: material.ValueKey('erd_group_${g.id}'), - dragStartBehavior: DragStartBehavior.down, - onTapDown: (_) => _canvasFocus.requestFocus(), - onPanStart: (_) { - _canvasFocus.requestFocus(); - _groupDragStart(g.id); - }, - onPanUpdate: (d) => _groupDragMove(g, d.delta), - onPanEnd: (_) => _groupDragEnd(), - onPanCancel: _groupDragEnd, - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - g.isSchema - ? material.Icons.schema_outlined - : material.Icons.folder_open_rounded, - size: 13, - color: color), - const material.SizedBox(width: 5), - material.Flexible( - child: Text(g.name, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: color)), - ), - if (g.note case final note?) ...[ - const material.SizedBox(width: 5), - material.Tooltip( - message: note, - child: material.Icon(material.Icons.notes_rounded, - key: material.ValueKey('erd_group_note_${g.id}'), - size: 12, - color: color), - ), - ], - ], - ), - ), - ); - return [ - material.Positioned.fromRect( - rect: frame, - child: material.IgnorePointer( - child: material.DecoratedBox( - decoration: material.BoxDecoration( - color: color.withValues(alpha: 0.05), - borderRadius: material.BorderRadius.circular(12), - border: material.Border.all(color: color.withValues(alpha: 0.55)), - ), - ), - ), - ), - material.Positioned( - left: frame.left + 10, - top: frame.top + 3, - width: max(0.0, frame.width - 20), - height: ErdLayout.frameTitleHeight - 6, - child: material.Align( - alignment: material.Alignment.centerLeft, - child: g.isSchema - ? title - : ContextMenu( - items: [ - MenuButton( - key: material.ValueKey('erd_group_edit_${g.id}'), - onPressed: (_) => unawaited(_editGroup(g)), - child: const Text('Rename / note…'), - ), - MenuButton( - subMenu: [ - for (var i = 0; i < erdHeaderSlots.length; i++) - MenuButton( - leading: material.Icon(material.Icons.circle, - size: 12, color: _slotColor(erdHeaderSlots[i])), - onPressed: (_) => _updateGroup(g.id, - (o) => o.copyWith(color: erdHeaderSlots[i])), - child: Text('Colour ${i + 1}'), - ), - ], - child: const Text('Colour'), - ), - MenuButton( - key: material.ValueKey('erd_group_ungroup_${g.id}'), - onPressed: (_) => _ungroup(g.id), - child: const Text('Ungroup'), - ), - ], - child: title, - ), - ), - ), - ]; - } + List _groupFrame(ErdGroup g, ErdLayout layout) => + ErdGroupFrame.build( + group: g, + layout: layout, + slotColor: _slotColor, + dragging: _draggingGroup == g.id, + onFocusCanvas: _canvasFocus.requestFocus, + onDragStart: () => _groupDragStart(g.id), + onDragMove: (delta) => _groupDragMove(g, delta), + onDragEnd: _groupDragEnd, + onEdit: () => unawaited(_editGroup(g)), + onColor: (slot) => _updateGroup(g.id, (o) => o.copyWith(color: slot)), + onUngroup: () => _ungroup(g.id), + ); /// Colours of the current theme for the SVG export, as the screen draws the /// cards and edges. - ErdSvgColors _svgColors() { - final wb = context.workbench; - final palette = context.semanticPalette; - String hex(material.Color c) => ErdSvgColors.hex(c.toARGB32()); - return ErdSvgColors( - background: hex(wb.surface), - card: hex(wb.surface), - border: hex(wb.borderSubtle), - header: hex(material.Color.alphaBlend( - wb.accent.withValues(alpha: 0.10), wb.surface)), - text: hex(Theme.of(context).colorScheme.foreground), - muted: hex(wb.mutedForeground), - edge: hex(wb.mutedForeground), - primaryKey: hex(palette.type1), - foreignKey: hex(palette.type2), - ); - } + ErdSvgColors _svgColors() => ErdExportData.themeColors(context); /// `-erd`, or `erd` when the database name is unknown. String get _fileStem => widget.databaseName.isEmpty ? 'erd' : '${widget.databaseName}-erd'; - void _export(_ExportAction action, ErdSchema schema, ErdLayout layout) { + void _export(ErdExportAction action, ErdSchema schema, ErdLayout layout) { switch (action) { - case _ExportAction.mermaid: + case ErdExportAction.mermaid: _save( '$_fileStem.mmd', Uint8List.fromList(utf8.encode(ErdExport.toMermaid(schema)))); - case _ExportAction.svg: + case ErdExportAction.svg: unawaited(_exportSvg(schema, layout)); - case _ExportAction.png: + case ErdExportAction.png: unawaited(_exportPng(schema, layout)); - case _ExportAction.pdfA4: + case ErdExportAction.pdfA4: unawaited(_exportPdf(schema, layout, PdfPaper.a4)); - case _ExportAction.pdfA3: + case ErdExportAction.pdfA3: unawaited(_exportPdf(schema, layout, PdfPaper.a3)); - case _ExportAction.dbml: + case ErdExportAction.dbml: _save('$_fileStem.dbml', Uint8List.fromList(utf8.encode(_dbml(schema)))); - case _ExportAction.copyDbml: + case ErdExportAction.copyDbml: Clipboard.setData(ClipboardData(text: _dbml(schema))); showAppToast( context: context, message: 'DBML copied to the clipboard', ); - case _ExportAction.toggleTheme: + case ErdExportAction.toggleTheme: unawaited(_toggleExportTheme()); - case _ExportAction.copyMermaid: + case ErdExportAction.copyMermaid: Clipboard.setData(ClipboardData(text: ErdExport.toMermaid(schema))); showAppToast( context: context, @@ -1977,77 +1640,19 @@ class _ErdViewState extends material.State { } /// "orders.customer_id → customers.id" next to the pointer. - material.Widget _edgeTipLabel(material.BuildContext context, ErdRelation r) { - final cs = Theme.of(context).colorScheme; - return material.DecoratedBox( - decoration: material.BoxDecoration( - color: cs.popover, - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all(color: cs.border), - ), - child: material.Padding( - padding: - const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: material.Text( - '${r.fromTable}.${r.fromColumn} → ${r.toTable}.${r.toColumn}', - style: material.TextStyle( - fontSize: 11, color: cs.popoverForeground), - ), - ), - ); - } + material.Widget _edgeTipLabel(material.BuildContext context, ErdRelation r) => + ErdEdgeTipLabel(relation: r); /// Find-a-table field and the tables it matches. Enter picks the first. material.Widget _searchPanel(ErdSchema schema) { if (!_searchOpen) return const material.SizedBox.shrink(); - final wb = context.workbench; - final matches = _matches(schema); - return material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - QueryaSearchField( - controller: _searchController, - focusNode: _searchFocus, - placeholder: 'Find table', - debounceDuration: Duration.zero, - onChanged: (v) => setState(() => _query = v.trim()), - onSubmitted: (_) { - if (matches.isNotEmpty) _pickFromSearch(schema, matches.first.name); - }, - ), - if (_query.isNotEmpty) - material.Container( - margin: const material.EdgeInsets.only(top: 4), - decoration: material.BoxDecoration( - color: wb.surface, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all(color: wb.borderSubtle), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (matches.isEmpty) - material.Padding( - padding: const material.EdgeInsets.all(8), - child: const Text('No tables match').muted().small(), - ), - for (final t in matches) - material.GestureDetector( - key: material.ValueKey('erd_search_result_${t.name}'), - behavior: material.HitTestBehavior.opaque, - onTap: () => _pickFromSearch(schema, t.name), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - child: material.Text(t.name, - style: const material.TextStyle(fontSize: 12)), - ), - ), - ], - ), - ), - ], + return ErdSearchPanel( + controller: _searchController, + focusNode: _searchFocus, + query: _query, + matches: _matches(schema), + onChanged: (v) => setState(() => _query = v.trim()), + onPick: (name) => _pickFromSearch(schema, name), ); } @@ -2062,57 +1667,21 @@ class _ErdViewState extends material.State { body = const material.Center( child: QueryaSpinner(label: 'Loading schema…')); } else if (_error != null) { - body = material.Center( - child: QueryaEmptyState( - icon: material.Icon(material.Icons.error_outline_rounded, - color: wb.destructive), - title: 'Could not load the schema', - description: _error, - actionLabel: 'Retry', - onAction: _load, - ), - ); + body = ErdStatusBody.error(context, message: _error, onRetry: _load); } else if (full != null && full.tables.isNotEmpty && schema!.isEmpty) { // Every table was hidden from the diagram: they exist, so say so and // offer them back instead of "No tables found". - body = material.Center( - child: QueryaEmptyState( - icon: material.Icon(material.Icons.visibility_off_outlined, - color: wb.mutedForeground), - title: 'All tables are hidden', - description: '${_hidden.length} ' - '${_hidden.length == 1 ? 'table is' : 'tables are'} hidden ' - 'from the diagram.', - actionLabel: 'Show all tables', - onAction: _showAllTables, - ), - ); + body = ErdStatusBody.allHidden(context, + hiddenCount: _hidden.length, onShowAll: _showAllTables); } else if (schema == null || layout == null || schema.isEmpty) { - final db = widget.databaseName; - body = material.Center( - child: QueryaEmptyState( - icon: material.Icon(material.Icons.table_chart_outlined, - color: wb.mutedForeground), - title: 'No tables found', - description: db.isEmpty - ? 'This schema has no tables, or this role cannot see them.' - : 'The current schema of $db has no tables, or this role ' - 'cannot see them.', - ), - ); + body = ErdStatusBody.noTables(context, + databaseName: widget.databaseName); } else if (widget.neighbourhoodDepth != null && widget.focusTable != null && schema.relations.isEmpty) { - body = material.Center( - child: QueryaEmptyState( - icon: material.Icon(material.Icons.link_off_rounded, - color: wb.mutedForeground), - title: 'No foreign keys to or from ${widget.focusTable}', - description: 'This table has no relations within the schema.', - actionLabel: 'Open full diagram', - onAction: widget.onOpenFullDiagram, - ), - ); + body = ErdStatusBody.noRelations(context, + focusTable: widget.focusTable, + onOpenFullDiagram: widget.onOpenFullDiagram); } else { body = material.CallbackShortcuts( bindings: _bindings(), @@ -2161,7 +1730,7 @@ class _ErdViewState extends material.State { valueListenable: _focus, builder: (context, focus, _) => material.CustomPaint( - painter: _RelationPainter( + painter: ErdRelationPainter( routes: _routes, color: wb.mutedForeground, highlight: wb.accent, @@ -2177,81 +1746,27 @@ class _ErdViewState extends material.State { top: layout.positions[t.name]!.dy, // Each card listens to its own focus, so a hover // rebuilds only the cards it changes. - child: material.ValueListenableBuilder<_CardFocus>( + child: material.ValueListenableBuilder( valueListenable: _cardState(t.name), builder: (context, focus, _) => material.Opacity( // Unrelated cards fade while a table is picked. opacity: focus.faded ? 0.35 : 1, child: ContextMenu( - items: [ - if (widget.onOpenTable case final open?) - MenuButton( - key: material.ValueKey( - 'erd_menu_open_${t.name}'), - onPressed: (_) => open(t.name), - child: const Text('Open data'), - ), - if (widget.onOpenInSql case final inSql?) - MenuButton( - key: material.ValueKey( - 'erd_menu_sql_${t.name}'), - onPressed: (_) => inSql(t.name), - child: const Text('Open in SQL'), - ), - if (widget.onShowRelations - case final relations?) - MenuButton( - key: material.ValueKey( - 'erd_menu_relations_${t.name}'), - onPressed: (_) => relations(t.name), - child: const Text('Show relations'), - ), - MenuButton( - onPressed: (_) { - Clipboard.setData( - ClipboardData(text: t.name)); - }, - child: const Text('Copy name'), - ), - MenuButton( - onPressed: (_) => _toggleCollapsed(t.name), - child: Text(_collapsed.contains(t.name) - ? 'Expand' - : 'Collapse'), - ), - MenuButton( - onPressed: (_) => _hide(t.name), - child: const Text('Hide from diagram'), - ), - ..._groupMenu(t.name), - MenuButton( - key: material.ValueKey( - 'erd_menu_colour_${t.name}'), - subMenu: [ - MenuButton( - onPressed: (_) => - _setHeaderColor(t.name, null), - child: const Text('Default'), - ), - for (var i = 0; - i < erdHeaderSlots.length; - i++) - MenuButton( - leading: material.Icon( - material.Icons.circle, - size: 12, - color: _slotColor( - erdHeaderSlots[i]), - ), - onPressed: (_) => _setHeaderColor( - t.name, erdHeaderSlots[i]), - child: Text('Colour ${i + 1}'), - ), - ], - child: const Text('Header colour'), - ), - ], - child: _TableCard( + items: ErdTableMenu.items( + table: t.name, + onOpenData: widget.onOpenTable, + onOpenInSql: widget.onOpenInSql, + onShowRelations: widget.onShowRelations, + collapsed: _collapsed.contains(t.name), + onToggleCollapsed: () => + _toggleCollapsed(t.name), + onHide: () => _hide(t.name), + groupItems: _groupMenu(t.name), + slotColor: _slotColor, + onHeaderColor: (slot) => + _setHeaderColor(t.name, slot), + ), + child: ErdTableCard( table: t, width: layout.widthFor(t.name), headerColor: _headerColor(t.name), @@ -2416,76 +1931,12 @@ class _ErdViewState extends material.State { ignoring: !ready, child: material.Opacity( opacity: ready ? 1 : 0.5, - child: QueryaActionMenu<_ExportAction>( - items: const [ - QueryaActionMenuItem( - value: _ExportAction.toggleTheme, - label: 'Toggle export theme (light / current)', - icon: material.Icons.palette_outlined, - ), - QueryaActionMenuItem( - value: _ExportAction.mermaid, - label: 'Mermaid (.mmd)', - icon: material.Icons.account_tree_outlined, - ), - QueryaActionMenuItem( - value: _ExportAction.svg, - label: 'SVG', - icon: material.Icons.polyline_outlined, - ), - QueryaActionMenuItem( - value: _ExportAction.png, - label: 'PNG', - icon: material.Icons.image_outlined, - ), - QueryaActionMenuItem( - value: _ExportAction.pdfA4, - label: 'PDF (A4)', - icon: material.Icons.picture_as_pdf_outlined, - ), - QueryaActionMenuItem( - value: _ExportAction.pdfA3, - label: 'PDF (A3)', - icon: material.Icons.picture_as_pdf_outlined, - ), - QueryaActionMenuItem( - value: _ExportAction.dbml, - label: 'DBML (.dbml)', - icon: material.Icons.code_rounded, - ), - QueryaActionMenuItem( - value: _ExportAction.copyDbml, - label: 'Copy DBML', - icon: material.Icons.content_copy_rounded, - ), - QueryaActionMenuItem( - value: _ExportAction.copyMermaid, - label: 'Copy Mermaid', - icon: material.Icons.content_copy_rounded, - ), - ], + child: ErdExportMenu( onSelected: (action) { if (schema != null && layout != null) { _export(action, schema, layout); } }, - child: material.Padding( - key: const material.ValueKey('erd_export'), - padding: const material.EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon(material.Icons.file_download_outlined, - size: 16, color: wb.mutedForeground), - const material.SizedBox(width: 6), - const Text('Export'), - const material.SizedBox(width: 4), - material.Icon(material.Icons.expand_more_rounded, - size: 16, color: wb.mutedForeground), - ], - ), - ), ), ), ), @@ -2497,399 +1948,3 @@ class _ErdViewState extends material.State { ); } } - -class _TableCard extends material.StatelessWidget { - const _TableCard({ - required this.table, - required this.width, - required this.headerColor, - required this.highlighted, - this.marked = false, - this.focusColumn, - this.junctionOf, - required this.dragging, - required this.onHover, - required this.onDragStart, - required this.onDragMove, - required this.onDragEnd, - this.onOpen, - this.onSelect, - }); - - final ErdTable table; - - /// From the layout: sized to the card's content (#1276). - final double width; - - /// Header tint and icon: by schema, picked per table, or the accent. - final material.Color headerColor; - final bool highlighted; - - /// Marked for a group with Shift / Ctrl + click (#1282). - final bool marked; - - /// The column of a picked relation on this card, shaded (#1281). - final String? focusColumn; - - /// For a many-to-many link table: the tables it links. - final String? junctionOf; - final bool dragging; - final material.VoidCallback? onOpen; - final material.VoidCallback? onSelect; - final void Function(bool inside) onHover; - final material.VoidCallback onDragStart; - final void Function(material.Offset delta) onDragMove; - final material.VoidCallback onDragEnd; - - /// Everything a row knows, one fact a line. - static String _columnTip(ErdColumn c) => [ - '${c.name} ${c.type}', - if (c.isPrimaryKey) 'Primary key', - if (c.isForeignKey) 'Foreign key', - c.isNullable ? 'Nullable' : 'Not null', - if (c.isUnique && !c.isPrimaryKey) 'Unique', - if (c.isIdentity) 'Generated (identity / auto increment)', - if (c.defaultValue case final d?) 'Default: $d', - if (c.domainBase case final b?) 'Domain over $b', - if (c.enumValues.isNotEmpty) 'Values: ${_enumList(c.enumValues)}', - if (c.comment case final n?) 'Note: $n', - ].join('\n'); - - /// At most [cap] labels, then how many more. - static String _enumList(List values, {int cap = 12}) => - values.length <= cap - ? values.join(', ') - : '${values.take(cap).join(', ')} (+${values.length - cap} more)'; - - /// Shadows read only on light surfaces, so a dark canvas gets twice the alpha. - double _shadowAlpha(material.Color canvas) { - final base = dragging ? 0.28 : 0.12; - return canvas.computeLuminance() < 0.5 ? base * 2 : base; - } - - @override - material.Widget build(material.BuildContext context) { - erdCardBuilds++; - final wb = context.workbench; - final palette = context.semanticPalette; - final radius = material.BorderRadius.circular(8); - return material.MouseRegion( - cursor: dragging - ? material.SystemMouseCursors.grabbing - : material.SystemMouseCursors.grab, - onEnter: (_) => onHover(true), - onExit: (_) => onHover(false), - // The card's pan recognizer joins the arena before the canvas's one - // and wins it, so dragging a card never pans the canvas. `down` - // reports the movement from the press, slop included. - child: material.GestureDetector( - key: material.ValueKey('erd_table_${table.name}'), - dragStartBehavior: DragStartBehavior.down, - onTap: onSelect, - onDoubleTap: onOpen, - onPanStart: (_) => onDragStart(), - onPanUpdate: (d) => onDragMove(d.delta), - onPanEnd: (_) => onDragEnd(), - onPanCancel: onDragEnd, - child: material.Container( - width: width, - height: ErdLayout.cardHeight(table), - decoration: material.BoxDecoration( - color: wb.surface, - borderRadius: radius, - border: material.Border.all( - color: highlighted || marked ? wb.accent : wb.borderSubtle, - width: highlighted ? 1.5 : 1, - ), - boxShadow: [ - // A marked card gets a ring outside it, so its content area - // is the one the card was measured for. - if (marked) - material.BoxShadow(color: wb.accent, spreadRadius: 2.5), - material.BoxShadow( - color: wb.shadow.withValues(alpha: _shadowAlpha(wb.canvas)), - blurRadius: dragging ? 18 : 8, - offset: material.Offset(0, dragging ? 6 : 2), - ), - ], - ), - child: material.ClipRRect( - borderRadius: radius, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Container( - height: ErdLayout.headerHeight, - color: headerColor.withValues( - alpha: highlighted ? 0.18 : 0.10), - padding: const material.EdgeInsets.symmetric(horizontal: 10), - child: material.Row( - children: [ - material.Icon(material.Icons.table_chart_outlined, - size: 14, color: headerColor), - const material.SizedBox(width: 6), - material.Expanded( - child: Text(table.name, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: const material.TextStyle( - fontWeight: material.FontWeight.w600, - fontSize: 13)), - ), - // The table's comment in the database (#1279). - if (table.comment case final note?) ...[ - material.Tooltip( - message: note, - child: material.Icon(material.Icons.notes_rounded, - key: material.ValueKey( - 'erd_note_${table.name}'), - size: 13, - color: wb.mutedForeground), - ), - const material.SizedBox(width: 6), - ], - // A many-to-many link table (#1281). - if (junctionOf case final linked?) ...[ - material.Tooltip( - message: 'Link table: many-to-many between $linked', - child: Text('M:N', - key: material.ValueKey( - 'erd_junction_${table.name}'), - style: material.TextStyle( - fontSize: 9, - fontWeight: material.FontWeight.w700, - color: headerColor)), - ), - const material.SizedBox(width: 6), - ], - Text('${table.columns.length}', - style: material.TextStyle( - fontSize: 10, color: wb.mutedForeground)), - ], - ), - ), - for (final c in table.columns) - material.Container( - key: c.name == focusColumn - ? material.ValueKey( - 'erd_focus_column_${table.name}_${c.name}') - : null, - height: ErdLayout.rowHeight, - // The column of a picked relation (#1281). - color: c.name == focusColumn - ? wb.accent.withValues(alpha: 0.14) - : null, - child: material.Tooltip( - message: _columnTip(c), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 10), - child: material.Row( - children: [ - // A column can be both PK and FK (junction - // tables), so both markers get a slot. - material.SizedBox( - width: 26, - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - if (c.isPrimaryKey) - material.Icon(material.Icons.key_rounded, - size: 11, color: palette.type1), - if (c.isPrimaryKey && c.isForeignKey) - const material.SizedBox(width: 4), - if (c.isForeignKey) - material.Icon(material.Icons.link_rounded, - size: 11, color: palette.type2), - ], - ), - ), - // The card is measured to fit both; at its - // maximum width the name gives way. - material.Expanded( - child: Text(c.name, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - fontWeight: c.isPrimaryKey - ? material.FontWeight.w600 - : material.FontWeight.normal, - )), - ), - const material.SizedBox(width: 8), - material.ConstrainedBox( - // Only a card at its maximum width has to - // share: then the type keeps half the row. - constraints: material.BoxConstraints( - maxWidth: width >= ErdLayout.maxCardWidth - ? (width - 56) / 2 - : double.infinity), - child: Text(c.isNullable ? '${c.type}?' : c.type, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 10, - fontFamily: QueryaTypography.mono, - fontFamilyFallback: - QueryaTypography.monoFontFamilyFallback, - color: wb.mutedForeground)), - ), - if (c.comment != null) ...[ - const material.SizedBox(width: 3), - material.Icon(material.Icons.notes_rounded, - key: material.ValueKey( - 'erd_note_${table.name}_${c.name}'), - size: 10, - color: wb.mutedForeground), - ], - // UQ / AI / DF after the type (#1277). - for (final b in c.badges) ...[ - const material.SizedBox(width: 3), - Text(b, - key: material.ValueKey( - 'erd_badge_${table.name}_${c.name}_$b'), - style: material.TextStyle( - fontSize: 9, - fontWeight: material.FontWeight.w700, - color: switch (b) { - 'EN' => palette.type5, - 'UQ' => palette.type3, - 'AI' => palette.type4, - _ => wb.mutedForeground, - })), - ], - ], - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _RelationPainter extends material.CustomPainter { - _RelationPainter({ - required this.routes, - required this.color, - required this.highlight, - required this.focus, - this.picked, - }); - - final List routes; - final material.Color color; - final material.Color highlight; - - /// Table whose relations are drawn on top in [highlight]. - final String? focus; - - /// A relation picked by a click: only it is drawn in [highlight] (#1281). - final ErdRelation? picked; - - bool get _anyFocus => picked != null || focus != null; - - bool _focused(ErdRoute r) { - final p = picked; - if (p != null) return r.relation.sameAs(p); - return focus != null && - (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( - text: material.TextSpan( - text: text, - style: material.TextStyle( - fontSize: 10, color: c, fontWeight: material.FontWeight.w600)), - textDirection: material.TextDirection.ltr, - )..layout(); - tp.paint(canvas, at - material.Offset(tp.width / 2, tp.height / 2)); - tp.dispose(); - } - - @override - void paint(material.Canvas canvas, material.Size size) { - // Others first, focused on top. - for (final pass in [false, true]) { - 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); - // 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) { - final (oa, ob) = ErdGeometry.oneBar(r.points[0], r.points[1]); - canvas.drawLine(oa, ob, paint); - } else { - for (final (a, b) - in ErdGeometry.crowFoot(r.points[0], r.points[1])) { - canvas.drawLine(a, b, paint); - } - } - // FK side: a circle when the column may be NULL ("zero or many"), a - // bar otherwise ("one or many"). - if (r.relation.optional) { - final (c, radius) = - ErdGeometry.optionalCircle(r.points[0], r.points[1]); - canvas.drawCircle(c, radius, paint); - } else { - final (fa, fb) = - ErdGeometry.oneBar(r.points[0], r.points[1], distance: 17); - canvas.drawLine(fa, fb, paint); - } - final (barA, barB) = - ErdGeometry.oneBar(r.points.last, r.points[r.points.length - 2]); - canvas.drawLine(barA, barB, paint); - // Cardinality labels, as dbdiagram writes them. - _label(canvas, r.relation.oneToOne ? '1' : '*', - ErdGeometry.endLabel(r.points[0], r.points[1]), paint.color); - _label( - canvas, - '1', - ErdGeometry.endLabel( - r.points.last, r.points[r.points.length - 2]), - paint.color); - } - } - } - - /// 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 || - old.color != color || - old.highlight != highlight || - old.focus != focus || - old.picked != picked; -} - -/// Polyline with corners rounded by up to 8 px. -material.Path roundedPath(List pts, {double radius = 8}) { - final path = material.Path()..moveTo(pts.first.dx, pts.first.dy); - for (final (p1, corner, p2) in ErdGeometry.corners(pts, radius: radius)) { - path - ..lineTo(p1.dx, p1.dy) - ..quadraticBezierTo(corner.dx, corner.dy, p2.dx, p2.dy); - } - path.lineTo(pts.last.dx, pts.last.dy); - return path; -} diff --git a/lib/features/erd/widgets/erd_edge_tip_label.dart b/lib/features/erd/widgets/erd_edge_tip_label.dart new file mode 100644 index 00000000..d79dff2b --- /dev/null +++ b/lib/features/erd/widgets/erd_edge_tip_label.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// "orders.customer_id → customers.id" next to the pointer. +class ErdEdgeTipLabel extends material.StatelessWidget { + const ErdEdgeTipLabel({super.key, required this.relation}); + + final ErdRelation relation; + + @override + material.Widget build(material.BuildContext context) { + final r = relation; + final cs = Theme.of(context).colorScheme; + return material.DecoratedBox( + decoration: material.BoxDecoration( + color: cs.popover, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: cs.border), + ), + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: material.Text( + '${r.fromTable}.${r.fromColumn} → ${r.toTable}.${r.toColumn}', + style: + material.TextStyle(fontSize: 11, color: cs.popoverForeground), + ), + ), + ); + } +} diff --git a/lib/features/erd/widgets/erd_export_menu.dart b/lib/features/erd/widgets/erd_export_menu.dart new file mode 100644 index 00000000..7ca93168 --- /dev/null +++ b/lib/features/erd/widgets/erd_export_menu.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Entries of the diagram's Export menu. +enum ErdExportAction { + mermaid, + svg, + png, + pdfA4, + pdfA3, + dbml, + copyMermaid, + copyDbml, + toggleTheme, +} + +/// The toolbar's Export menu. +class ErdExportMenu extends material.StatelessWidget { + const ErdExportMenu({super.key, required this.onSelected}); + + final void Function(ErdExportAction action) onSelected; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + return QueryaActionMenu( + items: const [ + QueryaActionMenuItem( + value: ErdExportAction.toggleTheme, + label: 'Toggle export theme (light / current)', + icon: material.Icons.palette_outlined, + ), + QueryaActionMenuItem( + value: ErdExportAction.mermaid, + label: 'Mermaid (.mmd)', + icon: material.Icons.account_tree_outlined, + ), + QueryaActionMenuItem( + value: ErdExportAction.svg, + label: 'SVG', + icon: material.Icons.polyline_outlined, + ), + QueryaActionMenuItem( + value: ErdExportAction.png, + label: 'PNG', + icon: material.Icons.image_outlined, + ), + QueryaActionMenuItem( + value: ErdExportAction.pdfA4, + label: 'PDF (A4)', + icon: material.Icons.picture_as_pdf_outlined, + ), + QueryaActionMenuItem( + value: ErdExportAction.pdfA3, + label: 'PDF (A3)', + icon: material.Icons.picture_as_pdf_outlined, + ), + QueryaActionMenuItem( + value: ErdExportAction.dbml, + label: 'DBML (.dbml)', + icon: material.Icons.code_rounded, + ), + QueryaActionMenuItem( + value: ErdExportAction.copyDbml, + label: 'Copy DBML', + icon: material.Icons.content_copy_rounded, + ), + QueryaActionMenuItem( + value: ErdExportAction.copyMermaid, + label: 'Copy Mermaid', + icon: material.Icons.content_copy_rounded, + ), + ], + onSelected: onSelected, + child: material.Padding( + key: const material.ValueKey('erd_export'), + padding: + const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.file_download_outlined, + size: 16, color: wb.mutedForeground), + const material.SizedBox(width: 6), + const Text('Export'), + const material.SizedBox(width: 4), + material.Icon(material.Icons.expand_more_rounded, + size: 16, color: wb.mutedForeground), + ], + ), + ), + ); + } +} diff --git a/lib/features/erd/widgets/erd_group_frame.dart b/lib/features/erd/widgets/erd_group_frame.dart new file mode 100644 index 00000000..51903627 --- /dev/null +++ b/lib/features/erd/widgets/erd_group_frame.dart @@ -0,0 +1,132 @@ +import 'dart:math' show max; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// A group's frame on the canvas: a tinted box that takes no pointer, and its +/// title, which drags the whole group and has the group's menu. +abstract final class ErdGroupFrame { + /// The widgets of [group]'s frame, positioned in canvas space; empty when + /// none of its tables is in [layout]. + static List build({ + required ErdGroup group, + required ErdLayout layout, + required material.Color Function(String slot) slotColor, + required bool dragging, + required material.VoidCallback onFocusCanvas, + required material.VoidCallback onDragStart, + required void Function(material.Offset delta) onDragMove, + required material.VoidCallback onDragEnd, + required material.VoidCallback onEdit, + required void Function(String slot) onColor, + required material.VoidCallback onUngroup, + }) { + final g = group; + final frame = layout.frameOf(g.tables); + if (frame == null) return const []; + final color = slotColor(g.color); + final title = material.MouseRegion( + cursor: dragging + ? material.SystemMouseCursors.grabbing + : material.SystemMouseCursors.grab, + child: material.GestureDetector( + key: material.ValueKey('erd_group_${g.id}'), + dragStartBehavior: DragStartBehavior.down, + onTapDown: (_) => onFocusCanvas(), + onPanStart: (_) { + onFocusCanvas(); + onDragStart(); + }, + onPanUpdate: (d) => onDragMove(d.delta), + onPanEnd: (_) => onDragEnd(), + onPanCancel: onDragEnd, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + g.isSchema + ? material.Icons.schema_outlined + : material.Icons.folder_open_rounded, + size: 13, + color: color), + const material.SizedBox(width: 5), + material.Flexible( + child: Text(g.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: color)), + ), + if (g.note case final note?) ...[ + const material.SizedBox(width: 5), + material.Tooltip( + message: note, + child: material.Icon(material.Icons.notes_rounded, + key: material.ValueKey('erd_group_note_${g.id}'), + size: 12, + color: color), + ), + ], + ], + ), + ), + ); + return [ + material.Positioned.fromRect( + rect: frame, + child: material.IgnorePointer( + child: material.DecoratedBox( + decoration: material.BoxDecoration( + color: color.withValues(alpha: 0.05), + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: color.withValues(alpha: 0.55)), + ), + ), + ), + ), + material.Positioned( + left: frame.left + 10, + top: frame.top + 3, + width: max(0.0, frame.width - 20), + height: ErdLayout.frameTitleHeight - 6, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: g.isSchema + ? title + : ContextMenu( + items: [ + MenuButton( + key: material.ValueKey('erd_group_edit_${g.id}'), + onPressed: (_) => onEdit(), + child: const Text('Rename / note…'), + ), + MenuButton( + subMenu: [ + for (var i = 0; i < erdHeaderSlots.length; i++) + MenuButton( + leading: material.Icon(material.Icons.circle, + size: 12, color: slotColor(erdHeaderSlots[i])), + onPressed: (_) => onColor(erdHeaderSlots[i]), + child: Text('Colour ${i + 1}'), + ), + ], + child: const Text('Colour'), + ), + MenuButton( + key: material.ValueKey('erd_group_ungroup_${g.id}'), + onPressed: (_) => onUngroup(), + child: const Text('Ungroup'), + ), + ], + child: title, + ), + ), + ), + ]; + } +} diff --git a/lib/features/erd/widgets/erd_relation_painter.dart b/lib/features/erd/widgets/erd_relation_painter.dart new file mode 100644 index 00000000..264189ed --- /dev/null +++ b/lib/features/erd/widgets/erd_relation_painter.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_geometry.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; + +/// Draws the relations of the diagram: rounded polylines with crow's feet and +/// cardinality markers. +class ErdRelationPainter extends material.CustomPainter { + ErdRelationPainter({ + required this.routes, + required this.color, + required this.highlight, + required this.focus, + this.picked, + }); + + final List routes; + final material.Color color; + final material.Color highlight; + + /// Table whose relations are drawn on top in [highlight]. + final String? focus; + + /// A relation picked by a click: only it is drawn in [highlight] (#1281). + final ErdRelation? picked; + + bool get _anyFocus => picked != null || focus != null; + + bool _focused(ErdRoute r) { + final p = picked; + if (p != null) return r.relation.sameAs(p); + return focus != null && + (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( + text: material.TextSpan( + text: text, + style: material.TextStyle( + fontSize: 10, color: c, fontWeight: material.FontWeight.w600)), + textDirection: material.TextDirection.ltr, + )..layout(); + tp.paint(canvas, at - material.Offset(tp.width / 2, tp.height / 2)); + tp.dispose(); + } + + @override + void paint(material.Canvas canvas, material.Size size) { + // Others first, focused on top. + for (final pass in [false, true]) { + 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); + // 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) { + final (oa, ob) = ErdGeometry.oneBar(r.points[0], r.points[1]); + canvas.drawLine(oa, ob, paint); + } else { + for (final (a, b) + in ErdGeometry.crowFoot(r.points[0], r.points[1])) { + canvas.drawLine(a, b, paint); + } + } + // FK side: a circle when the column may be NULL ("zero or many"), a + // bar otherwise ("one or many"). + if (r.relation.optional) { + final (c, radius) = + ErdGeometry.optionalCircle(r.points[0], r.points[1]); + canvas.drawCircle(c, radius, paint); + } else { + final (fa, fb) = + ErdGeometry.oneBar(r.points[0], r.points[1], distance: 17); + canvas.drawLine(fa, fb, paint); + } + final (barA, barB) = + ErdGeometry.oneBar(r.points.last, r.points[r.points.length - 2]); + canvas.drawLine(barA, barB, paint); + // Cardinality labels, as dbdiagram writes them. + _label(canvas, r.relation.oneToOne ? '1' : '*', + ErdGeometry.endLabel(r.points[0], r.points[1]), paint.color); + _label( + canvas, + '1', + ErdGeometry.endLabel( + r.points.last, r.points[r.points.length - 2]), + paint.color); + } + } + } + + /// 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(ErdRelationPainter old) => + old.routes != routes || + old.color != color || + old.highlight != highlight || + old.focus != focus || + old.picked != picked; +} + +/// Polyline with corners rounded by up to 8 px. +material.Path roundedPath(List pts, {double radius = 8}) { + final path = material.Path()..moveTo(pts.first.dx, pts.first.dy); + for (final (p1, corner, p2) in ErdGeometry.corners(pts, radius: radius)) { + path + ..lineTo(p1.dx, p1.dy) + ..quadraticBezierTo(corner.dx, corner.dy, p2.dx, p2.dy); + } + path.lineTo(pts.last.dx, pts.last.dy); + return path; +} diff --git a/lib/features/erd/widgets/erd_search_panel.dart b/lib/features/erd/widgets/erd_search_panel.dart new file mode 100644 index 00000000..aa93b5a4 --- /dev/null +++ b/lib/features/erd/widgets/erd_search_panel.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_search_field.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Find-a-table field and the tables it matches. Enter picks the first. +class ErdSearchPanel extends material.StatelessWidget { + const ErdSearchPanel({ + super.key, + required this.controller, + required this.focusNode, + required this.query, + required this.matches, + required this.onChanged, + required this.onPick, + }); + + final material.TextEditingController controller; + final material.FocusNode focusNode; + + /// The trimmed text typed so far; the result list shows once it is not empty. + final String query; + + /// The tables matching [query], best first. + final List matches; + final void Function(String text) onChanged; + + /// Called with the name of the table to pick. + final void Function(String name) onPick; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + QueryaSearchField( + controller: controller, + focusNode: focusNode, + placeholder: 'Find table', + debounceDuration: Duration.zero, + onChanged: onChanged, + onSubmitted: (_) { + if (matches.isNotEmpty) onPick(matches.first.name); + }, + ), + if (query.isNotEmpty) + material.Container( + margin: const material.EdgeInsets.only(top: 4), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: wb.borderSubtle), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (matches.isEmpty) + material.Padding( + padding: const material.EdgeInsets.all(8), + child: const Text('No tables match').muted().small(), + ), + for (final t in matches) + material.GestureDetector( + key: material.ValueKey('erd_search_result_${t.name}'), + behavior: material.HitTestBehavior.opaque, + onTap: () => onPick(t.name), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: material.Text(t.name, + style: const material.TextStyle(fontSize: 12)), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/erd/widgets/erd_status_body.dart b/lib/features/erd/widgets/erd_status_body.dart new file mode 100644 index 00000000..ded44f00 --- /dev/null +++ b/lib/features/erd/widgets/erd_status_body.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_empty_state.dart'; + +/// What the diagram area shows instead of the canvas. +abstract final class ErdStatusBody { + static material.Widget error( + material.BuildContext context, { + required String? message, + required material.VoidCallback onRetry, + }) { + final wb = context.workbench; + return material.Center( + child: QueryaEmptyState( + icon: material.Icon(material.Icons.error_outline_rounded, + color: wb.destructive), + title: 'Could not load the schema', + description: message, + actionLabel: 'Retry', + onAction: onRetry, + ), + ); + } + + /// Every table was hidden from the diagram: they exist, so say so and offer + /// them back instead of "No tables found". + static material.Widget allHidden( + material.BuildContext context, { + required int hiddenCount, + required material.VoidCallback onShowAll, + }) { + final wb = context.workbench; + return material.Center( + child: QueryaEmptyState( + icon: material.Icon(material.Icons.visibility_off_outlined, + color: wb.mutedForeground), + title: 'All tables are hidden', + description: '$hiddenCount ' + '${hiddenCount == 1 ? 'table is' : 'tables are'} hidden ' + 'from the diagram.', + actionLabel: 'Show all tables', + onAction: onShowAll, + ), + ); + } + + static material.Widget noTables( + material.BuildContext context, { + required String databaseName, + }) { + final wb = context.workbench; + final db = databaseName; + return material.Center( + child: QueryaEmptyState( + icon: material.Icon(material.Icons.table_chart_outlined, + color: wb.mutedForeground), + title: 'No tables found', + description: db.isEmpty + ? 'This schema has no tables, or this role cannot see them.' + : 'The current schema of $db has no tables, or this role ' + 'cannot see them.', + ), + ); + } + + static material.Widget noRelations( + material.BuildContext context, { + required String? focusTable, + required material.VoidCallback? onOpenFullDiagram, + }) { + final wb = context.workbench; + return material.Center( + child: QueryaEmptyState( + icon: material.Icon(material.Icons.link_off_rounded, + color: wb.mutedForeground), + title: 'No foreign keys to or from $focusTable', + description: 'This table has no relations within the schema.', + actionLabel: 'Open full diagram', + onAction: onOpenFullDiagram, + ), + ); + } +} diff --git a/lib/features/erd/widgets/erd_table_card.dart b/lib/features/erd/widgets/erd_table_card.dart new file mode 100644 index 00000000..78ec05e2 --- /dev/null +++ b/lib/features/erd/widgets/erd_table_card.dart @@ -0,0 +1,293 @@ +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// A card's share of the focus: highlighted, faded, and the column of a +/// picked relation on it, if any. +typedef ErdCardFocus = ({bool highlighted, bool faded, String? column}); + +/// Card builds so far. A test seam: hovering one card must not rebuild the +/// others. +@visibleForTesting +int erdCardBuilds = 0; + +/// One table of the diagram: header, columns with their markers and badges. +class ErdTableCard extends material.StatelessWidget { + const ErdTableCard({ + super.key, + required this.table, + required this.width, + required this.headerColor, + required this.highlighted, + this.marked = false, + this.focusColumn, + this.junctionOf, + required this.dragging, + required this.onHover, + required this.onDragStart, + required this.onDragMove, + required this.onDragEnd, + this.onOpen, + this.onSelect, + }); + + final ErdTable table; + + /// From the layout: sized to the card's content (#1276). + final double width; + + /// Header tint and icon: by schema, picked per table, or the accent. + final material.Color headerColor; + final bool highlighted; + + /// Marked for a group with Shift / Ctrl + click (#1282). + final bool marked; + + /// The column of a picked relation on this card, shaded (#1281). + final String? focusColumn; + + /// For a many-to-many link table: the tables it links. + final String? junctionOf; + final bool dragging; + final material.VoidCallback? onOpen; + final material.VoidCallback? onSelect; + final void Function(bool inside) onHover; + final material.VoidCallback onDragStart; + final void Function(material.Offset delta) onDragMove; + final material.VoidCallback onDragEnd; + + /// Everything a row knows, one fact a line. + static String _columnTip(ErdColumn c) => [ + '${c.name} ${c.type}', + if (c.isPrimaryKey) 'Primary key', + if (c.isForeignKey) 'Foreign key', + c.isNullable ? 'Nullable' : 'Not null', + if (c.isUnique && !c.isPrimaryKey) 'Unique', + if (c.isIdentity) 'Generated (identity / auto increment)', + if (c.defaultValue case final d?) 'Default: $d', + if (c.domainBase case final b?) 'Domain over $b', + if (c.enumValues.isNotEmpty) 'Values: ${_enumList(c.enumValues)}', + if (c.comment case final n?) 'Note: $n', + ].join('\n'); + + /// At most [cap] labels, then how many more. + static String _enumList(List values, {int cap = 12}) => + values.length <= cap + ? values.join(', ') + : '${values.take(cap).join(', ')} (+${values.length - cap} more)'; + + /// Shadows read only on light surfaces, so a dark canvas gets twice the alpha. + double _shadowAlpha(material.Color canvas) { + final base = dragging ? 0.28 : 0.12; + return canvas.computeLuminance() < 0.5 ? base * 2 : base; + } + + @override + material.Widget build(material.BuildContext context) { + erdCardBuilds++; + final wb = context.workbench; + final palette = context.semanticPalette; + final radius = material.BorderRadius.circular(8); + return material.MouseRegion( + cursor: dragging + ? material.SystemMouseCursors.grabbing + : material.SystemMouseCursors.grab, + onEnter: (_) => onHover(true), + onExit: (_) => onHover(false), + // The card's pan recognizer joins the arena before the canvas's one + // and wins it, so dragging a card never pans the canvas. `down` + // reports the movement from the press, slop included. + child: material.GestureDetector( + key: material.ValueKey('erd_table_${table.name}'), + dragStartBehavior: DragStartBehavior.down, + onTap: onSelect, + onDoubleTap: onOpen, + onPanStart: (_) => onDragStart(), + onPanUpdate: (d) => onDragMove(d.delta), + onPanEnd: (_) => onDragEnd(), + onPanCancel: onDragEnd, + child: material.Container( + width: width, + height: ErdLayout.cardHeight(table), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: radius, + border: material.Border.all( + color: highlighted || marked ? wb.accent : wb.borderSubtle, + width: highlighted ? 1.5 : 1, + ), + boxShadow: [ + // A marked card gets a ring outside it, so its content area + // is the one the card was measured for. + if (marked) + material.BoxShadow(color: wb.accent, spreadRadius: 2.5), + material.BoxShadow( + color: wb.shadow.withValues(alpha: _shadowAlpha(wb.canvas)), + blurRadius: dragging ? 18 : 8, + offset: material.Offset(0, dragging ? 6 : 2), + ), + ], + ), + child: material.ClipRRect( + borderRadius: radius, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + height: ErdLayout.headerHeight, + color: headerColor.withValues( + alpha: highlighted ? 0.18 : 0.10), + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.Icon(material.Icons.table_chart_outlined, + size: 14, color: headerColor), + const material.SizedBox(width: 6), + material.Expanded( + child: Text(table.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: const material.TextStyle( + fontWeight: material.FontWeight.w600, + fontSize: 13)), + ), + // The table's comment in the database (#1279). + if (table.comment case final note?) ...[ + material.Tooltip( + message: note, + child: material.Icon(material.Icons.notes_rounded, + key: material.ValueKey( + 'erd_note_${table.name}'), + size: 13, + color: wb.mutedForeground), + ), + const material.SizedBox(width: 6), + ], + // A many-to-many link table (#1281). + if (junctionOf case final linked?) ...[ + material.Tooltip( + message: 'Link table: many-to-many between $linked', + child: Text('M:N', + key: material.ValueKey( + 'erd_junction_${table.name}'), + style: material.TextStyle( + fontSize: 9, + fontWeight: material.FontWeight.w700, + color: headerColor)), + ), + const material.SizedBox(width: 6), + ], + Text('${table.columns.length}', + style: material.TextStyle( + fontSize: 10, color: wb.mutedForeground)), + ], + ), + ), + for (final c in table.columns) + material.Container( + key: c.name == focusColumn + ? material.ValueKey( + 'erd_focus_column_${table.name}_${c.name}') + : null, + height: ErdLayout.rowHeight, + // The column of a picked relation (#1281). + color: c.name == focusColumn + ? wb.accent.withValues(alpha: 0.14) + : null, + child: material.Tooltip( + message: _columnTip(c), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10), + child: material.Row( + children: [ + // A column can be both PK and FK (junction + // tables), so both markers get a slot. + material.SizedBox( + width: 26, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (c.isPrimaryKey) + material.Icon(material.Icons.key_rounded, + size: 11, color: palette.type1), + if (c.isPrimaryKey && c.isForeignKey) + const material.SizedBox(width: 4), + if (c.isForeignKey) + material.Icon(material.Icons.link_rounded, + size: 11, color: palette.type2), + ], + ), + ), + // The card is measured to fit both; at its + // maximum width the name gives way. + material.Expanded( + child: Text(c.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontWeight: c.isPrimaryKey + ? material.FontWeight.w600 + : material.FontWeight.normal, + )), + ), + const material.SizedBox(width: 8), + material.ConstrainedBox( + // Only a card at its maximum width has to + // share: then the type keeps half the row. + constraints: material.BoxConstraints( + maxWidth: width >= ErdLayout.maxCardWidth + ? (width - 56) / 2 + : double.infinity), + child: Text(c.isNullable ? '${c.type}?' : c.type, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 10, + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, + color: wb.mutedForeground)), + ), + if (c.comment != null) ...[ + const material.SizedBox(width: 3), + material.Icon(material.Icons.notes_rounded, + key: material.ValueKey( + 'erd_note_${table.name}_${c.name}'), + size: 10, + color: wb.mutedForeground), + ], + // UQ / AI / DF after the type (#1277). + for (final b in c.badges) ...[ + const material.SizedBox(width: 3), + Text(b, + key: material.ValueKey( + 'erd_badge_${table.name}_${c.name}_$b'), + style: material.TextStyle( + fontSize: 9, + fontWeight: material.FontWeight.w700, + color: switch (b) { + 'EN' => palette.type5, + 'UQ' => palette.type3, + 'AI' => palette.type4, + _ => wb.mutedForeground, + })), + ], + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/erd/widgets/erd_table_menu.dart b/lib/features/erd/widgets/erd_table_menu.dart new file mode 100644 index 00000000..80944317 --- /dev/null +++ b/lib/features/erd/widgets/erd_table_menu.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The entries of a table card's context menu. +abstract final class ErdTableMenu { + static List items({ + required String table, + required void Function(String table)? onOpenData, + required void Function(String table)? onOpenInSql, + required void Function(String table)? onShowRelations, + required bool collapsed, + required material.VoidCallback onToggleCollapsed, + required material.VoidCallback onHide, + required List groupItems, + required material.Color Function(String slot) slotColor, + required void Function(String? slot) onHeaderColor, + }) { + return [ + if (onOpenData case final open?) + MenuButton( + key: material.ValueKey('erd_menu_open_$table'), + onPressed: (_) => open(table), + child: const Text('Open data'), + ), + if (onOpenInSql case final inSql?) + MenuButton( + key: material.ValueKey('erd_menu_sql_$table'), + onPressed: (_) => inSql(table), + child: const Text('Open in SQL'), + ), + if (onShowRelations case final relations?) + MenuButton( + key: material.ValueKey('erd_menu_relations_$table'), + onPressed: (_) => relations(table), + child: const Text('Show relations'), + ), + MenuButton( + onPressed: (_) { + Clipboard.setData(ClipboardData(text: table)); + }, + child: const Text('Copy name'), + ), + MenuButton( + onPressed: (_) => onToggleCollapsed(), + child: Text(collapsed ? 'Expand' : 'Collapse'), + ), + MenuButton( + onPressed: (_) => onHide(), + child: const Text('Hide from diagram'), + ), + ...groupItems, + MenuButton( + key: material.ValueKey('erd_menu_colour_$table'), + subMenu: [ + MenuButton( + onPressed: (_) => onHeaderColor(null), + child: const Text('Default'), + ), + for (var i = 0; i < erdHeaderSlots.length; i++) + MenuButton( + leading: material.Icon( + material.Icons.circle, + size: 12, + color: slotColor(erdHeaderSlots[i]), + ), + onPressed: (_) => onHeaderColor(erdHeaderSlots[i]), + child: Text('Colour ${i + 1}'), + ), + ], + child: const Text('Header colour'), + ), + ]; + } +} diff --git a/lib/features/erd/widgets/erd_views_menu.dart b/lib/features/erd/widgets/erd_views_menu.dart new file mode 100644 index 00000000..7764fad1 --- /dev/null +++ b/lib/features/erd/widgets/erd_views_menu.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// The toolbar's view switcher: All tables, the saved views, and the +/// commands to save, rename and delete one. +class ErdViewsMenu extends material.StatelessWidget { + const ErdViewsMenu({ + super.key, + required this.views, + required this.active, + required this.onShowAll, + required this.onSwitch, + required this.onNew, + required this.onRename, + required this.onDelete, + }); + + final List views; + + /// The view the diagram is in, or null for *All tables*. + final ErdSavedView? active; + final material.VoidCallback onShowAll; + final void Function(String id) onSwitch; + final material.VoidCallback onNew; + final material.VoidCallback onRename; + final material.VoidCallback onDelete; + + /// [name] cut to 28 characters with an ellipsis, for menus and the toolbar. + static String shortName(String name) => + name.length <= 28 ? name : '${name.substring(0, 27)}…'; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + final current = active; + return QueryaActionMenu( + items: [ + // A mark on every row, so the labels line up. + QueryaActionMenuItem( + value: 'all', + label: 'All tables', + icon: current == null + ? material.Icons.radio_button_checked_rounded + : material.Icons.radio_button_unchecked_rounded, + ), + for (final v in views) + QueryaActionMenuItem( + value: 'view:${v.id}', + label: '${shortName(v.name)} (${v.tables.length})', + icon: v.id == current?.id + ? material.Icons.radio_button_checked_rounded + : material.Icons.radio_button_unchecked_rounded, + ), + const QueryaActionMenuItem( + value: 'new', + label: 'Save as new view…', + icon: material.Icons.bookmark_add_outlined, + ), + if (current != null) ...[ + const QueryaActionMenuItem( + value: 'rename', + label: 'Rename view…', + icon: material.Icons.edit_outlined, + ), + const QueryaActionMenuItem( + value: 'delete', + label: 'Delete view', + icon: material.Icons.delete_outline_rounded, + ), + ], + ], + onSelected: (v) { + switch (v) { + case 'all': + onShowAll(); + case 'new': + onNew(); + case 'rename': + onRename(); + case 'delete': + onDelete(); + default: + if (v.startsWith('view:')) onSwitch(v.substring(5)); + } + }, + child: material.Padding( + key: const material.ValueKey('erd_views'), + padding: + const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.bookmarks_outlined, + size: 16, color: wb.mutedForeground), + const material.SizedBox(width: 6), + Text(current == null ? 'All tables' : shortName(current.name)), + const material.SizedBox(width: 4), + material.Icon(material.Icons.expand_more_rounded, + size: 16, color: wb.mutedForeground), + ], + ), + ), + ); + } +} diff --git a/test/core/erd/erd_group_ops_test.dart b/test/core/erd/erd_group_ops_test.dart new file mode 100644 index 00000000..9ea89e0e --- /dev/null +++ b/test/core/erd/erd_group_ops_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/erd/erd_group_ops.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; + +ErdGroup _group(String id, List tables, {String? name}) => ErdGroup( + id: id, + name: name ?? 'Group $id', + color: erdHeaderSlots.first, + tables: tables, + ); + +void main() { + group('ErdGroupOps.groupOf', () { + test('finds the group of a table', () { + final groups = [_group('g1', ['a', 'b']), _group('g2', ['c'])]; + expect(ErdGroupOps.groupOf(groups, 'c')?.id, 'g2'); + expect(ErdGroupOps.groupOf(groups, 'z'), isNull); + }); + }); + + group('ErdGroupOps.ungroupTables', () { + test('removes tables and drops a group left empty', () { + final groups = [_group('g1', ['a', 'b']), _group('g2', ['c'])]; + final out = ErdGroupOps.ungroupTables(groups, {'a', 'c'}); + expect(out.map((g) => g.id), ['g1']); + expect(out.single.tables, ['b']); + }); + + test('keeps the group named in except', () { + final groups = [_group('g1', ['a']), _group('g2', ['a', 'b'])]; + final out = ErdGroupOps.ungroupTables(groups, {'a'}, except: 'g2'); + expect(out.map((g) => g.id), ['g2']); + expect(out.single.tables, ['a', 'b']); + }); + + test('does not change its argument', () { + final groups = [_group('g1', ['a'])]; + ErdGroupOps.ungroupTables(groups, {'a'}); + expect(groups.single.tables, ['a']); + }); + }); + + group('ErdGroupOps numbering', () { + test('nextNameNumber skips taken default names', () { + final groups = [_group('g1', ['a'], name: 'Group 2')]; + expect(ErdGroupOps.nextNameNumber(groups), 3); + }); + + test('nextIdNumber skips taken ids', () { + final groups = [_group('g1', ['a']), _group('g2', ['b'])]; + expect(ErdGroupOps.nextIdNumber(groups), 3); + expect(ErdGroupOps.nextIdNumber(const []), 1); + }); + }); + + group('ErdGroupOps.withNewGroup', () { + test('moves tables into a new group in diagram order', () { + final groups = [_group('g1', ['a', 'b'])]; + final out = ErdGroupOps.withNewGroup( + groups, + tables: {'c', 'a'}, + tableNames: ['a', 'b', 'c'], + name: 'Billing', + note: 'money', + ); + expect(out.map((g) => g.id), ['g1', 'g2']); + expect(out.first.tables, ['b']); + expect(out.last.name, 'Billing'); + expect(out.last.note, 'money'); + expect(out.last.tables, ['a', 'c']); + expect(out.last.color, erdHeaderSlots[1 % erdHeaderSlots.length]); + }); + }); + + group('ErdGroupOps.addTables', () { + test('takes tables from other groups and appends new ones', () { + final groups = [_group('g1', ['a', 'b']), _group('g2', ['c'])]; + final out = ErdGroupOps.addTables(groups, 'g2', {'a', 'c', 'd'}); + expect(out.firstWhere((g) => g.id == 'g1').tables, ['b']); + expect(out.firstWhere((g) => g.id == 'g2').tables, ['c', 'a', 'd']); + }); + + test('only ungroups when the group is unknown', () { + final groups = [_group('g1', ['a', 'b'])]; + final out = ErdGroupOps.addTables(groups, 'missing', {'a'}); + expect(out.single.tables, ['b']); + }); + }); +} diff --git a/test/core/erd/erd_groups_test.dart b/test/core/erd/erd_groups_test.dart index 345bd414..6bf195ce 100644 --- a/test/core/erd/erd_groups_test.dart +++ b/test/core/erd/erd_groups_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; /// #1282: table groups. ErdSchema _schema() => ErdSchema.fromCatalog(columnRows: const [ diff --git a/test/core/erd/erd_layout_engine_test.dart b/test/core/erd/erd_layout_engine_test.dart new file mode 100644 index 00000000..303b7c85 --- /dev/null +++ b/test/core/erd/erd_layout_engine_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout_engine.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; + +ErdColumn _col( + String name, { + bool pk = false, + bool fk = false, +}) => + ErdColumn(name: name, type: 'int', isPrimaryKey: pk, isForeignKey: fk); + +ErdRelation _rel(String from, String to) => ErdRelation( + fromTable: from, + fromColumn: '${to}_id', + toTable: to, + toColumn: 'id', + ); + +void main() { + final users = ErdTable( + name: 'users', + columns: [_col('id', pk: true), _col('name')], + ); + final orders = ErdTable( + name: 'orders', + columns: [_col('id', pk: true), _col('users_id', fk: true)], + ); + final roles = ErdTable( + name: 'roles', + columns: [_col('id', pk: true), _col('label')], + ); + final userRoles = ErdTable( + name: 'user_roles', + columns: [ + _col('users_id', pk: true, fk: true), + _col('roles_id', pk: true, fk: true), + ], + ); + final schema = ErdSchema( + tables: [users, orders, roles, userRoles], + relations: [ + _rel('orders', 'users'), + _rel('user_roles', 'users'), + _rel('user_roles', 'roles'), + ], + ); + + group('ErdLayoutEngine.focusIn', () { + test('returns null without a focus table', () { + expect(ErdLayoutEngine.focusIn(schema, null), isNull); + }); + + test('returns the table when the schema names it', () { + expect(ErdLayoutEngine.focusIn(schema, 'orders'), 'orders'); + }); + + test('falls back to the bare name of a schema-qualified table', () { + expect(ErdLayoutEngine.focusIn(schema, 'public.orders'), 'orders'); + }); + + test('returns null for an unknown table', () { + expect(ErdLayoutEngine.focusIn(schema, 'public.missing'), isNull); + }); + }); + + group('ErdLayoutEngine.visibleOf', () { + test('hides tables and drops their relations', () { + final visible = ErdLayoutEngine.visibleOf( + schema, + hidden: {'users'}, + collapsed: const {}, + detail: ErdDetail.all, + ); + expect(visible.tables.map((t) => t.name), + ['orders', 'roles', 'user_roles']); + expect(visible.relations.map((r) => '${r.fromTable}>${r.toTable}'), + ['user_roles>roles']); + }); + + test('collapsed tables lose their columns', () { + final visible = ErdLayoutEngine.visibleOf( + schema, + hidden: const {}, + collapsed: {'orders'}, + detail: ErdDetail.all, + ); + final ordersTable = visible.tables.firstWhere((t) => t.name == 'orders'); + expect(ordersTable.columns, isEmpty); + }); + + test('keys detail keeps only primary and foreign key columns', () { + final visible = ErdLayoutEngine.visibleOf( + schema, + hidden: const {}, + collapsed: const {}, + detail: ErdDetail.keys, + ); + final usersTable = visible.tables.firstWhere((t) => t.name == 'users'); + expect(usersTable.columns.map((c) => c.name), ['id']); + }); + + test('names detail drops all columns', () { + final visible = ErdLayoutEngine.visibleOf( + schema, + hidden: const {}, + collapsed: const {}, + detail: ErdDetail.names, + ); + expect(visible.tables.every((t) => t.columns.isEmpty), isTrue); + }); + }); + + group('ErdLayoutEngine graph helpers', () { + test('neighbourMap relates tables both ways', () { + final map = ErdLayoutEngine.neighbourMap(schema); + expect(map['users'], {'orders', 'user_roles'}); + expect(map['orders'], {'users'}); + expect(map['roles'], {'user_roles'}); + }); + + test('junctionMap names the tables a link table links', () { + final map = ErdLayoutEngine.junctionMap(schema); + expect(map.keys, ['user_roles']); + expect(map['user_roles'], 'users and roles'); + }); + + test('isFocusedIn is true for the focus and its neighbours', () { + final map = ErdLayoutEngine.neighbourMap(schema); + expect(ErdLayoutEngine.isFocusedIn(map, null, 'users'), isFalse); + expect(ErdLayoutEngine.isFocusedIn(map, 'users', 'users'), isTrue); + expect(ErdLayoutEngine.isFocusedIn(map, 'users', 'orders'), isTrue); + expect(ErdLayoutEngine.isFocusedIn(map, 'users', 'roles'), isFalse); + }); + }); + + group('ErdLayoutEngine.matches', () { + test('matches names case-insensitively', () { + expect( + ErdLayoutEngine.matches(schema, 'ROLE').map((t) => t.name), + ['roles', 'user_roles'], + ); + }); + + test('caps the result at the limit', () { + expect(ErdLayoutEngine.matches(schema, '', limit: 2), hasLength(2)); + }); + }); + + group('ErdLayoutEngine.withSaved', () { + test('returns the computed layout when nothing is saved', () { + final computed = ErdLayout.compute(schema); + expect( + identical(ErdLayoutEngine.withSaved(computed, null), computed), + isTrue, + ); + expect( + identical( + ErdLayoutEngine.withSaved(computed, const ErdSavedLayout()), + computed, + ), + isTrue, + ); + }); + + test('puts saved cards back and keeps the others out of their way', () { + final computed = ErdLayout.compute(schema); + const savedAt = Offset(500, 40); + final merged = ErdLayoutEngine.withSaved( + computed, + const ErdSavedLayout(positions: {'users': savedAt}), + ); + expect(merged.positions['users'], savedAt); + final savedRight = savedAt.dx + computed.widthFor('users'); + for (final name in ['orders', 'roles', 'user_roles']) { + expect(merged.positions[name]!.dx, greaterThan(savedRight)); + } + }); + }); +} diff --git a/test/core/erd/erd_notes_test.dart b/test/core/erd/erd_notes_test.dart index 30c6a573..79711396 100644 --- a/test/core/erd/erd_notes_test.dart +++ b/test/core/erd/erd_notes_test.dart @@ -5,7 +5,7 @@ import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_note_text.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; /// #1283: sticky notes. void main() { diff --git a/test/features/erd/erd_card_width_colour_test.dart b/test/features/erd/erd_card_width_colour_test.dart index 27576739..e0aedf97 100644 --- a/test/features/erd/erd_card_width_colour_test.dart +++ b/test/features/erd/erd_card_width_colour_test.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; diff --git a/test/features/erd/erd_relationship_view_test.dart b/test/features/erd/erd_relationship_view_test.dart index 7030bf27..2f66a49e 100644 --- a/test/features/erd/erd_relationship_view_test.dart +++ b/test/features/erd/erd_relationship_view_test.dart @@ -6,9 +6,9 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:querya_desktop/features/erd/erd_geometry.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; -import 'package:querya_desktop/features/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_geometry.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 1d6f3c88..b5b140a7 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -8,10 +8,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; -import 'package:querya_desktop/features/erd/erd_geometry.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_geometry.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; -import 'package:querya_desktop/features/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; diff --git a/test/features/erd/erd_ui_review_fixes_test.dart b/test/features/erd/erd_ui_review_fixes_test.dart index 1a8218eb..9aa6728a 100644 --- a/test/features/erd/erd_ui_review_fixes_test.dart +++ b/test/features/erd/erd_ui_review_fixes_test.dart @@ -9,8 +9,8 @@ import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; -import 'package:querya_desktop/features/erd/erd_layout.dart'; -import 'package:querya_desktop/features/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';