From b65bec42f74b399d1ae39b66314538cd5a0ebe47 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:59:52 +0300 Subject: [PATCH 1/2] perf(erd): hover rebuilds only the cards it changes (#1169) The card stack listened to the hover notifier as a whole, so every move over a card rebuilt all cards and the neighbour map. Each card now has its own focus notifier, and the edges have one; a single sync moves only the notifiers whose highlight or fade changed. The neighbour map is built once per load. A widget test counts card builds: hovering one table rebuilds one card. --- lib/features/erd/erd_view.dart | 116 +++++++++++++++++++++++++------- test/features/erd/erd_test.dart | 44 ++++++++++++ 2 files changed, 134 insertions(+), 26 deletions(-) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 8881a665..9fe21582 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart'; @@ -50,6 +51,15 @@ Future defaultErdFileSaver(String name, Uint8List bytes) async { /// Entries of the diagram's Export menu. enum _ExportAction { mermaid, svg, png, copyMermaid, toggleTheme } +/// A card's focus: related to the focused table, and faded while another +/// table is picked and this one is not related to it. +typedef _CardFocus = ({bool highlighted, bool faded}); + +/// Card builds so far. A test seam: hovering one card must not rebuild the +/// others. +@visibleForTesting +int erdCardBuilds = 0; + class ErdView extends material.StatefulWidget { const ErdView({ super.key, @@ -109,6 +119,13 @@ class _ErdViewState extends material.State { String? _selected; bool _routeScheduled = false; + /// The focus the edges draw. Every card has its own notifier in + /// [_cardStates]; [_syncFocus] moves only the ones whose focus changed, so a + /// hover rebuilds the cards it touches and not the whole canvas. + final _focus = material.ValueNotifier(null); + final _cardStates = >{}; + 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]. @@ -141,6 +158,10 @@ class _ErdViewState extends material.State { void dispose() { _transform.dispose(); _hovered.dispose(); + _focus.dispose(); + for (final card in _cardStates.values) { + card.dispose(); + } _edgeTipNotifier.dispose(); _searchController.dispose(); _searchFocus.dispose(); @@ -152,6 +173,7 @@ class _ErdViewState extends material.State { void initState() { super.initState(); _searchFocus.addListener(_onSearchFocus); + _hovered.addListener(_syncFocus); _load(); } @@ -187,6 +209,7 @@ class _ErdViewState extends material.State { if (!mounted) return; setState(() { _schema = schema; + _neighbours = _neighbourMap(schema); _setLayout(ErdLayout.compute(_visibleOf(schema))); _loading = false; }); @@ -281,6 +304,7 @@ class _ErdViewState extends material.State { // A pick on a hidden table would leave every card faded. if (_selected == table) _selected = null; }); + _syncFocus(); _reflow(); } @@ -289,7 +313,10 @@ class _ErdViewState extends material.State { _reflow(); } - void _dragStart(String table) => setState(() => _dragging = table); + void _dragStart(String table) { + setState(() => _dragging = table); + _syncFocus(); + } /// [screenDelta] is in screen pixels; the canvas may be zoomed. void _dragMove(String table, material.Offset screenDelta) { @@ -320,6 +347,7 @@ class _ErdViewState extends material.State { void _dragEnd() { if (_dragging == null) return; setState(() => _dragging = null); + _syncFocus(); } Future _save(String name, Uint8List bytes) => @@ -390,6 +418,7 @@ class _ErdViewState extends material.State { _transform.value = Matrix4.identity()..translate(t.dx, t.dy)..scale(s); } setState(() => _selected = name); + _syncFocus(); } /// A click on the empty canvas: clears the pick and takes the keyboard, so @@ -398,12 +427,14 @@ class _ErdViewState extends material.State { _canvasFocus.requestFocus(); if (_selected == null) return; setState(() => _selected = null); + _syncFocus(); } /// A click on a card picks it. void _pick(String name) { _canvasFocus.requestFocus(); setState(() => _selected = name); + _syncFocus(); } void _openSearch() { @@ -424,7 +455,10 @@ class _ErdViewState extends material.State { _canvasFocus.requestFocus(); return; } - if (_selected != null) setState(() => _selected = null); + if (_selected != null) { + setState(() => _selected = null); + _syncFocus(); + } } List _matches(ErdSchema schema) { @@ -478,6 +512,33 @@ class _ErdViewState extends material.State { return focus == table || (neighbours[focus]?.contains(table) ?? false); } + /// The table the diagram focuses: the dragged one, then the picked one, then + /// the one under the mouse. + String? _currentFocus() => _dragging ?? _selected ?? _hovered.value; + + _CardFocus _cardFocusFor(String table, String? focus) { + final highlighted = _isFocusedIn(_neighbours, focus, table); + return (highlighted: highlighted, faded: _selected != null && !highlighted); + } + + /// The notifier of card [table], made on first use with its current focus. + material.ValueNotifier<_CardFocus> _cardState(String table) => + _cardStates.putIfAbsent( + table, + () => material.ValueNotifier(_cardFocusFor(table, _currentFocus())), + ); + + /// 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]. + void _syncFocus() { + final focus = _currentFocus(); + _focus.value = focus; + for (final entry in _cardStates.entries) { + entry.value.value = _cardFocusFor(entry.key, focus); + } + } + /// PNG of the diagram. With the light palette it is drawn from the same SVG /// the SVG export saves, so the two match. With the current theme it is a /// screenshot of the canvas: a picked table, the hover highlight and the edge @@ -500,7 +561,10 @@ class _ErdViewState extends material.State { _edgeTipNotifier.value != null; _hovered.value = null; _edgeTipNotifier.value = null; - if (picked != null) setState(() => _selected = null); + if (picked != null) { + setState(() => _selected = null); + _syncFocus(); + } try { if (needsFrame) await material.WidgetsBinding.instance.endOfFrame; if (!mounted) return; @@ -530,6 +594,7 @@ class _ErdViewState extends material.State { } finally { if (mounted && picked != null && _selected == null) { setState(() => _selected = picked); + _syncFocus(); } } } @@ -789,12 +854,7 @@ class _ErdViewState extends material.State { // Empty canvas: a tap clears the selection. behavior: material.HitTestBehavior.translucent, onTap: _clearSelection, - child: material.ValueListenableBuilder( - valueListenable: _hovered, - builder: (context, hovered, _) { - final focus = _dragging ?? _selected ?? hovered; - final neighbours = _neighbourMap(schema); - return material.Stack( + child: material.Stack( children: [ // Lowest layer: sees the pointer wherever no card is, // so edges can name themselves on hover. @@ -807,12 +867,16 @@ class _ErdViewState extends material.State { ), ), material.Positioned.fill( - child: material.CustomPaint( - painter: _RelationPainter( - routes: _routes, - color: wb.mutedForeground, - highlight: wb.accent, - focus: focus, + child: material.ValueListenableBuilder( + valueListenable: _focus, + builder: (context, focus, _) => + material.CustomPaint( + painter: _RelationPainter( + routes: _routes, + color: wb.mutedForeground, + highlight: wb.accent, + focus: focus, + ), ), ), ), @@ -820,13 +884,14 @@ class _ErdViewState extends material.State { material.Positioned( left: layout.positions[t.name]!.dx, top: layout.positions[t.name]!.dy, - child: material.Opacity( - // Unrelated cards fade while a table is picked. - opacity: _selected != null && - !_isFocusedIn(neighbours, focus, t.name) - ? 0.35 - : 1, - child: ContextMenu( + // Each card listens to its own focus, so a hover + // rebuilds only the cards it changes. + child: material.ValueListenableBuilder<_CardFocus>( + valueListenable: _cardState(t.name), + builder: (context, focus, _) => material.Opacity( + // Unrelated cards fade while a table is picked. + opacity: focus.faded ? 0.35 : 1, + child: ContextMenu( items: [ if (widget.onOpenTable case final open?) MenuButton( @@ -870,8 +935,7 @@ class _ErdViewState extends material.State { ], child: _TableCard( table: t, - highlighted: - _isFocusedIn(neighbours, focus, t.name), + highlighted: focus.highlighted, dragging: _dragging == t.name, onOpen: widget.onOpenTable == null ? null @@ -887,11 +951,10 @@ class _ErdViewState extends material.State { onDragEnd: _dragEnd, ), ), + ), ), ), ], - ); - }, ), ), ), @@ -1088,6 +1151,7 @@ class _TableCard extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { + erdCardBuilds++; final wb = context.workbench; final palette = context.semanticPalette; final radius = material.BorderRadius.circular(8); diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 3752c10f..3030a9bc 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -757,6 +757,50 @@ void main() { closeTo(height, 0.5)); }); + testWidgets('hovering a card rebuilds only the cards it changes', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final wide = FakeSqlExecutionDelegate(onExecute: (sql) { + if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult(rows: [ + for (var i = 0; i < 20; i++) ['t$i', 'id', 'INTEGER', '1'], + ['child', 'id', 'INTEGER', '1'], + ['child', 't0_id', 'INTEGER', '0'], + ]); + } + return const SqlExecutionResult(rows: [ + ['child', 't0_id', 't0', 'id'], + ]); + }); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: wide, dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + await t.pump(); + + final mouse = await t.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: material.Offset.zero); + + // The first hover changes one card's focus: only that card rebuilds. + erdCardBuilds = 0; + await mouse.moveTo( + t.getCenter(find.byKey(const material.ValueKey('erd_table_t5')))); + await t.pump(); + expect(erdCardBuilds, 1); + + // Moving to child: t5 loses the highlight, child and t0 gain it. The + // other twenty cards stay as they are. + erdCardBuilds = 0; + await mouse.moveTo( + t.getCenter(find.byKey(const material.ValueKey('erd_table_child')))); + await t.pump(); + expect(erdCardBuilds, greaterThan(0)); + expect(erdCardBuilds, lessThanOrEqualTo(3)); + }); + testWidgets('Copy Mermaid puts the diagram on the clipboard', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800)); String? copied; From 8c56338926299db4deb8f6f11111b52422625d10 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 15:02:41 +0300 Subject: [PATCH 2/2] fix(erd): drop the foundation import that shadcn_flutter already provides (#1169) --- lib/features/erd/erd_view.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 9fe21582..f29ce52a 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -5,7 +5,6 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:file_selector/file_selector.dart'; -import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart';