From dfc7e8f8e8fe45e68d932ef3229489dded555176 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 15:50:00 +0300 Subject: [PATCH 1/4] refactor(erd): ErdViewController holds the diagram's user state; the view reads it from there (#1690) --- lib/features/erd/erd_view.dart | 397 +++++++++++----------- lib/features/erd/erd_view_controller.dart | 60 ++++ 2 files changed, 250 insertions(+), 207 deletions(-) create mode 100644 lib/features/erd/erd_view_controller.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 5feeb843..97e51361 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -18,6 +18,7 @@ import 'package:querya_desktop/features/erd/erd_exporter.dart'; import 'package:querya_desktop/core/erd/erd_geometry.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_controller.dart'; import 'package:querya_desktop/features/erd/erd_view_name_dialog.dart'; import 'package:querya_desktop/core/erd/erd_group_ops.dart'; import 'package:querya_desktop/core/erd/erd_layout.dart'; @@ -120,6 +121,7 @@ class ErdView extends material.StatefulWidget { class _ErdViewState extends material.State implements ErdExportSource { late final ErdExporter _exporter = ErdExporter(this); + final _c = ErdViewController(); final _boundaryKey = material.GlobalKey(); ErdSchema? _schema; @@ -132,8 +134,6 @@ class _ErdViewState extends material.State /// click or search: their edges are highlighted. A picked table stays /// highlighted when the mouse leaves. final _hovered = material.ValueNotifier(null); - String? _dragging; - String? _selected; bool _routeScheduled = false; /// The focus the edges draw. Every card has its own notifier in @@ -141,50 +141,33 @@ class _ErdViewState extends material.State /// hover rebuilds the cards it touches and not the whole canvas. final _focus = material.ValueNotifier(null); final _cardStates = >{}; - Map> _neighbours = const {}; /// Density: only PK and FK columns, cards collapsed to their header, and /// tables hidden from the diagram. Layout and routing follow the visible /// schema from [_visibleOf]. - ErdDetail _detail = ErdDetail.all; - final Set _collapsed = {}; - final Set _hidden = {}; /// A relation picked by a click on its edge (#1281): its two tables and /// columns are highlighted and its label stays until Esc. - ErdRelation? _pickedRelation; /// Many-to-many link tables and what they link: `users and roles` (#1281). - Map _junctions = const {}; /// Header colour picked per table (palette slot names, #1276). - final Map _headerColors = {}; /// The user's table groups, saved with the layout (#1282). - final List _groups = []; /// Tables marked with Shift / Ctrl / Cmd + click, to be grouped. - final Set _marked = {}; /// The group whose frame is being dragged by its title. - String? _draggingGroup; /// Saved views of the diagram (#1284), the one the user is in, and the /// *All tables* state while a view is active. - final List _views = []; - String? _activeView; - ErdSavedLayout _base = const ErdSavedLayout(); /// Sticky notes on the canvas, saved with the layout (#1283). - final List _notes = []; - String? _draggingNote; /// Relation under the pointer and the pointer position in canvas space. final _edgeTipNotifier = material.ValueNotifier<(ErdRelation, material.Offset)?>(null); - bool _searchOpen = false; - String _query = ''; final _searchController = material.TextEditingController(); final _searchFocus = material.FocusNode(); final _canvasFocus = material.FocusNode(); @@ -245,13 +228,13 @@ class _ErdViewState extends material.State void _onSearchFocus() { if (!mounted || _searchFocus.hasFocus || - !_searchOpen || + !_c.searchOpen || _searchController.text.isNotEmpty) { return; } setState(() { - _searchOpen = false; - _query = ''; + _c.searchOpen = false; + _c.query = ''; }); _canvasFocus.requestFocus(); } @@ -272,12 +255,12 @@ class _ErdViewState extends material.State // The state to show: the diagram's own, or the view it was left in. var saved = stored; if (stored != null) { - _base = stored; - _views + _c.base = stored; + _c.views ..clear() ..addAll(stored.views); - _activeView = stored.activeView; - final view = _viewById(_activeView); + _c.activeView = stored.activeView; + final view = _viewById(_c.activeView); if (view != null) { saved = view.asLayout( {for (final t in schema.tables) t.name}, stored.headerColors); @@ -285,11 +268,11 @@ class _ErdViewState extends material.State } setState(() { _schema = schema; - _neighbours = ErdLayoutEngine.neighbourMap(schema); - _junctions = ErdLayoutEngine.junctionMap(schema); - _pickedRelation = null; + _c.neighbours = ErdLayoutEngine.neighbourMap(schema); + _c.junctions = ErdLayoutEngine.junctionMap(schema); + _c.pickedRelation = null; if (saved != null) _applyState(saved, colours: true); - _marked.clear(); + _c.marked.clear(); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; _cardCache = {}; @@ -335,22 +318,22 @@ class _ErdViewState extends material.State /// and hidden, the detail, groups and notes. Header colours belong to the /// diagram, so a view switch leaves them ([colours] false). void _applyState(ErdSavedLayout s, {required bool colours}) { - _collapsed + _c.collapsed ..clear() ..addAll(s.collapsed); - _hidden + _c.hidden ..clear() ..addAll(s.hidden); - _detail = s.detail; + _c.detail = s.detail; if (colours) { - _headerColors + _c.headerColors ..clear() ..addAll(s.headerColors); } - _groups + _c.groups ..clear() ..addAll(s.groups); - _notes + _c.notes ..clear() ..addAll(s.notes); } @@ -370,18 +353,18 @@ class _ErdViewState extends material.State /// diagram spans several schemas. List _allGroups() { final schema = _schema; - if (schema == null) return List.of(_groups); + if (schema == null) return List.of(_c.groups); return [ - ..._groups, - ...erdSchemaGroups([for (final t in schema.tables) t.name], _groups), + ..._c.groups, + ...erdSchemaGroups([for (final t in schema.tables) t.name], _c.groups), ]; } - ErdGroup? _groupOf(String table) => ErdGroupOps.groupOf(_groups, table); + ErdGroup? _groupOf(String table) => ErdGroupOps.groupOf(_c.groups, table); void _replaceGroups(List groups) { final next = List.of(groups); - _groups + _c.groups ..clear() ..addAll(next); } @@ -389,21 +372,21 @@ class _ErdViewState extends material.State /// Asks for a name and groups [tables], taking them out of other groups. Future _newGroup(Set tables) async { if (tables.isEmpty) return; - final n = ErdGroupOps.nextNameNumber(_groups); + final n = ErdGroupOps.nextNameNumber(_c.groups); final text = await showErdGroupDialog(context, title: 'New group', action: 'Create', name: 'Group $n'); if (text == null || !mounted) return; setState(() { _replaceGroups(ErdGroupOps.withNewGroup( - _groups, + _c.groups, tables: tables, tableNames: [for (final t in _schema?.tables ?? const []) t.name], name: text.name, note: text.note, )); - _marked.clear(); + _c.marked.clear(); }); - _gatherIfCovering(_groups.last.id); + _gatherIfCovering(_c.groups.last.id); _scheduleSave(); } @@ -411,9 +394,9 @@ class _ErdViewState extends material.State /// packed into one block, the other cards stay (#1282). void _gatherIfCovering(String id) { final layout = _layout; - final i = _groups.indexWhere((g) => g.id == id); + final i = _c.groups.indexWhere((g) => g.id == id); if (layout == null || i < 0) return; - final tables = _groups[i].tables; + final tables = _c.groups[i].tables; if (!layout.frameCoversOthers(tables)) return; setState(() { _setLayout(layout.gather(tables)); @@ -421,15 +404,15 @@ class _ErdViewState extends material.State }); showAppToast( context: context, - message: 'Moved the tables of "${_groups[i].name}" together so its ' + message: 'Moved the tables of "${_c.groups[i].name}" together so its ' 'frame covers no other table.', ); } void _addToGroup(String id, Set tables) { setState(() { - _replaceGroups(ErdGroupOps.addTables(_groups, id, tables)); - if (_groups.any((g) => g.id == id)) _marked.clear(); + _replaceGroups(ErdGroupOps.addTables(_c.groups, id, tables)); + if (_c.groups.any((g) => g.id == id)) _c.marked.clear(); }); _gatherIfCovering(id); _scheduleSave(); @@ -447,19 +430,19 @@ class _ErdViewState extends material.State } void _updateGroup(String id, ErdGroup Function(ErdGroup) change) { - final i = _groups.indexWhere((g) => g.id == id); + final i = _c.groups.indexWhere((g) => g.id == id); if (i < 0) return; - setState(() => _groups[i] = change(_groups[i])); + setState(() => _c.groups[i] = change(_c.groups[i])); _scheduleSave(); } void _ungroup(String id) { - setState(() => _groups.removeWhere((g) => g.id == id)); + setState(() => _c.groups.removeWhere((g) => g.id == id)); _scheduleSave(); } void _removeFromGroup(String table) { - setState(() => _replaceGroups(ErdGroupOps.ungroupTables(_groups, {table}))); + setState(() => _replaceGroups(ErdGroupOps.ungroupTables(_c.groups, {table}))); _scheduleSave(); } @@ -470,11 +453,11 @@ class _ErdViewState extends material.State if (keys.isShiftPressed || keys.isControlPressed || keys.isMetaPressed) { _canvasFocus.requestFocus(); setState(() { - if (!_marked.remove(table)) _marked.add(table); + if (!_c.marked.remove(table)) _c.marked.add(table); }); return; } - if (_marked.isNotEmpty) setState(_marked.clear); + if (_c.marked.isNotEmpty) setState(_c.marked.clear); _pick(table); } @@ -484,10 +467,10 @@ class _ErdViewState extends material.State final schema = _schema; if (schema == null) return; setState(() { - _activeView = viewId; - _selected = null; - _pickedRelation = null; - _marked.clear(); + _c.activeView = viewId; + _c.selected = null; + _c.pickedRelation = null; + _c.marked.clear(); _applyState(target, colours: false); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), target)); }); @@ -511,17 +494,17 @@ class _ErdViewState extends material.State /// Switches to the view [id], or to *All tables* for null. void _switchView(String? id) { final schema = _schema; - if (schema == null || id == _activeView) return; + if (schema == null || id == _c.activeView) return; _captureCurrent(); if (id == null) { - _showState(null, _base); + _showState(null, _c.base); return; } final view = _viewById(id); if (view == null) return; _showState( id, - view.asLayout({for (final t in schema.tables) t.name}, _headerColors)); + view.asLayout({for (final t in schema.tables) t.name}, _c.headerColors)); } /// Saves what the diagram shows as a new view and moves into it. @@ -531,65 +514,65 @@ class _ErdViewState extends material.State final name = await showErdViewNameDialog(context, title: 'New view', action: 'Save', - name: 'View ${_views.length + 1}'); + name: 'View ${_c.views.length + 1}'); if (name == null || !mounted) return; - final fromAll = _viewById(_activeView) == null; + final fromAll = _viewById(_c.activeView) == null; _captureCurrent(); // The tables hidden by hand now belong to the view; All tables shows // every table, always. - if (fromAll) _base = _base.showingAll(); + if (fromAll) _c.base = _c.base.showingAll(); var n = 1; - while (_views.any((v) => v.id == 'v$n')) { + while (_c.views.any((v) => v.id == 'v$n')) { n++; } setState(() { - _views.add(ErdSavedView( + _c.views.add(ErdSavedView( id: 'v$n', name: name, tables: { for (final t in schema.tables) - if (!_hidden.contains(t.name)) t.name, + if (!_c.hidden.contains(t.name)) t.name, }, layout: _current(), )); - _activeView = 'v$n'; + _c.activeView = 'v$n'; }); _scheduleSave(); } Future _renameView() async { - final view = _viewById(_activeView); + final view = _viewById(_c.activeView); if (view == null) return; final name = await showErdViewNameDialog(context, title: 'Rename view', action: 'Rename', name: view.name); if (name == null || !mounted) return; - final i = _views.indexWhere((v) => v.id == view.id); + final i = _c.views.indexWhere((v) => v.id == view.id); if (i < 0) return; - setState(() => _views[i] = _views[i].copyWith(name: name)); + setState(() => _c.views[i] = _c.views[i].copyWith(name: name)); _scheduleSave(); } /// Deletes the active view and goes back to *All tables*. void _deleteView() { - final id = _activeView; + final id = _c.activeView; if (id == null) return; - _views.removeWhere((v) => v.id == id); - _showState(null, _base); + _c.views.removeWhere((v) => v.id == id); + _showState(null, _c.base); } /// Moves the notes attached to a table by what the table moved, from /// [before] to the current layout. void _followTables(ErdLayout before) { final after = _layout; - if (after == null || _notes.every((n) => n.attachedTo == null)) return; - for (var i = 0; i < _notes.length; i++) { - final t = _notes[i].attachedTo; + if (after == null || _c.notes.every((n) => n.attachedTo == null)) return; + for (var i = 0; i < _c.notes.length; i++) { + final t = _c.notes[i].attachedTo; if (t == null) continue; final a = before.positions[t], b = after.positions[t]; if (a == null || b == null || a == b) continue; final d = b - a; - _notes[i] = _notes[i] - .copyWith(x: _notes[i].x + d.dx, y: _notes[i].y + d.dy); + _c.notes[i] = _c.notes[i] + .copyWith(x: _c.notes[i].x + d.dx, y: _c.notes[i].y + d.dy); } } @@ -608,8 +591,8 @@ class _ErdViewState extends material.State title: 'New note', action: 'Add'); if (text == null || !mounted) return; final note = - ErdNoteOps.create(_notes, text: text, centre: _viewCentre()); - setState(() => _notes.add(note)); + ErdNoteOps.create(_c.notes, text: text, centre: _viewCentre()); + setState(() => _c.notes.add(note)); _scheduleSave(); } @@ -621,29 +604,29 @@ class _ErdViewState extends material.State } void _updateNote(String id, ErdNote Function(ErdNote) change) { - final i = _notes.indexWhere((n) => n.id == id); + final i = _c.notes.indexWhere((n) => n.id == id); if (i < 0) return; - setState(() => _notes[i] = change(_notes[i])); + setState(() => _c.notes[i] = change(_c.notes[i])); _scheduleSave(); } void _deleteNote(String id) { - setState(() => _notes.removeWhere((n) => n.id == id)); + setState(() => _c.notes.removeWhere((n) => n.id == id)); _scheduleSave(); } void _noteDragMove(String id, material.Offset screenDelta) { - final i = _notes.indexWhere((n) => n.id == id); + final i = _c.notes.indexWhere((n) => n.id == id); if (i < 0) return; - final moved = ErdNoteOps.moved(_notes[i], screenDelta, _scale); - setState(() => _notes[i] = moved); + final moved = ErdNoteOps.moved(_c.notes[i], screenDelta, _scale); + setState(() => _c.notes[i] = moved); } void _noteResize(String id, material.Offset screenDelta) { - final i = _notes.indexWhere((n) => n.id == id); + final i = _c.notes.indexWhere((n) => n.id == id); if (i < 0) return; - final resized = ErdNoteOps.resized(_notes[i], screenDelta, _scale); - setState(() => _notes[i] = resized); + final resized = ErdNoteOps.resized(_c.notes[i], screenDelta, _scale); + setState(() => _c.notes[i] = resized); } /// The table nearest to a note's middle, to attach it to. @@ -657,7 +640,7 @@ class _ErdViewState extends material.State /// Canvas size: the cards and every note. material.Size _canvasSize(ErdLayout layout) { var w = layout.size.width, h = layout.size.height; - for (final n in _notes) { + for (final n in _c.notes) { w = max(w, n.x + n.width + ErdLayout.margin); h = max(h, n.y + n.height + ErdLayout.margin); } @@ -672,15 +655,15 @@ class _ErdViewState extends material.State note: n, tint: _noteTint, slotColor: _slotColor, - dragging: _draggingNote == n.id, + dragging: _c.draggingNote == n.id, onFocusCanvas: _canvasFocus.requestFocus, - onDragStart: () => setState(() => _draggingNote = n.id), + onDragStart: () => setState(() => _c.draggingNote = n.id), onDragMove: (d) => _noteDragMove(n.id, d), onDragEnd: () { - setState(() => _draggingNote = null); + setState(() => _c.draggingNote = null); _scheduleSave(); }, - onDragCancel: () => setState(() => _draggingNote = null), + onDragCancel: () => setState(() => _c.draggingNote = null), onEdit: () => unawaited(_editNote(n)), onChange: (change) => _updateNote(n.id, change), onAttachNearest: () { @@ -694,13 +677,13 @@ class _ErdViewState extends material.State onResizeEnd: _scheduleSave, ); - void _groupDragStart(String id) => setState(() => _draggingGroup = id); + void _groupDragStart(String id) => setState(() => _c.draggingGroup = id); /// Moves every visible table of [group] by [screenDelta], keeping its frame /// inside the canvas. void _groupDragMove(ErdGroup group, material.Offset screenDelta) { final layout = _layout; - if (_draggingGroup != group.id || layout == null) return; + if (_c.draggingGroup != group.id || layout == null) return; final frame = layout.frameOf(group.tables); if (frame == null) return; final scale = _scale; @@ -718,8 +701,8 @@ class _ErdViewState extends material.State } void _groupDragEnd() { - if (_draggingGroup == null) return; - setState(() => _draggingGroup = null); + if (_c.draggingGroup == null) return; + setState(() => _c.draggingGroup = null); _scheduleSave(); } @@ -750,12 +733,12 @@ class _ErdViewState extends material.State final t = m.getTranslation(); return ErdSavedLayout( positions: Map.of(_layout?.positions ?? const {}), - collapsed: Set.of(_collapsed), - hidden: Set.of(_hidden), - detail: _detail, - headerColors: Map.of(_headerColors), - groups: List.of(_groups), - notes: List.of(_notes), + collapsed: Set.of(_c.collapsed), + hidden: Set.of(_c.hidden), + detail: _c.detail, + headerColors: Map.of(_c.headerColors), + groups: List.of(_c.groups), + notes: List.of(_c.notes), scale: m.getMaxScaleOnAxis(), translation: material.Offset(t.x, t.y), ); @@ -767,24 +750,24 @@ class _ErdViewState extends material.State final schema = _schema; if (schema == null || _layout == null) return; final cur = _current(); - final i = _views.indexWhere((v) => v.id == _activeView); + final i = _c.views.indexWhere((v) => v.id == _c.activeView); if (i < 0) { - _base = cur; + _c.base = cur; return; } - _views[i] = ErdSavedView( - id: _views[i].id, - name: _views[i].name, + _c.views[i] = ErdSavedView( + id: _c.views[i].id, + name: _c.views[i].name, tables: { for (final t in schema.tables) - if (!_hidden.contains(t.name)) t.name, + if (!_c.hidden.contains(t.name)) t.name, }, layout: cur, ); } ErdSavedView? _viewById(String? id) { - for (final v in _views) { + for (final v in _c.views) { if (v.id == id) return v; } return null; @@ -798,8 +781,8 @@ class _ErdViewState extends material.State await widget.layoutStore!.write( widget.layoutKey!, // The diagram's own state, with the header colours as they are now. - _base.withViews(List.of(_views), _activeView, - headerColors: Map.of(_headerColors)), + _c.base.withViews(List.of(_c.views), _c.activeView, + headerColors: Map.of(_c.headerColors)), ); } catch (_) { // Keeping the layout is a convenience: a failed write is not an error @@ -818,9 +801,9 @@ class _ErdViewState extends material.State /// The schema as drawn, for the current density and visibility. ErdSchema _visibleOf(ErdSchema schema) => ErdLayoutEngine.visibleOf( schema, - hidden: _hidden, - collapsed: _collapsed, - detail: _detail, + hidden: _c.hidden, + collapsed: _c.collapsed, + detail: _c.detail, ); /// Recomputes the layout for the current density and visibility. @@ -846,36 +829,36 @@ class _ErdViewState extends material.State void _toggleCollapsed(String table) { setState(() { - if (!_collapsed.remove(table)) _collapsed.add(table); + if (!_c.collapsed.remove(table)) _c.collapsed.add(table); }); _reflow(); } void _hide(String table) { setState(() { - _hidden.add(table); - _marked.remove(table); + _c.hidden.add(table); + _c.marked.remove(table); // A pick on a hidden table would leave every card faded. - if (_selected == table) _selected = null; + if (_c.selected == table) _c.selected = null; }); _syncFocus(); _reflow(); } void _showAllTables() { - setState(_hidden.clear); + setState(_c.hidden.clear); _reflow(); } void _dragStart(String table) { - setState(() => _dragging = table); + setState(() => _c.dragging = table); _syncFocus(); } /// [screenDelta] is in screen pixels; the canvas may be zoomed. void _dragMove(String table, material.Offset screenDelta) { final layout = _layout; - if (_dragging != table || layout == null) return; + if (_c.dragging != table || layout == null) return; final scale = _transform.value.getMaxScaleOnAxis(); final delta = screenDelta / (scale == 0 ? 1 : scale); // The card follows the pointer at once; the edges are routed once per frame. @@ -902,8 +885,8 @@ class _ErdViewState extends material.State } void _dragEnd() { - if (_dragging == null) return; - setState(() => _dragging = null); + if (_c.dragging == null) return; + setState(() => _c.dragging = null); _syncFocus(); _scheduleSave(); } @@ -958,8 +941,8 @@ class _ErdViewState extends material.State _selectTable(schema, name); _searchController.clear(); setState(() { - _searchOpen = false; - _query = ''; + _c.searchOpen = false; + _c.query = ''; }); _canvasFocus.requestFocus(); } @@ -977,7 +960,7 @@ class _ErdViewState extends material.State final t = vs.center(material.Offset.zero) - centre * s; _transform.value = Matrix4.identity()..translate(t.dx, t.dy)..scale(s); } - setState(() => _selected = name); + setState(() => _c.selected = name); _syncFocus(); } @@ -985,13 +968,13 @@ class _ErdViewState extends material.State /// the zoom and search shortcuts work after a click anywhere on it. void _clearSelection() { _canvasFocus.requestFocus(); - if (_selected == null && _pickedRelation == null && _marked.isEmpty) { + if (_c.selected == null && _c.pickedRelation == null && _c.marked.isEmpty) { return; } setState(() { - _selected = null; - _pickedRelation = null; - _marked.clear(); + _c.selected = null; + _c.pickedRelation = null; + _c.marked.clear(); }); _edgeTipNotifier.value = null; _syncFocus(); @@ -1007,8 +990,8 @@ class _ErdViewState extends material.State } _canvasFocus.requestFocus(); setState(() { - _pickedRelation = hit; - _selected = null; + _c.pickedRelation = hit; + _c.selected = null; }); _edgeTipNotifier.value = (hit, p); _syncFocus(); @@ -1018,15 +1001,15 @@ class _ErdViewState extends material.State void _pick(String name) { _canvasFocus.requestFocus(); setState(() { - _selected = name; - _pickedRelation = null; + _c.selected = name; + _c.pickedRelation = null; }); _edgeTipNotifier.value = null; _syncFocus(); } void _openSearch() { - setState(() => _searchOpen = true); + setState(() => _c.searchOpen = true); material.WidgetsBinding.instance .addPostFrameCallback((_) => _searchFocus.requestFocus()); } @@ -1034,33 +1017,33 @@ class _ErdViewState extends material.State /// Escape: closes the search when it is open, else clears the pick. One /// Esc never does both, so closing the search keeps the table found. void _escape() { - if (_searchOpen) { + if (_c.searchOpen) { _searchController.clear(); setState(() { - _searchOpen = false; - _query = ''; + _c.searchOpen = false; + _c.query = ''; }); _canvasFocus.requestFocus(); return; } - if (_pickedRelation != null) { - setState(() => _pickedRelation = null); + if (_c.pickedRelation != null) { + setState(() => _c.pickedRelation = null); _edgeTipNotifier.value = null; _syncFocus(); return; } - if (_marked.isNotEmpty) { - setState(_marked.clear); + if (_c.marked.isNotEmpty) { + setState(_c.marked.clear); return; } - if (_selected != null) { - setState(() => _selected = null); + if (_c.selected != null) { + setState(() => _c.selected = null); _syncFocus(); } } List _matches(ErdSchema schema) => - ErdLayoutEngine.matches(schema, _query); + ErdLayoutEngine.matches(schema, _c.query); /// Keyboard shortcuts for both Ctrl (Linux, Windows) and Cmd (macOS). Map _bindings() { @@ -1086,10 +1069,10 @@ class _ErdViewState extends material.State /// The table the diagram focuses: the dragged one, then the picked one, then /// the one under the mouse. - String? _currentFocus() => _dragging ?? _selected ?? _hovered.value; + String? _currentFocus() => _c.dragging ?? _c.selected ?? _hovered.value; ErdCardFocus _cardFocusFor(String table, String? focus) { - final p = _pickedRelation; + final p = _c.pickedRelation; if (p != null) { final on = table == p.fromTable || table == p.toTable; final column = table == p.fromTable @@ -1097,10 +1080,10 @@ class _ErdViewState extends material.State : (table == p.toTable ? p.toColumn : null); return (highlighted: on, faded: !on, column: column); } - final highlighted = ErdLayoutEngine.isFocusedIn(_neighbours, focus, table); + final highlighted = ErdLayoutEngine.isFocusedIn(_c.neighbours, focus, table); return ( highlighted: highlighted, - faded: _selected != null && !highlighted, + faded: _c.selected != null && !highlighted, column: null, ); } @@ -1114,7 +1097,7 @@ class _ErdViewState extends material.State /// Pushes the focus to the edges and to each card. A notifier only fires /// when its own highlight or fade changed, so the rest are not rebuilt. - /// Call after any change to [_selected], [_dragging] or [_hovered]. + /// Call after any change to [_c.selected], [_c.dragging] or [_hovered]. void _syncFocus() { final focus = _currentFocus(); _focus.value = focus; @@ -1130,7 +1113,7 @@ class _ErdViewState extends material.State /// Header colour of [table] on screen: the picked slot, the schema's slot, /// or the accent for a table of the current schema. material.Color _headerColor(String table) { - final slot = erdHeaderSlot(table, _headerColors); + final slot = erdHeaderSlot(table, _c.headerColors); if (slot == null) return context.workbench.accent; return _slotColor(slot); } @@ -1149,9 +1132,9 @@ class _ErdViewState extends material.State void _setHeaderColor(String table, String? slot) { setState(() { if (slot == null) { - _headerColors.remove(table); + _c.headerColors.remove(table); } else { - _headerColors[table] = slot; + _c.headerColors[table] = slot; } }); _scheduleSave(); @@ -1162,15 +1145,15 @@ class _ErdViewState extends material.State ErdExportData.headerFills( schema, cardHex, - headerColors: _headerColors, + headerColors: _c.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() => ErdViewsMenu( - views: _views, - active: _viewById(_activeView), + views: _c.views, + active: _viewById(_c.activeView), onShowAll: () => _switchView(null), onSwitch: _switchView, onNew: () => unawaited(_newView()), @@ -1181,7 +1164,7 @@ class _ErdViewState extends material.State /// Group entries of a card's menu: group it (with the marked tables), add /// it to a group, take it out of its group. List _groupMenu(String table) { - final tables = {..._marked, table}; + final tables = {..._c.marked, table}; final current = _groupOf(table); return [ MenuButton( @@ -1197,10 +1180,10 @@ class _ErdViewState extends material.State ? 'New group…' : 'Group ${tables.length} tables…'), ), - if (_groups.any((g) => g.id != current?.id)) + if (_c.groups.any((g) => g.id != current?.id)) MenuButton( subMenu: [ - for (final g in _groups) + for (final g in _c.groups) if (g.id != current?.id) MenuButton( onPressed: (_) => _addToGroup(g.id, tables), @@ -1220,7 +1203,7 @@ class _ErdViewState extends material.State /// Notes for the SVG, tinted as on screen over the export background. List _svgNotes(ErdSvgColors colors) => ErdExportData.notes( - _notes, + _c.notes, colors, slotColor: _slotColor, noteTint: _noteTint, @@ -1242,7 +1225,7 @@ class _ErdViewState extends material.State group: g, layout: layout, slotColor: _slotColor, - dragging: _draggingGroup == g.id, + dragging: _c.draggingGroup == g.id, onFocusCanvas: _canvasFocus.requestFocus, onDragStart: () => _groupDragStart(g.id), onDragMove: (delta) => _groupDragMove(g, delta), @@ -1276,13 +1259,13 @@ class _ErdViewState extends material.State List get routes => _routes; @override - List get groups => _groups; + List get groups => _c.groups; @override - List get notes => _notes; + List get notes => _c.notes; @override - Map get headerColorSlots => _headerColors; + Map get headerColorSlots => _c.headerColors; @override material.Color slotColor(String slot) => _slotColor(slot); @@ -1312,9 +1295,9 @@ class _ErdViewState extends material.State Future withCleanCanvas(Future Function() capture) async { // The pick, a picked relation and the marks for a group are screen // state: none of them belongs in the image, and all come back after it. - final picked = _selected; - final pickedRelation = _pickedRelation; - final marked = Set.of(_marked); + final picked = _c.selected; + final pickedRelation = _c.pickedRelation; + final marked = Set.of(_c.marked); final hadScreenState = picked != null || pickedRelation != null || marked.isNotEmpty; @@ -1325,9 +1308,9 @@ class _ErdViewState extends material.State _edgeTipNotifier.value = null; if (hadScreenState) { setState(() { - _selected = null; - _pickedRelation = null; - _marked.clear(); + _c.selected = null; + _c.pickedRelation = null; + _c.marked.clear(); }); _syncFocus(); } @@ -1339,11 +1322,11 @@ class _ErdViewState extends material.State if (mounted && hadScreenState) { setState(() { // What the user did during the capture wins. - if (_selected == null && _pickedRelation == null) { - _selected = picked; - _pickedRelation = pickedRelation; + if (_c.selected == null && _c.pickedRelation == null) { + _c.selected = picked; + _c.pickedRelation = pickedRelation; } - if (_marked.isEmpty) _marked.addAll(marked); + if (_c.marked.isEmpty) _c.marked.addAll(marked); }); _syncFocus(); } @@ -1357,7 +1340,7 @@ class _ErdViewState extends material.State /// 6 screen px of it, if any, whatever the zoom. void _hoverCanvas(material.Offset p) { // A picked relation keeps its label until Esc or another click. - if (_pickedRelation != null) return; + if (_c.pickedRelation != null) return; final hit = _relationAt(p); // Only the tip layer listens: no rebuild of the view for a hover move. _edgeTipNotifier.value = hit == null ? null : (hit, p); @@ -1379,7 +1362,7 @@ class _ErdViewState extends material.State } void _leaveCanvas() { - if (_pickedRelation != null) return; + if (_c.pickedRelation != null) return; _edgeTipNotifier.value = null; } @@ -1389,13 +1372,13 @@ class _ErdViewState extends material.State /// 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(); + if (!_c.searchOpen) return const material.SizedBox.shrink(); return ErdSearchPanel( controller: _searchController, focusNode: _searchFocus, - query: _query, + query: _c.query, matches: _matches(schema), - onChanged: (v) => setState(() => _query = v.trim()), + onChanged: (v) => setState(() => _c.query = v.trim()), onPick: (name) => _pickFromSearch(schema, name), ); } @@ -1416,9 +1399,9 @@ class _ErdViewState extends material.State final tables = schema.tables; final shown = min(tables.length, _mountedTables); final menuKey = Object.hash( - Object.hashAll(_marked), + Object.hashAll(_c.marked), Object.hashAll([ - for (final g in _groups) + for (final g in _c.groups) Object.hash(g.id, g.name, Object.hashAll(g.tables)), ]), ); @@ -1436,10 +1419,10 @@ class _ErdViewState extends material.State position: layout.positions[t.name]!, width: layout.widthFor(t.name), headerColor: _headerColor(t.name), - collapsed: _collapsed.contains(t.name), - marked: _marked.contains(t.name), - dragging: _dragging == t.name, - junctionOf: _junctions[t.name], + collapsed: _c.collapsed.contains(t.name), + marked: _c.marked.contains(t.name), + dragging: _c.dragging == t.name, + junctionOf: _c.junctions[t.name], menuKey: menuKey, palette: palette, onOpenTable: widget.onOpenTable, @@ -1486,7 +1469,7 @@ class _ErdViewState extends material.State onOpenData: widget.onOpenTable, onOpenInSql: widget.onOpenInSql, onShowRelations: widget.onShowRelations, - collapsed: _collapsed.contains(t.name), + collapsed: _c.collapsed.contains(t.name), onToggleCollapsed: () => _toggleCollapsed(t.name), onHide: () => _hide(t.name), @@ -1501,12 +1484,12 @@ class _ErdViewState extends material.State headerColor: _headerColor(t.name), highlighted: focus.highlighted, focusColumn: focus.column, - junctionOf: _junctions[t.name], - dragging: _dragging == t.name, + junctionOf: _c.junctions[t.name], + dragging: _c.dragging == t.name, onOpen: widget.onOpenTable == null ? null : () => widget.onOpenTable!(t.name), - marked: _marked.contains(t.name), + marked: _c.marked.contains(t.name), onSelect: () => _tapCard(t.name), onHover: (inside) => _hovered.value = inside ? t.name @@ -1539,7 +1522,7 @@ class _ErdViewState extends material.State // Every table was hidden from the diagram: they exist, so say so and // offer them back instead of "No tables found". body = ErdStatusBody.allHidden(context, - hiddenCount: _hidden.length, onShowAll: _showAllTables); + hiddenCount: _c.hidden.length, onShowAll: _showAllTables); } else if (schema == null || layout == null || schema.isEmpty) { body = ErdStatusBody.noTables(context, databaseName: widget.databaseName); @@ -1562,7 +1545,7 @@ class _ErdViewState extends material.State constrained: false, transformationController: _transform, // A card or group drag must not pan the canvas. - panEnabled: _dragging == null && _draggingGroup == null, + panEnabled: _c.dragging == null && _c.draggingGroup == null, minScale: _zoomFloor, maxScale: _maxScale, boundaryMargin: const material.EdgeInsets.all(400), @@ -1602,14 +1585,14 @@ class _ErdViewState extends material.State color: wb.mutedForeground, highlight: wb.accent, focus: focus, - picked: _pickedRelation, + picked: _c.pickedRelation, ), ), ), ), ..._cards(schema, layout), // Sticky notes above the cards (#1283). - for (final n in _notes) _noteWidget(n), + for (final n in _c.notes) _noteWidget(n), ], ), ), @@ -1703,10 +1686,10 @@ class _ErdViewState extends material.State child: QueryaTabStrip( key: const material.ValueKey('erd_detail'), labels: const ['Names', 'Keys', 'All'], - selectedIndex: ErdDetail.values.indexOf(_detail), + selectedIndex: ErdDetail.values.indexOf(_c.detail), onSelected: (i) { - if (ErdDetail.values[i] == _detail) return; - setState(() => _detail = ErdDetail.values[i]); + if (ErdDetail.values[i] == _c.detail) return; + setState(() => _c.detail = ErdDetail.values[i]); _reflow(); }, ), @@ -1728,21 +1711,21 @@ class _ErdViewState extends material.State tooltip: 'Add a sticky note to the diagram', onPressed: ready ? () => unawaited(_addNote()) : null, ), - if (_marked.isNotEmpty) + if (_c.marked.isNotEmpty) QueryaButton.secondary( key: const material.ValueKey('erd_group_marked'), compact: compact, - label: 'Group ${_marked.length} ' - '${_marked.length == 1 ? 'table' : 'tables'}', + label: 'Group ${_c.marked.length} ' + '${_c.marked.length == 1 ? 'table' : 'tables'}', icon: material.Icons.folder_open_rounded, tooltip: 'Shift / Ctrl + click marks more tables', - onPressed: () => unawaited(_newGroup({..._marked})), + onPressed: () => unawaited(_newGroup({..._c.marked})), ), - if (_hidden.isNotEmpty) + if (_c.hidden.isNotEmpty) QueryaButton.secondary( key: const material.ValueKey('erd_show_all'), compact: compact, - label: 'Show ${_hidden.length} hidden', + label: 'Show ${_c.hidden.length} hidden', icon: material.Icons.visibility_outlined, onPressed: _showAllTables, ), diff --git a/lib/features/erd/erd_view_controller.dart b/lib/features/erd/erd_view_controller.dart new file mode 100644 index 00000000..a73ce833 --- /dev/null +++ b/lib/features/erd/erd_view_controller.dart @@ -0,0 +1,60 @@ +import 'package:querya_desktop/core/erd/erd_group_ops.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; + +/// What the user has done to a diagram, apart from where the cards are: +/// the picked table and relation, marks, density, groups, notes and saved +/// views. No widgets and no layout geometry, so each rule is a plain method +/// that a unit test can call (#1690). +/// +/// The view owns the layout, the routes and the viewport, and asks this for +/// the rest. Mutators change the state; the view decides when to rebuild and +/// to save. +class ErdViewController { + /// The table being dragged. + String? dragging; + + /// The table picked by a click or search: its edges stay highlighted. + String? selected; + + /// A relation picked by a click on its edge (#1281): its two tables and + /// columns are highlighted and its label stays until Esc. + ErdRelation? pickedRelation; + + /// Tables marked with Shift / Ctrl / Cmd + click, to be grouped. + final Set marked = {}; + + /// Density: only PK and FK columns, cards collapsed to their header, and + /// tables hidden from the diagram. + ErdDetail detail = ErdDetail.all; + final Set collapsed = {}; + final Set hidden = {}; + + /// Header colour picked per table (palette slot names, #1276). + final Map headerColors = {}; + + /// The user's table groups, saved with the layout (#1282). + final List groups = []; + + /// The group whose frame is being dragged by its title. + String? draggingGroup; + + /// Sticky notes on the canvas, saved with the layout (#1283). + final List notes = []; + String? draggingNote; + + /// Saved views of the diagram (#1284), the one the user is in, and the + /// *All tables* state while a view is active. + final List views = []; + String? activeView; + ErdSavedLayout base = const ErdSavedLayout(); + + /// Neighbours per table, for the focus of a hover or a pick. + Map> neighbours = const {}; + + /// Many-to-many link tables and what they link: `users and roles` (#1281). + Map junctions = const {}; + + bool searchOpen = false; + String query = ''; +} From d7c5967f72f02a2f009bc518ae1cebd6efe33db5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 15:51:34 +0300 Subject: [PATCH 2/4] refactor(erd): groups, marks, density, notes, views, picks and Esc live in ErdViewController (#1690) --- lib/features/erd/erd_view.dart | 287 ++++++---------------- lib/features/erd/erd_view_controller.dart | 276 +++++++++++++++++++++ 2 files changed, 349 insertions(+), 214 deletions(-) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 97e51361..a7b6a1d7 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -260,7 +260,7 @@ class _ErdViewState extends material.State ..clear() ..addAll(stored.views); _c.activeView = stored.activeView; - final view = _viewById(_c.activeView); + final view = _c.viewById(_c.activeView); if (view != null) { saved = view.asLayout( {for (final t in schema.tables) t.name}, stored.headerColors); @@ -271,7 +271,7 @@ class _ErdViewState extends material.State _c.neighbours = ErdLayoutEngine.neighbourMap(schema); _c.junctions = ErdLayoutEngine.junctionMap(schema); _c.pickedRelation = null; - if (saved != null) _applyState(saved, colours: true); + if (saved != null) _c.applyState(saved, colours: true); _c.marked.clear(); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; @@ -314,30 +314,6 @@ class _ErdViewState extends material.State } } - /// The state of the diagram a [ErdSavedLayout] holds: what is collapsed - /// and hidden, the detail, groups and notes. Header colours belong to the - /// diagram, so a view switch leaves them ([colours] false). - void _applyState(ErdSavedLayout s, {required bool colours}) { - _c.collapsed - ..clear() - ..addAll(s.collapsed); - _c.hidden - ..clear() - ..addAll(s.hidden); - _c.detail = s.detail; - if (colours) { - _c.headerColors - ..clear() - ..addAll(s.headerColors); - } - _c.groups - ..clear() - ..addAll(s.groups); - _c.notes - ..clear() - ..addAll(s.notes); - } - /// Layout with cards measured in this view's fonts and text scale. ErdLayout _compute(ErdSchema schema) { final measure = ErdCardMeasure( @@ -346,27 +322,7 @@ class _ErdViewState extends material.State ); return ErdLayout.compute(schema, measure: measure.width, - groups: [for (final g in _allGroups()) g.tables]); - } - - /// The user's groups, then one per schema for the tables in none when the - /// diagram spans several schemas. - List _allGroups() { - final schema = _schema; - if (schema == null) return List.of(_c.groups); - return [ - ..._c.groups, - ...erdSchemaGroups([for (final t in schema.tables) t.name], _c.groups), - ]; - } - - ErdGroup? _groupOf(String table) => ErdGroupOps.groupOf(_c.groups, table); - - void _replaceGroups(List groups) { - final next = List.of(groups); - _c.groups - ..clear() - ..addAll(next); + groups: [for (final g in _c.allGroups(_schema)) g.tables]); } /// Asks for a name and groups [tables], taking them out of other groups. @@ -376,17 +332,16 @@ class _ErdViewState extends material.State final text = await showErdGroupDialog(context, title: 'New group', action: 'Create', name: 'Group $n'); if (text == null || !mounted) return; + late final String id; setState(() { - _replaceGroups(ErdGroupOps.withNewGroup( - _c.groups, + id = _c.createGroup( tables: tables, tableNames: [for (final t in _schema?.tables ?? const []) t.name], name: text.name, note: text.note, - )); - _c.marked.clear(); + ); }); - _gatherIfCovering(_c.groups.last.id); + _gatherIfCovering(id); _scheduleSave(); } @@ -400,7 +355,7 @@ class _ErdViewState extends material.State if (!layout.frameCoversOthers(tables)) return; setState(() { _setLayout(layout.gather(tables)); - _followTables(layout); + _c.followTables(layout, _layout); }); showAppToast( context: context, @@ -410,10 +365,7 @@ class _ErdViewState extends material.State } void _addToGroup(String id, Set tables) { - setState(() { - _replaceGroups(ErdGroupOps.addTables(_c.groups, id, tables)); - if (_c.groups.any((g) => g.id == id)) _c.marked.clear(); - }); + setState(() => _c.addToGroup(id, tables)); _gatherIfCovering(id); _scheduleSave(); } @@ -430,19 +382,18 @@ class _ErdViewState extends material.State } void _updateGroup(String id, ErdGroup Function(ErdGroup) change) { - final i = _c.groups.indexWhere((g) => g.id == id); - if (i < 0) return; - setState(() => _c.groups[i] = change(_c.groups[i])); - _scheduleSave(); + var found = false; + setState(() => found = _c.updateGroup(id, change)); + if (found) _scheduleSave(); } void _ungroup(String id) { - setState(() => _c.groups.removeWhere((g) => g.id == id)); + setState(() => _c.ungroup(id)); _scheduleSave(); } void _removeFromGroup(String table) { - setState(() => _replaceGroups(ErdGroupOps.ungroupTables(_c.groups, {table}))); + setState(() => _c.removeFromGroup(table)); _scheduleSave(); } @@ -452,9 +403,7 @@ class _ErdViewState extends material.State final keys = HardwareKeyboard.instance; if (keys.isShiftPressed || keys.isControlPressed || keys.isMetaPressed) { _canvasFocus.requestFocus(); - setState(() { - if (!_c.marked.remove(table)) _c.marked.add(table); - }); + setState(() => _c.toggleMarked(table)); return; } if (_c.marked.isNotEmpty) setState(_c.marked.clear); @@ -471,7 +420,7 @@ class _ErdViewState extends material.State _c.selected = null; _c.pickedRelation = null; _c.marked.clear(); - _applyState(target, colours: false); + _c.applyState(target, colours: false); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), target)); }); _syncFocus(); @@ -500,7 +449,7 @@ class _ErdViewState extends material.State _showState(null, _c.base); return; } - final view = _viewById(id); + final view = _c.viewById(id); if (view == null) return; _showState( id, @@ -516,18 +465,15 @@ class _ErdViewState extends material.State action: 'Save', name: 'View ${_c.views.length + 1}'); if (name == null || !mounted) return; - final fromAll = _viewById(_c.activeView) == null; + final fromAll = _c.viewById(_c.activeView) == null; _captureCurrent(); // The tables hidden by hand now belong to the view; All tables shows // every table, always. if (fromAll) _c.base = _c.base.showingAll(); - var n = 1; - while (_c.views.any((v) => v.id == 'v$n')) { - n++; - } + final id = _c.nextViewId(); setState(() { _c.views.add(ErdSavedView( - id: 'v$n', + id: id, name: name, tables: { for (final t in schema.tables) @@ -535,47 +481,30 @@ class _ErdViewState extends material.State }, layout: _current(), )); - _c.activeView = 'v$n'; + _c.activeView = id; }); _scheduleSave(); } Future _renameView() async { - final view = _viewById(_c.activeView); + final view = _c.viewById(_c.activeView); if (view == null) return; final name = await showErdViewNameDialog(context, title: 'Rename view', action: 'Rename', name: view.name); if (name == null || !mounted) return; - final i = _c.views.indexWhere((v) => v.id == view.id); - if (i < 0) return; - setState(() => _c.views[i] = _c.views[i].copyWith(name: name)); - _scheduleSave(); + var found = false; + setState(() => found = _c.renameView(view.id, name)); + if (found) _scheduleSave(); } /// Deletes the active view and goes back to *All tables*. void _deleteView() { final id = _c.activeView; if (id == null) return; - _c.views.removeWhere((v) => v.id == id); + _c.removeView(id); _showState(null, _c.base); } - /// Moves the notes attached to a table by what the table moved, from - /// [before] to the current layout. - void _followTables(ErdLayout before) { - final after = _layout; - if (after == null || _c.notes.every((n) => n.attachedTo == null)) return; - for (var i = 0; i < _c.notes.length; i++) { - final t = _c.notes[i].attachedTo; - if (t == null) continue; - final a = before.positions[t], b = after.positions[t]; - if (a == null || b == null || a == b) continue; - final d = b - a; - _c.notes[i] = _c.notes[i] - .copyWith(x: _c.notes[i].x + d.dx, y: _c.notes[i].y + d.dy); - } - } - /// Canvas point at the middle of what the viewport shows. material.Offset _viewCentre() { final vs = _viewportSize(); @@ -592,7 +521,7 @@ class _ErdViewState extends material.State if (text == null || !mounted) return; final note = ErdNoteOps.create(_c.notes, text: text, centre: _viewCentre()); - setState(() => _c.notes.add(note)); + setState(() => _c.addNote(note)); _scheduleSave(); } @@ -604,29 +533,22 @@ class _ErdViewState extends material.State } void _updateNote(String id, ErdNote Function(ErdNote) change) { - final i = _c.notes.indexWhere((n) => n.id == id); - if (i < 0) return; - setState(() => _c.notes[i] = change(_c.notes[i])); - _scheduleSave(); + var found = false; + setState(() => found = _c.updateNote(id, change)); + if (found) _scheduleSave(); } void _deleteNote(String id) { - setState(() => _c.notes.removeWhere((n) => n.id == id)); + setState(() => _c.deleteNote(id)); _scheduleSave(); } void _noteDragMove(String id, material.Offset screenDelta) { - final i = _c.notes.indexWhere((n) => n.id == id); - if (i < 0) return; - final moved = ErdNoteOps.moved(_c.notes[i], screenDelta, _scale); - setState(() => _c.notes[i] = moved); + setState(() => _c.moveNote(id, screenDelta, _scale)); } void _noteResize(String id, material.Offset screenDelta) { - final i = _c.notes.indexWhere((n) => n.id == id); - if (i < 0) return; - final resized = ErdNoteOps.resized(_c.notes[i], screenDelta, _scale); - setState(() => _c.notes[i] = resized); + setState(() => _c.resizeNote(id, screenDelta, _scale)); } /// The table nearest to a note's middle, to attach it to. @@ -637,16 +559,6 @@ class _ErdViewState extends material.State return ErdNoteOps.nearestTable(n, layout, _visibleOf(schema)); } - /// Canvas size: the cards and every note. - material.Size _canvasSize(ErdLayout layout) { - var w = layout.size.width, h = layout.size.height; - for (final n in _c.notes) { - w = max(w, n.x + n.width + ErdLayout.margin); - h = max(h, n.y + n.height + ErdLayout.margin); - } - return material.Size(w, h); - } - /// Tint of a note without a colour over the canvas, on screen and in the /// exports. static const double _noteTint = 0.07; @@ -695,7 +607,7 @@ class _ErdViewState extends material.State for (final t in group.tables) if (layout.positions[t] case final p?) t: p + delta, }); - _followTables(layout); + _c.followTables(layout, _layout); }); _scheduleRoutes(); } @@ -766,13 +678,6 @@ class _ErdViewState extends material.State ); } - ErdSavedView? _viewById(String? id) { - for (final v in _c.views) { - if (v.id == id) return v; - } - return null; - } - Future _saveNow() async { final layout = _layout; if (!_persists || layout == null) return; @@ -820,7 +725,7 @@ class _ErdViewState extends material.State _setLayout(arrange || current == null ? computed : ErdLayoutEngine.withSaved(computed, ErdSavedLayout(positions: current.positions))); - if (current != null) _followTables(current); + if (current != null) _c.followTables(current, _layout); }); _scheduleSave(); } @@ -828,19 +733,12 @@ class _ErdViewState extends material.State void _autoLayout() => _reflow(arrange: true); void _toggleCollapsed(String table) { - setState(() { - if (!_c.collapsed.remove(table)) _c.collapsed.add(table); - }); + setState(() => _c.toggleCollapsed(table)); _reflow(); } void _hide(String table) { - setState(() { - _c.hidden.add(table); - _c.marked.remove(table); - // A pick on a hidden table would leave every card faded. - if (_c.selected == table) _c.selected = null; - }); + setState(() => _c.hide(table)); _syncFocus(); _reflow(); } @@ -865,7 +763,7 @@ class _ErdViewState extends material.State setState(() { _layout = layout.withPosition(table, layout.positions[table]! + delta); - _followTables(layout); + _c.followTables(layout, _layout); }); _scheduleRoutes(); } @@ -926,7 +824,7 @@ class _ErdViewState extends material.State final vs = _viewportSize(); if (layout == null || vs == null) return; // The cards and every note: a note placed away from the cards counts. - final size = _canvasSize(layout); + final size = _c.canvasSize(layout); final s = ErdLayout.fitScale(size, vs); final floor = min(_minScale, s); if (floor != _zoomFloor) setState(() => _zoomFloor = floor); @@ -968,14 +866,8 @@ class _ErdViewState extends material.State /// the zoom and search shortcuts work after a click anywhere on it. void _clearSelection() { _canvasFocus.requestFocus(); - if (_c.selected == null && _c.pickedRelation == null && _c.marked.isEmpty) { - return; - } - setState(() { - _c.selected = null; - _c.pickedRelation = null; - _c.marked.clear(); - }); + if (!_c.clearPicks()) return; + setState(() {}); _edgeTipNotifier.value = null; _syncFocus(); } @@ -989,10 +881,7 @@ class _ErdViewState extends material.State return; } _canvasFocus.requestFocus(); - setState(() { - _c.pickedRelation = hit; - _c.selected = null; - }); + setState(() => _c.pickRelation(hit)); _edgeTipNotifier.value = (hit, p); _syncFocus(); } @@ -1000,10 +889,7 @@ class _ErdViewState extends material.State /// A click on a card picks it. void _pick(String name) { _canvasFocus.requestFocus(); - setState(() { - _c.selected = name; - _c.pickedRelation = null; - }); + setState(() => _c.pick(name)); _edgeTipNotifier.value = null; _syncFocus(); } @@ -1014,31 +900,26 @@ class _ErdViewState extends material.State .addPostFrameCallback((_) => _searchFocus.requestFocus()); } - /// Escape: closes the search when it is open, else clears the pick. One - /// Esc never does both, so closing the search keeps the table found. + /// Escape: one step per press (see [ErdViewController.escape]); closing + /// the search keeps the table found. void _escape() { - if (_c.searchOpen) { - _searchController.clear(); - setState(() { - _c.searchOpen = false; - _c.query = ''; - }); - _canvasFocus.requestFocus(); - return; - } - if (_c.pickedRelation != null) { - setState(() => _c.pickedRelation = null); - _edgeTipNotifier.value = null; - _syncFocus(); - return; - } - if (_c.marked.isNotEmpty) { - setState(_c.marked.clear); - return; - } - if (_c.selected != null) { - setState(() => _c.selected = null); - _syncFocus(); + final step = _c.escape(); + switch (step) { + case ErdEscape.closedSearch: + _searchController.clear(); + setState(() {}); + _canvasFocus.requestFocus(); + case ErdEscape.droppedRelation: + setState(() {}); + _edgeTipNotifier.value = null; + _syncFocus(); + case ErdEscape.clearedMarks: + setState(() {}); + case ErdEscape.clearedPick: + setState(() {}); + _syncFocus(); + case ErdEscape.nothing: + break; } } @@ -1067,26 +948,10 @@ class _ErdViewState extends material.State return out; } - /// The table the diagram focuses: the dragged one, then the picked one, then - /// the one under the mouse. - String? _currentFocus() => _c.dragging ?? _c.selected ?? _hovered.value; + String? _currentFocus() => _c.currentFocus(_hovered.value); - ErdCardFocus _cardFocusFor(String table, String? focus) { - final p = _c.pickedRelation; - if (p != null) { - final on = table == p.fromTable || table == p.toTable; - final column = table == p.fromTable - ? p.fromColumn - : (table == p.toTable ? p.toColumn : null); - return (highlighted: on, faded: !on, column: column); - } - final highlighted = ErdLayoutEngine.isFocusedIn(_c.neighbours, focus, table); - return ( - highlighted: highlighted, - faded: _c.selected != null && !highlighted, - column: null, - ); - } + ErdCardFocus _cardFocusFor(String table, String? focus) => + _c.cardFocusFor(table, focus); /// The notifier of card [table], made on first use with its current focus. material.ValueNotifier _cardState(String table) => @@ -1130,13 +995,7 @@ class _ErdViewState extends material.State } void _setHeaderColor(String table, String? slot) { - setState(() { - if (slot == null) { - _c.headerColors.remove(table); - } else { - _c.headerColors[table] = slot; - } - }); + setState(() => _c.setHeaderColor(table, slot)); _scheduleSave(); } @@ -1153,7 +1012,7 @@ class _ErdViewState extends material.State /// commands to save, rename and delete one. material.Widget _viewsMenu() => ErdViewsMenu( views: _c.views, - active: _viewById(_c.activeView), + active: _c.viewById(_c.activeView), onShowAll: () => _switchView(null), onSwitch: _switchView, onNew: () => unawaited(_newView()), @@ -1165,7 +1024,7 @@ class _ErdViewState extends material.State /// it to a group, take it out of its group. List _groupMenu(String table) { final tables = {..._c.marked, table}; - final current = _groupOf(table); + final current = _c.groupOf(table); return [ MenuButton( key: material.ValueKey('erd_menu_group_$table'), @@ -1212,7 +1071,7 @@ class _ErdViewState extends material.State /// Group frames for the SVG: the screen's tint over the export background. List _svgGroups(ErdLayout layout, String backgroundHex) => ErdExportData.groups( - _allGroups(), + _c.allGroups(_schema), layout, backgroundHex, slotColor: _slotColor, @@ -1271,7 +1130,7 @@ class _ErdViewState extends material.State material.Color slotColor(String slot) => _slotColor(slot); @override - material.Size canvasSize(ErdLayout layout) => _canvasSize(layout); + material.Size canvasSize(ErdLayout layout) => _c.canvasSize(layout); @override ErdSvgColors currentThemeColors() => _svgColors(); @@ -1553,8 +1412,8 @@ class _ErdViewState extends material.State key: _boundaryKey, child: material.Container( color: wb.surface, - width: _canvasSize(layout).width, - height: _canvasSize(layout).height, + width: _c.canvasSize(layout).width, + height: _c.canvasSize(layout).height, child: material.GestureDetector( // A tap on an edge picks its relation, elsewhere it clears // the selection (#1281). @@ -1573,7 +1432,7 @@ class _ErdViewState extends material.State ), ), // Group frames under the edges and cards (#1282). - for (final g in _allGroups()) + for (final g in _c.allGroups(_schema)) ..._groupFrame(g, layout), material.Positioned.fill( child: material.ValueListenableBuilder( diff --git a/lib/features/erd/erd_view_controller.dart b/lib/features/erd/erd_view_controller.dart index a73ce833..30e74b3d 100644 --- a/lib/features/erd/erd_view_controller.dart +++ b/lib/features/erd/erd_view_controller.dart @@ -1,5 +1,11 @@ +import 'dart:math' show max; +import 'dart:ui' show Offset, Size; + 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_note_ops.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; /// What the user has done to a diagram, apart from where the cards are: @@ -57,4 +63,274 @@ class ErdViewController { bool searchOpen = false; String query = ''; + + // ---- Groups -------------------------------------------------------------- + + /// The user's groups, then one per schema for the tables in none when the + /// diagram spans several schemas. + List allGroups(ErdSchema? schema) { + if (schema == null) return List.of(groups); + return [ + ...groups, + ...erdSchemaGroups([for (final t in schema.tables) t.name], groups), + ]; + } + + ErdGroup? groupOf(String table) => ErdGroupOps.groupOf(groups, table); + + void _replaceGroups(List next) { + final copy = List.of(next); + groups + ..clear() + ..addAll(copy); + } + + /// Groups [tables] under [name], taking them out of other groups, and + /// clears the marks. Returns the id of the new group. + String createGroup({ + required Set tables, + required Iterable tableNames, + required String name, + String? note, + }) { + _replaceGroups(ErdGroupOps.withNewGroup( + groups, + tables: tables, + tableNames: tableNames, + name: name, + note: note, + )); + marked.clear(); + return groups.last.id; + } + + /// Adds [tables] to the group [id]; the marks go when the group exists. + void addToGroup(String id, Set tables) { + _replaceGroups(ErdGroupOps.addTables(groups, id, tables)); + if (groups.any((g) => g.id == id)) marked.clear(); + } + + /// Changes the group [id]; false when there is none. + bool updateGroup(String id, ErdGroup Function(ErdGroup) change) { + final i = groups.indexWhere((g) => g.id == id); + if (i < 0) return false; + groups[i] = change(groups[i]); + return true; + } + + void ungroup(String id) => groups.removeWhere((g) => g.id == id); + + void removeFromGroup(String table) => + _replaceGroups(ErdGroupOps.ungroupTables(groups, {table})); + + // ---- Marks, density, colours -------------------------------------------- + + void toggleMarked(String table) { + if (!marked.remove(table)) marked.add(table); + } + + void toggleCollapsed(String table) { + if (!collapsed.remove(table)) collapsed.add(table); + } + + /// Hides [table] from the diagram. + void hide(String table) { + hidden.add(table); + marked.remove(table); + // A pick on a hidden table would leave every card faded. + if (selected == table) selected = null; + } + + /// [slot] null puts the table back to the default header colour. + void setHeaderColor(String table, String? slot) { + if (slot == null) { + headerColors.remove(table); + } else { + headerColors[table] = slot; + } + } + + /// Applies the state a [ErdSavedLayout] holds: what is collapsed and hidden, + /// the detail, groups and notes. Header colours belong to the diagram, so a + /// view switch leaves them ([colours] false). + void applyState(ErdSavedLayout s, {required bool colours}) { + collapsed + ..clear() + ..addAll(s.collapsed); + hidden + ..clear() + ..addAll(s.hidden); + detail = s.detail; + if (colours) { + headerColors + ..clear() + ..addAll(s.headerColors); + } + groups + ..clear() + ..addAll(s.groups); + notes + ..clear() + ..addAll(s.notes); + } + + // ---- Notes --------------------------------------------------------------- + + void addNote(ErdNote note) => notes.add(note); + + bool updateNote(String id, ErdNote Function(ErdNote) change) { + final i = notes.indexWhere((n) => n.id == id); + if (i < 0) return false; + notes[i] = change(notes[i]); + return true; + } + + void deleteNote(String id) => notes.removeWhere((n) => n.id == id); + + void moveNote(String id, Offset screenDelta, double scale) { + final i = notes.indexWhere((n) => n.id == id); + if (i < 0) return; + notes[i] = ErdNoteOps.moved(notes[i], screenDelta, scale); + } + + void resizeNote(String id, Offset screenDelta, double scale) { + final i = notes.indexWhere((n) => n.id == id); + if (i < 0) return; + notes[i] = ErdNoteOps.resized(notes[i], screenDelta, scale); + } + + /// Moves the notes attached to a table by what the table moved, from + /// [before] to [after]. + void followTables(ErdLayout before, ErdLayout? after) { + if (after == null || notes.every((n) => n.attachedTo == null)) return; + for (var i = 0; i < notes.length; i++) { + final t = notes[i].attachedTo; + if (t == null) continue; + final a = before.positions[t], b = after.positions[t]; + if (a == null || b == null || a == b) continue; + final d = b - a; + notes[i] = notes[i].copyWith(x: notes[i].x + d.dx, y: notes[i].y + d.dy); + } + } + + /// Canvas size: the cards and every note. + Size canvasSize(ErdLayout layout) { + var w = layout.size.width, h = layout.size.height; + for (final n in notes) { + w = max(w, n.x + n.width + ErdLayout.margin); + h = max(h, n.y + n.height + ErdLayout.margin); + } + return Size(w, h); + } + + // ---- Saved views --------------------------------------------------------- + + ErdSavedView? viewById(String? id) { + for (final v in views) { + if (v.id == id) return v; + } + return null; + } + + /// The id for the next view, `v`. + String nextViewId() { + var n = 1; + while (views.any((v) => v.id == 'v$n')) { + n++; + } + return 'v$n'; + } + + /// Renames the view [id]; false when there is none. + bool renameView(String id, String name) { + final i = views.indexWhere((v) => v.id == id); + if (i < 0) return false; + views[i] = views[i].copyWith(name: name); + return true; + } + + void removeView(String id) => views.removeWhere((v) => v.id == id); + + // ---- Pick and focus ------------------------------------------------------ + + /// A click on a card with no modifier: picks it and drops a picked relation. + void pick(String name) { + selected = name; + pickedRelation = null; + } + + /// A click on an edge: picks the relation, drops the table pick. + void pickRelation(ErdRelation relation) { + pickedRelation = relation; + selected = null; + } + + /// Drops the picks and the marks. False when there was nothing to drop. + bool clearPicks() { + if (selected == null && pickedRelation == null && marked.isEmpty) { + return false; + } + selected = null; + pickedRelation = null; + marked.clear(); + return true; + } + + /// What Esc does, one step per press: close the search, else drop the + /// picked relation, else the marks, else the pick. The state is changed; the + /// result says which step it was. + ErdEscape escape() { + if (searchOpen) { + searchOpen = false; + query = ''; + return ErdEscape.closedSearch; + } + if (pickedRelation != null) { + pickedRelation = null; + return ErdEscape.droppedRelation; + } + if (marked.isNotEmpty) { + marked.clear(); + return ErdEscape.clearedMarks; + } + if (selected != null) { + selected = null; + return ErdEscape.clearedPick; + } + return ErdEscape.nothing; + } + + /// The table the diagram focuses: the dragged one, then the picked one, then + /// [hovered]. + String? currentFocus(String? hovered) => dragging ?? selected ?? hovered; + + /// How the card [table] looks for the focused table [focus]: highlighted + /// when it is the focus or next to it, faded when something is picked and + /// it is not, and the column of a picked relation. + ({bool highlighted, bool faded, String? column}) cardFocusFor( + String table, String? focus) { + final p = pickedRelation; + if (p != null) { + final on = table == p.fromTable || table == p.toTable; + final column = table == p.fromTable + ? p.fromColumn + : (table == p.toTable ? p.toColumn : null); + return (highlighted: on, faded: !on, column: column); + } + final highlighted = ErdLayoutEngine.isFocusedIn(neighbours, focus, table); + return ( + highlighted: highlighted, + faded: selected != null && !highlighted, + column: null, + ); + } +} + +/// Which step an Esc press took. +enum ErdEscape { + closedSearch, + droppedRelation, + clearedMarks, + clearedPick, + nothing, } From 9b83a3ab63158844ea07115b89f5ceb5f0b1b347 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 15:53:18 +0300 Subject: [PATCH 3/4] refactor(erd): the toolbar is a widget; controller tests (#1690) --- lib/features/erd/erd_view.dart | 128 ++------ lib/features/erd/widgets/erd_toolbar.dart | 134 +++++++++ .../erd/erd_view_controller_test.dart | 277 ++++++++++++++++++ 3 files changed, 435 insertions(+), 104 deletions(-) create mode 100644 lib/features/erd/widgets/erd_toolbar.dart create mode 100644 test/features/erd/erd_view_controller_test.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index a7b6a1d7..934e7a9c 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -35,14 +35,11 @@ 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_toolbar.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/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; -import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; -import 'package:querya_desktop/shared/widgets/querya_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' hide TextExtension; // Moved out of this file (#1362); re-exported for existing importers. @@ -1512,108 +1509,31 @@ class _ErdViewState extends material.State return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Container( - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - padding: const material.EdgeInsets.all(8), - child: material.LayoutBuilder( - builder: (context, constraints) { - // Below ~1100 px the labels of Note, Auto layout and the hidden - // tables button give way to icons (the tooltips keep the names) - // so the toolbar stays on one row. - final compact = constraints.maxWidth < 1100; - return QueryaControlScope( - size: QueryaControlSize.md, - child: material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - QueryaButton.secondary( - key: const material.ValueKey('erd_refresh'), - label: 'Refresh', - icon: material.Icons.refresh_rounded, - onPressed: _loading ? null : _load, - ), - // Detail level for every card; a card collapsed by hand stays - // collapsed (#1278). - material.IgnorePointer( - ignoring: !ready, - child: material.Opacity( - opacity: ready ? 1 : 0.5, - child: material.Tooltip( - message: 'Table names only, key columns, or every column', - child: QueryaTabStrip( - key: const material.ValueKey('erd_detail'), - labels: const ['Names', 'Keys', 'All'], - selectedIndex: ErdDetail.values.indexOf(_c.detail), - onSelected: (i) { - if (ErdDetail.values[i] == _c.detail) return; - setState(() => _c.detail = ErdDetail.values[i]); - _reflow(); - }, - ), - ), - ), - ), - // Also with every table hidden: a view may be that empty, and - // the way out is another view. - if (_persists && + ErdToolbar( + ready: ready, + loading: _loading, + detail: _c.detail, + markedCount: _c.marked.length, + hiddenCount: _c.hidden.length, + viewsMenu: _persists && !_loading && _error == null && - (_schema?.tables.isNotEmpty ?? false)) - _viewsMenu(), - QueryaButton.secondary( - key: const material.ValueKey('erd_add_note'), - compact: compact, - label: 'Note', - icon: material.Icons.sticky_note_2_outlined, - tooltip: 'Add a sticky note to the diagram', - onPressed: ready ? () => unawaited(_addNote()) : null, - ), - if (_c.marked.isNotEmpty) - QueryaButton.secondary( - key: const material.ValueKey('erd_group_marked'), - compact: compact, - label: 'Group ${_c.marked.length} ' - '${_c.marked.length == 1 ? 'table' : 'tables'}', - icon: material.Icons.folder_open_rounded, - tooltip: 'Shift / Ctrl + click marks more tables', - onPressed: () => unawaited(_newGroup({..._c.marked})), - ), - if (_c.hidden.isNotEmpty) - QueryaButton.secondary( - key: const material.ValueKey('erd_show_all'), - compact: compact, - label: 'Show ${_c.hidden.length} hidden', - icon: material.Icons.visibility_outlined, - onPressed: _showAllTables, - ), - QueryaButton.secondary( - key: const material.ValueKey('erd_auto_layout'), - compact: compact, - label: 'Auto layout', - icon: material.Icons.auto_fix_high_rounded, - tooltip: 'Arrange the tables again (undoes manual moves)', - onPressed: ready ? _autoLayout : null, - ), - material.IgnorePointer( - ignoring: !ready, - child: material.Opacity( - opacity: ready ? 1 : 0.5, - child: ErdExportMenu( - onSelected: (action) { - if (schema != null && layout != null) { - _export(action, schema, layout); - } - }, - ), - ), - ), - ], - ), - ); - }, - ), + (_schema?.tables.isNotEmpty ?? false) + ? _viewsMenu() + : null, + onRefresh: _load, + onDetail: (d) { + if (d == _c.detail) return; + setState(() => _c.detail = d); + _reflow(); + }, + onAddNote: () => unawaited(_addNote()), + onGroupMarked: () => unawaited(_newGroup({..._c.marked})), + onShowHidden: _showAllTables, + onAutoLayout: _autoLayout, + onExport: (action) { + if (schema != null && layout != null) _export(action, schema, layout); + }, ), material.Expanded(child: material.ClipRect(child: body)), ], diff --git a/lib/features/erd/widgets/erd_toolbar.dart b/lib/features/erd/widgets/erd_toolbar.dart new file mode 100644 index 00000000..027da332 --- /dev/null +++ b/lib/features/erd/widgets/erd_toolbar.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; +import 'package:querya_desktop/shared/widgets/querya_button.dart'; +import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; + +/// The row above the diagram: refresh, detail level, views, notes, groups, +/// hidden tables, auto layout and export. +/// +/// [ready] is whether there is a diagram to act on; the buttons that need one +/// are off without it. [viewsMenu] is null where views do not apply. +class ErdToolbar extends material.StatelessWidget { + const ErdToolbar({ + super.key, + required this.ready, + required this.loading, + required this.detail, + required this.markedCount, + required this.hiddenCount, + required this.viewsMenu, + required this.onRefresh, + required this.onDetail, + required this.onAddNote, + required this.onGroupMarked, + required this.onShowHidden, + required this.onAutoLayout, + required this.onExport, + }); + + final bool ready; + final bool loading; + final ErdDetail detail; + final int markedCount; + final int hiddenCount; + final material.Widget? viewsMenu; + final material.VoidCallback onRefresh; + final void Function(ErdDetail detail) onDetail; + final material.VoidCallback onAddNote; + final material.VoidCallback onGroupMarked; + final material.VoidCallback onShowHidden; + final material.VoidCallback onAutoLayout; + final void Function(ErdExportAction action) onExport; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + decoration: SqlEditorChrome.sqlToolbarDecoration(context), + padding: const material.EdgeInsets.all(8), + child: material.LayoutBuilder( + builder: (context, constraints) { + // Below ~1100 px the labels of Note, Auto layout and the hidden + // tables button give way to icons (the tooltips keep the names) so + // the toolbar stays on one row. + final compact = constraints.maxWidth < 1100; + return QueryaControlScope( + size: QueryaControlSize.md, + child: material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + QueryaButton.secondary( + key: const material.ValueKey('erd_refresh'), + label: 'Refresh', + icon: material.Icons.refresh_rounded, + onPressed: loading ? null : onRefresh, + ), + // Detail level for every card; a card collapsed by hand stays + // collapsed (#1278). + _dimmed( + material.Tooltip( + message: 'Table names only, key columns, or every column', + child: QueryaTabStrip( + key: const material.ValueKey('erd_detail'), + labels: const ['Names', 'Keys', 'All'], + selectedIndex: ErdDetail.values.indexOf(detail), + onSelected: (i) => onDetail(ErdDetail.values[i]), + ), + ), + ), + // Also with every table hidden: a view may be that empty, and + // the way out is another view. + if (viewsMenu case final menu?) menu, + QueryaButton.secondary( + key: const material.ValueKey('erd_add_note'), + compact: compact, + label: 'Note', + icon: material.Icons.sticky_note_2_outlined, + tooltip: 'Add a sticky note to the diagram', + onPressed: ready ? onAddNote : null, + ), + if (markedCount > 0) + QueryaButton.secondary( + key: const material.ValueKey('erd_group_marked'), + compact: compact, + label: 'Group $markedCount ' + '${markedCount == 1 ? 'table' : 'tables'}', + icon: material.Icons.folder_open_rounded, + tooltip: 'Shift / Ctrl + click marks more tables', + onPressed: onGroupMarked, + ), + if (hiddenCount > 0) + QueryaButton.secondary( + key: const material.ValueKey('erd_show_all'), + compact: compact, + label: 'Show $hiddenCount hidden', + icon: material.Icons.visibility_outlined, + onPressed: onShowHidden, + ), + QueryaButton.secondary( + key: const material.ValueKey('erd_auto_layout'), + compact: compact, + label: 'Auto layout', + icon: material.Icons.auto_fix_high_rounded, + tooltip: 'Arrange the tables again (undoes manual moves)', + onPressed: ready ? onAutoLayout : null, + ), + _dimmed(ErdExportMenu(onSelected: onExport)), + ], + ), + ); + }, + ), + ); + } + + /// [child] half transparent and inert while there is no diagram. + material.Widget _dimmed(material.Widget child) => material.IgnorePointer( + ignoring: !ready, + child: material.Opacity(opacity: ready ? 1 : 0.5, child: child), + ); +} diff --git a/test/features/erd/erd_view_controller_test.dart b/test/features/erd/erd_view_controller_test.dart new file mode 100644 index 00000000..d213146b --- /dev/null +++ b/test/features/erd/erd_view_controller_test.dart @@ -0,0 +1,277 @@ + +import 'package:flutter_test/flutter_test.dart'; +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/features/erd/erd_view_controller.dart'; + +/// The diagram's user state and its rules, without a widget tree (#1690). +void main() { + late ErdViewController c; + setUp(() => c = ErdViewController()); + + const names = ['users', 'orders', 'items', 'tags']; + + group('groups', () { + test('a new group takes the tables out of other groups and clears marks', + () { + c.marked.addAll({'users', 'orders'}); + final first = c.createGroup( + tables: {'users', 'orders'}, tableNames: names, name: 'Core'); + expect(c.groupOf('users')?.name, 'Core'); + expect(c.marked, isEmpty); + + final second = c.createGroup( + tables: {'orders', 'items'}, tableNames: names, name: 'Sales'); + expect(second, isNot(first)); + expect(c.groupOf('orders')?.name, 'Sales'); + expect(c.groupOf('users')?.name, 'Core'); + expect(c.groups.last.id, second); + }); + + test('adding to a missing group changes nothing and keeps the marks', () { + c.marked.add('tags'); + c.addToGroup('nope', {'tags'}); + expect(c.groups, isEmpty); + expect(c.marked, {'tags'}); + }); + + test('adding to a group clears the marks; update finds by id', () { + final id = c.createGroup( + tables: {'users'}, tableNames: names, name: 'A'); + c.marked.add('tags'); + c.addToGroup(id, {'tags'}); + expect(c.groupOf('tags')?.id, id); + expect(c.marked, isEmpty); + + expect(c.updateGroup(id, (g) => g.copyWith(name: 'B')), isTrue); + expect(c.groups.single.name, 'B'); + expect(c.updateGroup('nope', (g) => g), isFalse); + }); + + test('a table leaving its only group removes the empty group', () { + final id = c.createGroup( + tables: {'users'}, tableNames: names, name: 'A'); + c.removeFromGroup('users'); + expect(c.groups, isEmpty); + c.createGroup(tables: {'users'}, tableNames: names, name: 'A'); + c.ungroup(c.groups.single.id); + expect(c.groups, isEmpty); + expect(id, isNotEmpty); + }); + + test('allGroups adds one group per schema when there are several', () { + final schema = ErdSchema(tables: [ + for (final n in ['a.users', 'a.orders', 'b.logs']) + ErdTable(name: n, columns: const []), + ], relations: const []); + final all = c.allGroups(schema); + expect(all.length, greaterThan(c.groups.length)); + expect(c.allGroups(null), isEmpty); + }); + }); + + group('marks, density, colours', () { + test('toggleMarked marks and unmarks', () { + c.toggleMarked('users'); + expect(c.marked, {'users'}); + c.toggleMarked('users'); + expect(c.marked, isEmpty); + }); + + test('hiding a table drops its mark and a pick on it', () { + c.marked.addAll({'users', 'tags'}); + c.selected = 'users'; + c.hide('users'); + expect(c.hidden, {'users'}); + expect(c.marked, {'tags'}); + expect(c.selected, isNull); + + c.selected = 'tags'; + c.hide('items'); + expect(c.selected, 'tags'); + }); + + test('collapsing toggles; a header colour is set and reset', () { + c.toggleCollapsed('users'); + expect(c.collapsed, {'users'}); + c.toggleCollapsed('users'); + expect(c.collapsed, isEmpty); + c.setHeaderColor('users', 'type2'); + expect(c.headerColors['users'], 'type2'); + c.setHeaderColor('users', null); + expect(c.headerColors, isEmpty); + }); + + test('applying a saved state replaces density, groups and notes; colours ' + 'only when asked', () { + c.headerColors['users'] = 'type1'; + c.notes.add(const ErdNote(id: 'n9', text: 'old', x: 0, y: 0)); + const saved = ErdSavedLayout( + collapsed: {'orders'}, + hidden: {'tags'}, + detail: ErdDetail.keys, + headerColors: {'orders': 'type3'}, + notes: [ErdNote(id: 'n1', text: 'new', x: 1, y: 2)], + ); + c.applyState(saved, colours: false); + expect(c.collapsed, {'orders'}); + expect(c.hidden, {'tags'}); + expect(c.detail, ErdDetail.keys); + expect(c.notes.single.id, 'n1'); + expect(c.headerColors, {'users': 'type1'}); + + c.applyState(saved, colours: true); + expect(c.headerColors, {'orders': 'type3'}); + }); + }); + + group('notes', () { + test('update, delete, move and resize find the note by id', () { + c.addNote(const ErdNote(id: 'n1', text: 'a', x: 100, y: 100)); + expect(c.updateNote('n1', (n) => n.copyWith(text: 'b')), isTrue); + expect(c.notes.single.text, 'b'); + expect(c.updateNote('zz', (n) => n), isFalse); + + c.moveNote('n1', const Offset(20, 40), 2); // zoom 2: half the canvas move + expect(c.notes.single.x, 110); + expect(c.notes.single.y, 120); + c.resizeNote('n1', const Offset(40, 20), 2); + expect(c.notes.single.width, ErdNote.defaultWidth + 20); + c.moveNote('zz', const Offset(1, 1), 1); + + c.deleteNote('n1'); + expect(c.notes, isEmpty); + }); + + test('a note attached to a table follows it; a loose one stays', () { + final schema = ErdSchema(tables: [ + for (final n in ['a', 'b']) ErdTable(name: n, columns: const []), + ], relations: const []); + final before = ErdLayout.compute(schema); + final after = before.withPosition('a', before.positions['a']! + const Offset(30, 10)); + c.notes.addAll([ + const ErdNote(id: 'n1', text: '', x: 5, y: 5, attachedTo: 'a'), + const ErdNote(id: 'n2', text: '', x: 5, y: 5), + ]); + c.followTables(before, after); + expect(c.notes[0].x, 35); + expect(c.notes[0].y, 15); + expect(c.notes[1].x, 5); + }); + + test('the canvas grows to hold a note placed beyond the cards', () { + const schema = ErdSchema( + tables: [ErdTable(name: 'a', columns: [])], relations: []); + final layout = ErdLayout.compute(schema); + final plain = c.canvasSize(layout); + expect(plain, layout.size); + c.notes.add(const ErdNote(id: 'far', text: '', x: 5000, y: 4000)); + final grown = c.canvasSize(layout); + expect(grown.width, 5000 + ErdNote.defaultWidth + ErdLayout.margin); + expect(grown.height, 4000 + ErdNote.defaultHeight + ErdLayout.margin); + }); + }); + + group('saved views', () { + ErdSavedView view(String id, String name) => ErdSavedView( + id: id, name: name, tables: const {}, layout: const ErdSavedLayout()); + + test('ids count up and fill a gap', () { + expect(c.nextViewId(), 'v1'); + c.views.addAll([view('v1', 'A'), view('v3', 'C')]); + expect(c.nextViewId(), 'v2'); + }); + + test('rename and remove by id', () { + c.views.add(view('v1', 'A')); + expect(c.renameView('v1', 'Z'), isTrue); + expect(c.viewById('v1')?.name, 'Z'); + expect(c.renameView('v9', 'Q'), isFalse); + expect(c.viewById(null), isNull); + c.removeView('v1'); + expect(c.views, isEmpty); + }); + }); + + group('picks and Esc', () { + test('picking a table drops a picked relation and the other way round', () { + const r = ErdRelation( + fromTable: 'orders', + fromColumn: 'user_id', + toTable: 'users', + toColumn: 'id'); + c.pickRelation(r); + expect(c.pickedRelation, r); + c.pick('users'); + expect(c.selected, 'users'); + expect(c.pickedRelation, isNull); + c.pickRelation(r); + expect(c.selected, isNull); + }); + + test('clearPicks says whether there was anything to clear', () { + expect(c.clearPicks(), isFalse); + c.marked.add('users'); + expect(c.clearPicks(), isTrue); + expect(c.marked, isEmpty); + }); + + test('Esc takes one step per press, in order', () { + const r = ErdRelation( + fromTable: 'a', fromColumn: 'x', toTable: 'b', toColumn: 'y'); + c + ..searchOpen = true + ..query = 'ord' + ..pickedRelation = r + ..marked.add('a') + ..selected = 'b'; + expect(c.escape(), ErdEscape.closedSearch); + expect(c.query, isEmpty); + expect(c.selected, 'b', reason: 'closing the search keeps the pick'); + expect(c.escape(), ErdEscape.droppedRelation); + expect(c.escape(), ErdEscape.clearedMarks); + expect(c.escape(), ErdEscape.clearedPick); + expect(c.escape(), ErdEscape.nothing); + }); + }); + + group('focus', () { + test('the dragged table beats the picked one, then the hovered one', () { + expect(c.currentFocus('h'), 'h'); + c.selected = 's'; + expect(c.currentFocus('h'), 's'); + c.dragging = 'd'; + expect(c.currentFocus('h'), 'd'); + }); + + test('a pick fades the cards that are not next to it', () { + c.neighbours = { + 'users': {'orders'}, + 'orders': {'users'}, + 'tags': {}, + }; + expect(c.cardFocusFor('tags', null).faded, isFalse, + reason: 'nothing is picked'); + c.selected = 'users'; + expect(c.cardFocusFor('orders', 'users').highlighted, isTrue); + expect(c.cardFocusFor('tags', 'users').faded, isTrue); + }); + + test('a picked relation lights its two tables and names their columns', + () { + c.pickedRelation = const ErdRelation( + fromTable: 'orders', + fromColumn: 'user_id', + toTable: 'users', + toColumn: 'id'); + final from = c.cardFocusFor('orders', null); + expect(from.highlighted, isTrue); + expect(from.column, 'user_id'); + expect(c.cardFocusFor('users', null).column, 'id'); + final other = c.cardFocusFor('tags', null); + expect(other.faded, isTrue); + expect(other.column, isNull); + }); + }); +} From 25fca199de1034e558c8b4e7d2a137e83e58c1fa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 15:54:41 +0300 Subject: [PATCH 4/4] refactor(erd): group menu, card inputs and the card cache move out of the view (#1690) --- lib/features/erd/erd_view.dart | 172 +++--------------- lib/features/erd/widgets/erd_card_cache.dart | 66 +++++++ lib/features/erd/widgets/erd_card_inputs.dart | 67 +++++++ lib/features/erd/widgets/erd_group_menu.dart | 50 +++++ 4 files changed, 207 insertions(+), 148 deletions(-) create mode 100644 lib/features/erd/widgets/erd_card_cache.dart create mode 100644 lib/features/erd/widgets/erd_card_inputs.dart create mode 100644 lib/features/erd/widgets/erd_group_menu.dart diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 934e7a9c..25562d69 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -27,6 +27,8 @@ import 'package:querya_desktop/core/erd/erd_model.dart'; import 'package:querya_desktop/core/erd/erd_note_ops.dart'; import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_card_cache.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_card_inputs.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'; @@ -34,6 +36,7 @@ import 'package:querya_desktop/features/erd/widgets/erd_note_card.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_group_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_table_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_toolbar.dart'; import 'package:querya_desktop/features/erd/widgets/erd_views_menu.dart'; @@ -272,9 +275,7 @@ class _ErdViewState extends material.State _c.marked.clear(); _setLayout(ErdLayoutEngine.withSaved(_compute(_visibleOf(schema)), saved)); _loading = false; - _cardCache = {}; - _mountedTables = - schema.tables.length > _progressiveFrom ? _mountChunk : 1 << 30; + _cardCache.reset(schema.tables.length); }); if (schema.truncated) { showAppToast( @@ -1017,46 +1018,6 @@ class _ErdViewState extends material.State 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. - List _groupMenu(String table) { - final tables = {..._c.marked, table}; - final current = _c.groupOf(table); - return [ - MenuButton( - key: material.ValueKey('erd_menu_group_$table'), - onPressed: (_) => unawaited(_newGroup(tables)), - // The way to put more tables in is not obvious: say it here. - trailing: tables.length == 1 - ? Text('Shift+click marks more', - style: material.TextStyle( - fontSize: 11, color: context.workbench.mutedForeground)) - : null, - child: Text(tables.length == 1 - ? 'New group…' - : 'Group ${tables.length} tables…'), - ), - if (_c.groups.any((g) => g.id != current?.id)) - MenuButton( - subMenu: [ - for (final g in _c.groups) - if (g.id != current?.id) - MenuButton( - onPressed: (_) => _addToGroup(g.id, tables), - child: Text(g.name), - ), - ], - child: const Text('Add to group'), - ), - if (current != null) - MenuButton( - key: material.ValueKey('erd_menu_ungroup_$table'), - onPressed: (_) => _removeFromGroup(table), - child: Text('Remove from ${current.name}'), - ), - ]; - } - /// Notes for the SVG, tinted as on screen over the export background. List _svgNotes(ErdSvgColors colors) => ErdExportData.notes( _c.notes, @@ -1239,21 +1200,12 @@ class _ErdViewState extends material.State ); } - /// Cards built so far for the open diagram: a big one mounts a chunk per - /// frame (see [_mountedTables]) instead of in one long frame. - static const _progressiveFrom = 60; - static const _mountChunk = 24; - int _mountedTables = 1 << 30; - bool _mountPending = false; - - /// Cards by table, with what they were built from. A rebuild of the diagram - /// (a pick, a drag, a toolbar change) reuses every card whose inputs did not - /// change, so Flutter skips its subtree instead of rebuilding 200 of them. - var _cardCache = {}; + late final _cardCache = ErdCardCache( + isMounted: () => mounted, + rebuild: () => setState(() {}), + ); List _cards(ErdSchema schema, ErdLayout layout) { - final tables = schema.tables; - final shown = min(tables.length, _mountedTables); final menuKey = Object.hash( Object.hashAll(_c.marked), Object.hashAll([ @@ -1265,11 +1217,9 @@ class _ErdViewState extends material.State // cards and the menu use. final p = context.semanticPalette; final palette = (p.type1, p.type2, p.type3, p.type4, p.type5); - final next = {}; - final cards = []; - for (var i = 0; i < shown; i++) { - final t = tables[i]; - final inputs = _CardInputs( + return _cardCache.cards( + tables: schema.tables, + inputsFor: (t) => ErdCardInputs( columns: t.columns, comment: t.comment, position: layout.positions[t.name]!, @@ -1284,28 +1234,9 @@ class _ErdViewState extends material.State onOpenTable: widget.onOpenTable, onOpenInSql: widget.onOpenInSql, onShowRelations: widget.onShowRelations, - ); - final cached = _cardCache[t.name]; - final card = cached != null && cached.$1 == inputs - ? cached.$2 - : _card(t, layout); - next[t.name] = (inputs, card); - cards.add(card); - } - _cardCache = next; - if (shown < tables.length) _mountMore(); - return cards; - } - - /// Mounts the next chunk of cards after this frame. - void _mountMore() { - if (_mountPending) return; - _mountPending = true; - material.WidgetsBinding.instance.addPostFrameCallback((_) { - _mountPending = false; - if (!mounted) return; - setState(() => _mountedTables += _mountChunk); - }); + ), + build: (t) => _card(t, layout), + ); } material.Widget _card(ErdTable t, ErdLayout layout) { @@ -1329,7 +1260,16 @@ class _ErdViewState extends material.State onToggleCollapsed: () => _toggleCollapsed(t.name), onHide: () => _hide(t.name), - groupItems: _groupMenu(t.name), + groupItems: erdGroupMenu( + table: t.name, + marked: _c.marked, + groups: _c.groups, + current: _c.groupOf(t.name), + mutedColor: context.workbench.mutedForeground, + onNewGroup: (tables) => unawaited(_newGroup(tables)), + onAddToGroup: _addToGroup, + onRemoveFromGroup: _removeFromGroup, + ), slotColor: _slotColor, onHeaderColor: (slot) => _setHeaderColor(t.name, slot), @@ -1540,67 +1480,3 @@ class _ErdViewState extends material.State ); } } - -/// What a table card is built from outside its own focus: when all of it is -/// equal to the last build's, the card is reused as it is. -@immutable -class _CardInputs { - const _CardInputs({ - required this.columns, - required this.comment, - required this.position, - required this.width, - required this.headerColor, - required this.collapsed, - required this.marked, - required this.dragging, - required this.junctionOf, - required this.menuKey, - required this.palette, - required this.onOpenTable, - required this.onOpenInSql, - required this.onShowRelations, - }); - - final List columns; - final String? comment; - final material.Offset position; - final double width; - final material.Color headerColor; - final bool collapsed; - final bool marked; - final bool dragging; - final String? junctionOf; - - /// Hash of what the card's menu lists: the marked tables and the groups. - final int menuKey; - final Object palette; - final Object? onOpenTable; - final Object? onOpenInSql; - final Object? onShowRelations; - - @override - bool operator ==(Object other) { - if (other is! _CardInputs) return false; - if (columns.length != other.columns.length) return false; - for (var i = 0; i < columns.length; i++) { - if (!identical(columns[i], other.columns[i])) return false; - } - return comment == other.comment && - position == other.position && - width == other.width && - headerColor == other.headerColor && - collapsed == other.collapsed && - marked == other.marked && - dragging == other.dragging && - junctionOf == other.junctionOf && - menuKey == other.menuKey && - palette == other.palette && - onOpenTable == other.onOpenTable && - onOpenInSql == other.onOpenInSql && - onShowRelations == other.onShowRelations; - } - - @override - int get hashCode => Object.hash(columns.length, position, width, menuKey); -} diff --git a/lib/features/erd/widgets/erd_card_cache.dart b/lib/features/erd/widgets/erd_card_cache.dart new file mode 100644 index 00000000..804e6bea --- /dev/null +++ b/lib/features/erd/widgets/erd_card_cache.dart @@ -0,0 +1,66 @@ +import 'dart:math' show min; + +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_card_inputs.dart'; + +/// The table cards of an open diagram, built only when something they show +/// changed, and a chunk per frame while a big diagram opens (#1546). +/// +/// A rebuild of the diagram (a pick, a drag, a toolbar change) hands back the +/// same widget for every card whose [ErdCardInputs] are equal to the last +/// build's, so Flutter skips its subtree instead of rebuilding 200 cards. +class ErdCardCache { + ErdCardCache({required this.isMounted, required this.rebuild}); + + /// Above this many tables the first frame carries [mountChunk] of them and + /// each following frame mounts [mountChunk] more. + static const progressiveFrom = 60; + static const mountChunk = 24; + + final bool Function() isMounted; + final VoidCallback rebuild; + + var _byTable = {}; + int _mounted = 1 << 30; + bool _pending = false; + + /// A diagram of [tableCount] tables was loaded: start over. + void reset(int tableCount) { + _byTable = {}; + _mounted = tableCount > progressiveFrom ? mountChunk : 1 << 30; + } + + /// The cards of [tables] that are mounted so far, in order. + List cards({ + required List tables, + required ErdCardInputs Function(ErdTable table) inputsFor, + required Widget Function(ErdTable table) build, + }) { + final shown = min(tables.length, _mounted); + final next = {}; + final cards = []; + for (var i = 0; i < shown; i++) { + final t = tables[i]; + final inputs = inputsFor(t); + final cached = _byTable[t.name]; + final card = cached != null && cached.$1 == inputs ? cached.$2 : build(t); + next[t.name] = (inputs, card); + cards.add(card); + } + _byTable = next; + if (shown < tables.length) _mountMore(); + return cards; + } + + void _mountMore() { + if (_pending) return; + _pending = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _pending = false; + if (!isMounted()) return; + _mounted += mountChunk; + rebuild(); + }); + } +} diff --git a/lib/features/erd/widgets/erd_card_inputs.dart b/lib/features/erd/widgets/erd_card_inputs.dart new file mode 100644 index 00000000..b56d728d --- /dev/null +++ b/lib/features/erd/widgets/erd_card_inputs.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_model.dart'; + +/// What a table card is built from outside its own focus: when all of it is +/// equal to the last build's, the card is reused as it is. +@immutable +class ErdCardInputs { + const ErdCardInputs({ + required this.columns, + required this.comment, + required this.position, + required this.width, + required this.headerColor, + required this.collapsed, + required this.marked, + required this.dragging, + required this.junctionOf, + required this.menuKey, + required this.palette, + required this.onOpenTable, + required this.onOpenInSql, + required this.onShowRelations, + }); + + final List columns; + final String? comment; + final material.Offset position; + final double width; + final material.Color headerColor; + final bool collapsed; + final bool marked; + final bool dragging; + final String? junctionOf; + + /// Hash of what the card's menu lists: the marked tables and the groups. + final int menuKey; + final Object palette; + final Object? onOpenTable; + final Object? onOpenInSql; + final Object? onShowRelations; + + @override + bool operator ==(Object other) { + if (other is! ErdCardInputs) return false; + if (columns.length != other.columns.length) return false; + for (var i = 0; i < columns.length; i++) { + if (!identical(columns[i], other.columns[i])) return false; + } + return comment == other.comment && + position == other.position && + width == other.width && + headerColor == other.headerColor && + collapsed == other.collapsed && + marked == other.marked && + dragging == other.dragging && + junctionOf == other.junctionOf && + menuKey == other.menuKey && + palette == other.palette && + onOpenTable == other.onOpenTable && + onOpenInSql == other.onOpenInSql && + onShowRelations == other.onShowRelations; + } + + @override + int get hashCode => Object.hash(columns.length, position, width, menuKey); +} diff --git a/lib/features/erd/widgets/erd_group_menu.dart b/lib/features/erd/widgets/erd_group_menu.dart new file mode 100644 index 00000000..a8c718d7 --- /dev/null +++ b/lib/features/erd/widgets/erd_group_menu.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; + +/// Group entries of a card's menu: group it (with the marked tables), add it +/// to a group, take it out of its group. +List erdGroupMenu({ + required String table, + required Set marked, + required List groups, + required ErdGroup? current, + required material.Color mutedColor, + required void Function(Set tables) onNewGroup, + required void Function(String id, Set tables) onAddToGroup, + required void Function(String table) onRemoveFromGroup, +}) { + final tables = {...marked, table}; + return [ + MenuButton( + key: material.ValueKey('erd_menu_group_$table'), + onPressed: (_) => onNewGroup(tables), + // The way to put more tables in is not obvious: say it here. + trailing: tables.length == 1 + ? Text('Shift+click marks more', + style: material.TextStyle(fontSize: 11, color: mutedColor)) + : null, + child: Text(tables.length == 1 + ? 'New group…' + : 'Group ${tables.length} tables…'), + ), + if (groups.any((g) => g.id != current?.id)) + MenuButton( + subMenu: [ + for (final g in groups) + if (g.id != current?.id) + MenuButton( + onPressed: (_) => onAddToGroup(g.id, tables), + child: Text(g.name), + ), + ], + child: const Text('Add to group'), + ), + if (current != null) + MenuButton( + key: material.ValueKey('erd_menu_ungroup_$table'), + onPressed: (_) => onRemoveFromGroup(table), + child: Text('Remove from ${current.name}'), + ), + ]; +}