Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 89 additions & 26 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ Future<void> 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,
Expand Down Expand Up @@ -109,6 +118,13 @@ class _ErdViewState extends material.State<ErdView> {
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<String?>(null);
final _cardStates = <String, material.ValueNotifier<_CardFocus>>{};
Map<String, Set<String>> _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].
Expand Down Expand Up @@ -141,6 +157,10 @@ class _ErdViewState extends material.State<ErdView> {
void dispose() {
_transform.dispose();
_hovered.dispose();
_focus.dispose();
for (final card in _cardStates.values) {
card.dispose();
}
_edgeTipNotifier.dispose();
_searchController.dispose();
_searchFocus.dispose();
Expand All @@ -152,6 +172,7 @@ class _ErdViewState extends material.State<ErdView> {
void initState() {
super.initState();
_searchFocus.addListener(_onSearchFocus);
_hovered.addListener(_syncFocus);
_load();
}

Expand Down Expand Up @@ -187,6 +208,7 @@ class _ErdViewState extends material.State<ErdView> {
if (!mounted) return;
setState(() {
_schema = schema;
_neighbours = _neighbourMap(schema);
_setLayout(ErdLayout.compute(_visibleOf(schema)));
_loading = false;
});
Expand Down Expand Up @@ -281,6 +303,7 @@ class _ErdViewState extends material.State<ErdView> {
// A pick on a hidden table would leave every card faded.
if (_selected == table) _selected = null;
});
_syncFocus();
_reflow();
}

Expand All @@ -289,7 +312,10 @@ class _ErdViewState extends material.State<ErdView> {
_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) {
Expand Down Expand Up @@ -320,6 +346,7 @@ class _ErdViewState extends material.State<ErdView> {
void _dragEnd() {
if (_dragging == null) return;
setState(() => _dragging = null);
_syncFocus();
}

Future<void> _save(String name, Uint8List bytes) =>
Expand Down Expand Up @@ -390,6 +417,7 @@ class _ErdViewState extends material.State<ErdView> {
_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
Expand All @@ -398,12 +426,14 @@ class _ErdViewState extends material.State<ErdView> {
_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() {
Expand All @@ -424,7 +454,10 @@ class _ErdViewState extends material.State<ErdView> {
_canvasFocus.requestFocus();
return;
}
if (_selected != null) setState(() => _selected = null);
if (_selected != null) {
setState(() => _selected = null);
_syncFocus();
}
}

List<ErdTable> _matches(ErdSchema schema) {
Expand Down Expand Up @@ -478,6 +511,33 @@ class _ErdViewState extends material.State<ErdView> {
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
Expand All @@ -500,7 +560,10 @@ class _ErdViewState extends material.State<ErdView> {
_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;
Expand Down Expand Up @@ -530,6 +593,7 @@ class _ErdViewState extends material.State<ErdView> {
} finally {
if (mounted && picked != null && _selected == null) {
setState(() => _selected = picked);
_syncFocus();
}
}
}
Expand Down Expand Up @@ -789,12 +853,7 @@ class _ErdViewState extends material.State<ErdView> {
// Empty canvas: a tap clears the selection.
behavior: material.HitTestBehavior.translucent,
onTap: _clearSelection,
child: material.ValueListenableBuilder<String?>(
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.
Expand All @@ -807,26 +866,31 @@ class _ErdViewState extends material.State<ErdView> {
),
),
material.Positioned.fill(
child: material.CustomPaint(
painter: _RelationPainter(
routes: _routes,
color: wb.mutedForeground,
highlight: wb.accent,
focus: focus,
child: material.ValueListenableBuilder<String?>(
valueListenable: _focus,
builder: (context, focus, _) =>
material.CustomPaint(
painter: _RelationPainter(
routes: _routes,
color: wb.mutedForeground,
highlight: wb.accent,
focus: focus,
),
),
),
),
for (final t in schema.tables)
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(
Expand Down Expand Up @@ -870,8 +934,7 @@ class _ErdViewState extends material.State<ErdView> {
],
child: _TableCard(
table: t,
highlighted:
_isFocusedIn(neighbours, focus, t.name),
highlighted: focus.highlighted,
dragging: _dragging == t.name,
onOpen: widget.onOpenTable == null
? null
Expand All @@ -887,11 +950,10 @@ class _ErdViewState extends material.State<ErdView> {
onDragEnd: _dragEnd,
),
),
),
),
),
],
);
},
),
),
),
Expand Down Expand Up @@ -1088,6 +1150,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);
Expand Down
44 changes: 44 additions & 0 deletions test/features/erd/erd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading