diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6bde0..c3757ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 2026-11-03 +- Schema and Linked view cards no longer end up on top of each other after expanding, resizing, dragging or loading a card (2026-11-03) - New users now get a welcome dialog on first launch (web and desktop) explaining how to connect and what to try; reopen it from Help ▸ Welcome… (2026-11-03) - File ▸ Show crash log opens the folder with dbscope.log for bug reports (desktop) (2026-11-03) - dbscope_pg_proxy can be pinned to one database with --allow-db-host, and caps each result at --max-rows rows (default 100000, flagged truncated) (2026-11-03) diff --git a/TODO.md b/TODO.md index b55ce88..b84b241 100644 --- a/TODO.md +++ b/TODO.md @@ -42,6 +42,13 @@ rg -n '\[SIMPLE\]' TODO.md +### **[P3]** Place new Linked view cards using their real size, not a guessed one + +- **Where.** `lib/linked/linked_view.dart:464` (`_placeNextTo`). +- **Why.** It looks for a free slot as if the new card were `source.width` wide and four rows tall. The canvas now pushes neighbours away once the card's real size is known, so nothing overlaps, but a tall or wide card can make its neighbours jump a moment after it appears. Placing it with its real size (or the compact-column count, which is known from the FKs) would avoid the jump. +- **Effort.** `<1h`. +- **Blocker.** none. +- **Noticed.** 2026-11-03, while fixing overlapping schema diagram cards. ### **[P2]** Find out whether the release build still needs `disable-library-validation` - **Where.** `macos/Runner/Release.entitlements` (`com.apple.security.cs.disable-library-validation`) diff --git a/docs/architecture.md b/docs/architecture.md index 4f2f4ab..2840936 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -244,7 +244,7 @@ Grid view modes (`enum GridViewMode { table, typed }`, in `lib/grid/grid_data.da ## Linked view (FK graph canvas) -`LinkedTab` (`lib/linked/`) is the third row-explorer alongside `TableTab` and `NavigatorTab`. The canvas is a `Stack` of free-floating, drag-by-header nodes connected by bezier edges; each node is either a `LinkedRowNode` (single row → `PropertyInspector`) or a `LinkedListNode` (paginated `KeysetPager` rows the user clicks to promote into row nodes). Outbound FK clicks in the inspector spawn row nodes; inbound clicks spawn list nodes filtered by the inbound FK (same `InboundFilter` the navigator uses). The cached `foreignKeys` list is loaded once per tab. Nodes are a fixed `_kNodeWidth × _kNodeHeight` so the edge painter can anchor connectors to rim midpoints without measuring widget sizes — a clean shape but it constrains body content. Background-pan lives as a `Positioned.fill` at the bottom of the stack so node hit-tests preempt it (otherwise horizontal drags inside an inspector field would pan the canvas instead of selecting text). Seed entry points: sidebar context menu → "Open as Linked" (sets `seedSchema`/`seedTable`, bootstraps a list node on first build), or empty `LinkedTab` + "Add table" header button. +`LinkedTab` (`lib/linked/`) is the third row-explorer alongside `TableTab` and `NavigatorTab`. The canvas is a `Stack` of free-floating, drag-by-header nodes connected by bezier edges; each node is either a `LinkedRowNode` (single row → `PropertyInspector`) or a `LinkedListNode` (paginated `KeysetPager` rows the user clicks to promote into row nodes). Outbound FK clicks in the inspector spawn row nodes; inbound clicks spawn list nodes filtered by the inbound FK (same `InboundFilter` the navigator uses). The cached `foreignKeys` list is loaded once per tab. Nodes are a fixed `_kNodeWidth × _kNodeHeight` so the edge painter can anchor connectors to rim midpoints without measuring widget sizes — a clean shape but it constrains body content. Background-pan lives as a `Positioned.fill` at the bottom of the stack so node hit-tests preempt it (otherwise horizontal drags inside an inspector field would pan the canvas instead of selecting text). Cards never overlap: whenever a card appears, changes size (width auto-fit, columns loading, a manual resize) or is dropped after a drag, the canvas runs `cascadeSeparate` from `package:schema_viewer` with that card pinned, the same pass the Schema viewer runs on expand and drag. Seed entry points: sidebar context menu → "Open as Linked" (sets `seedSchema`/`seedTable`, bootstraps a list node on first build), or empty `LinkedTab` + "Add table" header button. Row cards are rendered via the shared `SchemaCard` widget (see "Schema viewer"), with `SchemaFieldSpec.valueDisplay` populated from the loaded `row` map — so a Linked row card shows live data values instead of column type labels once the row loads. Edge styling (gradient stroke + two animated chevrons per edge, 3.5s cycle) is identical to the Schema viewer's `_EdgePainter` so the two views feel like one product. diff --git a/lib/linked/parts/canvas.dart b/lib/linked/parts/canvas.dart index 7b70662..a6a02ed 100644 --- a/lib/linked/parts/canvas.dart +++ b/lib/linked/parts/canvas.dart @@ -50,6 +50,12 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin { /// width alone so a manual resize is sticky. final _autoSizedOnce = {}; + /// Each row node's size as of the last build. A card that appears or + /// changes size (width auto-fit, columns loading, a manual resize) + /// pushes its neighbours away so cards never sit on top of each other. + /// Null until the first build, which takes the layout as given. + Map? _lastSizes; + /// Drives the chevron animation on edges. 0..1, wraps every cycle. late final Ticker _ticker; double _tickerValue = 0; @@ -153,6 +159,7 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin { ); if (node != null && _dragDelta != Offset.zero) { node.position += _dragDelta; + _separateFrom({node.id}); } setState(() { _dragNodeId = null; @@ -189,6 +196,51 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin { AppState.instance.notifyChange(); } + /// Push neighbours away from every card that is new or changed size + /// since the last build. The first build only records sizes, so a + /// restored canvas keeps the layout the user left it in. + void _separateResizedNodes( + List rows, + List foreignKeys, + ) { + final sizes = { + for (final node in rows) node.id: Size(node.width, _nodeHeightFor(node, foreignKeys)), + }; + final previous = _lastSizes; + _lastSizes = sizes; + if (previous == null) return; + final grown = { + for (final entry in sizes.entries) + if (previous[entry.key] != entry.value) entry.key, + }; + if (grown.isNotEmpty) _separateFrom(grown, sizes: sizes); + } + + /// Push every row node that overlaps one of [pinnedIds] (or a node + /// pushed in turn) out of the way. [pinnedIds] keep their positions. + void _separateFrom(Set pinnedIds, {Map? sizes}) { + final foreignKeys = widget.tab.foreignKeys; + final rows = { + for (final node in widget.tab.nodes) + if (node is LinkedRowNode) node.id: node, + }; + final positions = {for (final node in rows.values) node.id: node.position}; + final moved = cascadeSeparate( + movedIds: pinnedIds, + positions: positions, + dimsFor: (id) { + final node = rows[id]; + if (node == null) return null; + final size = sizes?[id] ?? Size(node.width, _nodeHeightFor(node, foreignKeys)); + return (w: size.width, h: size.height); + }, + allIds: positions.keys, + ); + for (final id in moved) { + rows[id]!.position = positions[id]!; + } + } + /// Build only the LinkedRowNode positions. `_topNodeId` (if any) is /// rendered last so it stacks on top. List _buildRowNodes(Offset pan) { @@ -202,17 +254,21 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin { final top = rows.removeAt(topIndex); rows.add(top); } + // Auto-fit the width once FKs are loaded and the user hasn't + // resized the card themselves. We only widen, never shrink — so a + // manual resize sticks. Done for every card before any is placed, + // so the separation pass below sees final sizes. + for (final node in rows) { + if (widget.tab.fksLoaded && !_autoSizedOnce.contains(node.id)) { + final fit = _autoWidthFor(node, foreignKeys); + if (fit > node.width) node.width = fit; + _autoSizedOnce.add(node.id); + } + } + _separateResizedNodes(rows, foreignKeys); return [ for (final node in rows) () { - // Auto-fit the width once FKs are loaded and the user hasn't - // resized the card themselves. We only widen, never shrink — - // so a manual resize sticks. - if (widget.tab.fksLoaded && !_autoSizedOnce.contains(node.id)) { - final fit = _autoWidthFor(node, foreignKeys); - if (fit > node.width) node.width = fit; - _autoSizedOnce.add(node.id); - } final height = _nodeHeightFor(node, foreignKeys); final width = node.width; return Positioned( diff --git a/packages/schema_viewer/lib/src/schema_layout.dart b/packages/schema_viewer/lib/src/schema_layout.dart index 1cd100f..249dad9 100644 --- a/packages/schema_viewer/lib/src/schema_layout.dart +++ b/packages/schema_viewer/lib/src/schema_layout.dart @@ -57,7 +57,7 @@ Size layoutSchemaNodes( List edges, { SchemaLayoutMode mode = SchemaLayoutMode.lr, double colGap = 80, - double rowGap = 24, + double rowGap = kSchemaNodeMargin, }) { if (nodes.isEmpty) return Size.zero; if (edges.isEmpty) { @@ -251,55 +251,101 @@ Size _layoutRanked( /// Cascading AABB separation used by the canvas when a node moves /// (drag) or grows (expand). [movedIds] are immovable; their positions -/// are authoritative. Mutates [positions] in place and returns the set -/// of node ids whose position changed. Mirrors admin_site/dbmlLayout.ts -/// cascadeSeparate. +/// are authoritative. Every other node that ends up within +/// [kSchemaNodeMargin] of a node it touches is pushed away along the +/// axis of least overlap, and each pushed node is then checked against +/// every other node (pinned ones included), so a push can never land a +/// card on top of another. Mutates [positions] in place and returns the +/// set of node ids whose position changed. +/// +/// Each pass moves the push one step further down a chain of touching +/// cards, so [maxPasses] defaults to enough passes for a chain through +/// every node. Two pinned nodes that overlap each other are left alone. Set cascadeSeparate({ required Set movedIds, required Map positions, required ({double w, double h})? Function(String id) dimsFor, required Iterable allIds, - int maxPasses = 8, + int? maxPasses, }) { + final ids = allIds.toList(); + final passLimit = maxPasses ?? ids.length * 2 + 8; final changed = {}; var work = Set.of(movedIds); - for (var pass = 0; pass < maxPasses; pass++) { + for (var pass = 0; pass < passLimit && work.isNotEmpty; pass++) { final next = {}; for (final id in work) { - final aPosition = positions[id]; final da = dimsFor(id); - if (aPosition == null || da == null) continue; - for (final otherId in allIds) { + if (da == null) continue; + for (final otherId in ids) { if (otherId == id) continue; - if (movedIds.contains(otherId)) continue; + final idPinned = movedIds.contains(id); + final otherPinned = movedIds.contains(otherId); + if (idPinned && otherPinned) continue; + final aPosition = positions[id]; final bPosition = positions[otherId]; final db = dimsFor(otherId); - if (bPosition == null || db == null) continue; + if (aPosition == null || bPosition == null || db == null) continue; final minDx = (da.w + db.w) / 2 + kSchemaNodeMargin; final minDy = (da.h + db.h) / 2 + kSchemaNodeMargin; + // Centre-to-centre distance, from `id` to `otherId`. final dx = (bPosition.dx + db.w / 2) - (aPosition.dx + da.w / 2); final dy = (bPosition.dy + db.h / 2) - (aPosition.dy + da.h / 2); final ox = minDx - dx.abs(); final oy = minDy - dy.abs(); if (ox <= 0 || oy <= 0) continue; - if (ox < oy) { - final push = ox + 0.5; - positions[otherId] = Offset( - bPosition.dx + (dx >= 0 ? push : -push), - bPosition.dy, - ); - } else { - final push = oy + 0.5; - positions[otherId] = Offset( - bPosition.dx, - bPosition.dy + (dy >= 0 ? push : -push), - ); + // The pinned side never moves. Otherwise `otherId` gets pushed, + // which keeps a cascade flowing outward from where it started. + final moverId = otherPinned ? id : otherId; + final sign = moverId == otherId ? 1.0 : -1.0; + final moverPosition = positions[moverId]!; + final moverDims = moverId == id ? da : db; + // The four ways out, shortest first: the two "away" pushes are + // the overlap on each axis, the two "back through" pushes clear + // the far side instead. Take the shortest one that lands clear + // of every card, else the shortest that at least avoids pinned + // cards. Without that, a card squeezed against a pinned card + // bounces between its neighbours until the passes run out. + final awayX = (dx >= 0 ? 1.0 : -1.0) * sign; + final awayY = (dy >= 0 ? 1.0 : -1.0) * sign; + final candidates = [ + Offset(awayX * (ox + 0.5), 0), + Offset(0, awayY * (oy + 0.5)), + Offset(-awayX * (2 * minDx - ox + 0.5), 0), + Offset(0, -awayY * (2 * minDy - oy + 0.5)), + ]..sort((a, b) => a.distance.compareTo(b.distance)); + bool crowds(Offset at, {required bool pinnedOnly}) { + for (final nearId in ids) { + if (nearId == moverId) continue; + if (pinnedOnly && !movedIds.contains(nearId)) continue; + final nearPosition = positions[nearId]; + final nearDims = dimsFor(nearId); + if (nearPosition == null || nearDims == null) continue; + final gapX = ((nearPosition.dx + nearDims.w / 2) - (at.dx + moverDims.w / 2)).abs(); + final gapY = ((nearPosition.dy + nearDims.h / 2) - (at.dy + moverDims.h / 2)).abs(); + if (gapX < (nearDims.w + moverDims.w) / 2 + kSchemaNodeMargin && + gapY < (nearDims.h + moverDims.h) / 2 + kSchemaNodeMargin) { + return true; + } + } + return false; } - changed.add(otherId); - next.add(otherId); + + Offset? firstWhere(bool Function(Offset candidate) test) { + for (final candidate in candidates) { + if (test(candidate)) return candidate; + } + return null; + } + + final push = firstWhere((c) => !crowds(moverPosition + c, pinnedOnly: false)) ?? + firstWhere((c) => !crowds(moverPosition + c, pinnedOnly: true)) ?? + candidates.first; + positions[moverId] = moverPosition + push; + changed.add(moverId); + next.add(moverId); } } - if (next.isEmpty) break; work = next; } return changed; diff --git a/test/schema_layout_test.dart b/test/schema_layout_test.dart new file mode 100644 index 0000000..6bd6a03 --- /dev/null +++ b/test/schema_layout_test.dart @@ -0,0 +1,174 @@ +// Contract: schema diagram cards never sit on top of each other. +// +// - `layoutSchemaNodes` places every card at least kSchemaNodeMargin +// from every other card, in both layout modes and in the no-edge grid. +// - `cascadeSeparate` (run when a card is dragged, expanded or resized) +// leaves no two cards closer than kSchemaNodeMargin unless both are +// pinned. That holds for a push chain longer than any fixed pass count +// and when a pushed card would land on a pinned one. +// - Pinned cards (`movedIds`) never move. + +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:schema_viewer/schema_viewer.dart'; + +SchemaNodeData _node(String id, {int fields = 2}) => SchemaNodeData( + id: id, + schema: 'public', + tableName: id, + compact: false, + fields: [ + for (var i = 0; i < fields; i++) SchemaFieldSpec(name: 'c$i', typeLabel: 'int', pk: i == 0), + ], +); + +/// Every pair of cards closer than [kSchemaNodeMargin], as "a/b" labels. +List _crowdedPairs( + Map positions, + ({double w, double h}) Function(String id) dims, { + Set ignorePairsWithin = const {}, +}) { + final ids = positions.keys.toList(); + final out = []; + for (var i = 0; i < ids.length; i++) { + for (var j = i + 1; j < ids.length; j++) { + final a = ids[i]; + final b = ids[j]; + if (ignorePairsWithin.contains(a) && ignorePairsWithin.contains(b)) continue; + final da = dims(a); + final db = dims(b); + final ra = Rect.fromLTWH(positions[a]!.dx, positions[a]!.dy, da.w, da.h); + final rb = Rect.fromLTWH(positions[b]!.dx, positions[b]!.dy, db.w, db.h); + // Allow a hair of float error under the margin. + if (ra + .inflate(kSchemaNodeMargin / 2 - 0.01) + .overlaps(rb.inflate(kSchemaNodeMargin / 2 - 0.01))) { + out.add('$a/$b'); + } + } + } + return out; +} + +void main() { + group('layoutSchemaNodes', () { + List nodes() => [ + for (var i = 0; i < 12; i++) _node('t$i', fields: 1 + i % 5), + ]; + // A hub with many children plus a cycle, so ranks hold many cards. + final edges = [ + for (var i = 1; i < 12; i++) SchemaEdgeData(id: 'e$i', sourceId: 't0', targetId: 't$i'), + const SchemaEdgeData(id: 'c1', sourceId: 't3', targetId: 't4'), + const SchemaEdgeData(id: 'c2', sourceId: 't4', targetId: 't3'), + ]; + + for (final mode in SchemaLayoutMode.values) { + test('keeps every card apart in ${mode.name} mode', () { + final list = nodes(); + layoutSchemaNodes(list, edges, mode: mode); + final byId = {for (final node in list) node.id: node}; + expect( + _crowdedPairs( + {for (final node in list) node.id: node.position}, + (id) => (w: effectiveNodeWidth(byId[id]!), h: renderedNodeHeight(byId[id]!)), + ), + isEmpty, + ); + }); + } + + test('keeps every card apart with no relationships', () { + final list = nodes(); + layoutSchemaNodes(list, const []); + final byId = {for (final node in list) node.id: node}; + expect( + _crowdedPairs( + {for (final node in list) node.id: node.position}, + (id) => (w: effectiveNodeWidth(byId[id]!), h: renderedNodeHeight(byId[id]!)), + ), + isEmpty, + ); + }); + }); + + group('cascadeSeparate', () { + const w = 200.0; + const h = 100.0; + ({double w, double h}) dims(String _) => (w: w, h: h); + + test('separates a column longer than eight cards when the top one grows', () { + // A tidy 20-card column whose top card is expanded to five times + // its height: the push has to travel the whole chain. + final positions = { + for (var i = 0; i < 20; i++) 'n$i': Offset(0, i * (h + kSchemaNodeMargin)), + }; + ({double w, double h}) grownDims(String id) => (w: w, h: id == 'n0' ? h * 5 : h); + cascadeSeparate( + movedIds: {'n0'}, + positions: positions, + dimsFor: grownDims, + allIds: positions.keys, + ); + expect(_crowdedPairs(positions, grownDims), isEmpty); + }); + + test('never leaves a card on top of another in a crowded canvas', () { + // A tidy grid, then one card is dropped somewhere at random (a + // drag). A pushed card has to be checked against the dropped card + // too, or a chain of pushes can land it back on top of it. + final random = math.Random(7); + for (var trial = 0; trial < 1000; trial++) { + final positions = { + for (var i = 0; i < 12; i++) + 'n$i': Offset((i % 4) * (w + kSchemaNodeMargin), (i ~/ 4) * (h + kSchemaNodeMargin)), + }; + final pinned = 'n${random.nextInt(12)}'; + final before = Offset(random.nextDouble() * 800, random.nextDouble() * 350); + positions[pinned] = before; + cascadeSeparate( + movedIds: {pinned}, + positions: positions, + dimsFor: dims, + allIds: positions.keys, + ); + expect(positions[pinned], before, reason: 'trial $trial'); + expect(_crowdedPairs(positions, dims), isEmpty, reason: 'trial $trial'); + } + }); + + test('pushes a card off a second pinned card', () { + // Two pinned cards side by side and one free card overlapping both. + final positions = { + 'left': Offset.zero, + 'right': const Offset(400, 0), + 'free': const Offset(150, 20), + }; + cascadeSeparate( + movedIds: {'left', 'right'}, + positions: positions, + dimsFor: dims, + allIds: positions.keys, + ); + expect(positions['left'], Offset.zero); + expect(positions['right'], const Offset(400, 0)); + expect(_crowdedPairs(positions, dims), isEmpty); + }); + + test('leaves pinned cards where they are', () { + final positions = { + 'a': Offset.zero, + 'b': const Offset(10, 10), + }; + cascadeSeparate( + movedIds: {'a'}, + positions: positions, + dimsFor: dims, + allIds: positions.keys, + ); + expect(positions['a'], Offset.zero); + expect(_crowdedPairs(positions, dims), isEmpty); + }); + }); +}