From 13d7541cc5ea1c473f3b339ec6691ca9c1ef5d76 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 10:42:37 +0300 Subject: [PATCH] fix(erd): large diagrams fit, Ctrl/Cmd++ zooms, Esc closes the search before the pick (#1154) Fit stopped at the 20 % zoom floor, so a schema of a few hundred tables did not fit on first load; the floor now follows the fit. Ctrl/Cmd++ is Shift+= on most layouts and was not bound. The search field takes Esc itself (clear, then unfocus), so Esc never closed the search and the canvas lost the keyboard. An empty search that loses the focus now closes and hands the keyboard back; picking a table from the search closes it too. The view's Esc closes an open search first and clears the pick only on the next press. A click on a card or the canvas takes the keyboard, and hiding the picked table clears the pick instead of leaving every card faded. --- lib/features/erd/erd_layout.dart | 11 +++ lib/features/erd/erd_view.dart | 98 +++++++++++++++++++------ test/features/erd/erd_test.dart | 121 +++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 22 deletions(-) diff --git a/lib/features/erd/erd_layout.dart b/lib/features/erd/erd_layout.dart index e9e976ae..3a0cf20a 100644 --- a/lib/features/erd/erd_layout.dart +++ b/lib/features/erd/erd_layout.dart @@ -40,6 +40,17 @@ class ErdLayout { return Size(w, h); } + /// Zoom that fits [content] into [viewport] with a 24 px margin, never + /// enlarged. It may go below the usual minimum zoom of the view, so a + /// diagram of a few hundred tables still fits. + static double fitScale(Size content, Size viewport) { + const pad = 24.0; + if (content.width <= 0 || content.height <= 0) return 1; + final s = math.min((viewport.width - 2 * pad) / content.width, + (viewport.height - 2 * pad) / content.height); + return s.clamp(0.02, 1.0).toDouble(); + } + /// Vertical center of [column] inside [t]'s card (header when unknown). double columnY(ErdTable t, String column) { final i = t.columns.indexWhere((c) => c.name == column); diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index f46938f7..d2fd8edc 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -122,6 +122,10 @@ class _ErdViewState extends material.State { static const double _maxScale = 3; static const double _zoomStep = 1.25; + /// Lowest zoom: [_minScale], or lower when the whole diagram needs it to + /// fit the viewport (a schema of a few hundred tables). + double _zoomFloor = _minScale; + @override void dispose() { _transform.dispose(); @@ -136,9 +140,28 @@ class _ErdViewState extends material.State { @override void initState() { super.initState(); + _searchFocus.addListener(_onSearchFocus); _load(); } + /// The search field handles Esc itself: the first clears the text, the + /// next drops the focus, so the view's own Esc never sees it. An empty + /// search that loses the focus closes, and the canvas gets the keyboard + /// back for the next Esc and the zoom shortcuts. + void _onSearchFocus() { + if (!mounted || + _searchFocus.hasFocus || + !_searchOpen || + _searchController.text.isNotEmpty) { + return; + } + setState(() { + _searchOpen = false; + _query = ''; + }); + _canvasFocus.requestFocus(); + } + Future _load() async { setState(() { _loading = true; @@ -234,7 +257,11 @@ class _ErdViewState extends material.State { } void _hide(String table) { - setState(() => _hidden.add(table)); + setState(() { + _hidden.add(table); + // A pick on a hidden table would leave every card faded. + if (_selected == table) _selected = null; + }); _reflow(); } @@ -292,7 +319,7 @@ class _ErdViewState extends material.State { /// Sets the zoom to [target], keeping the scene point under [focal] still. void _setScale(double target, material.Offset focal) { - final s = target.clamp(_minScale, _maxScale).toDouble(); + final s = target.clamp(_zoomFloor, _maxScale).toDouble(); final t = _transform.value.getTranslation(); final current = material.Offset(t.x, t.y); final next = focal - (focal - current) * (s / _scale); @@ -310,16 +337,26 @@ class _ErdViewState extends material.State { final layout = _layout; final vs = _viewportSize(); if (layout == null || vs == null) return; - const pad = 24.0; - final s = min((vs.width - 2 * pad) / layout.size.width, - (vs.height - 2 * pad) / layout.size.height) - .clamp(_minScale, 1.0) - .toDouble(); + final s = ErdLayout.fitScale(layout.size, vs); + final floor = min(_minScale, s); + if (floor != _zoomFloor) setState(() => _zoomFloor = floor); final tx = (vs.width - layout.size.width * s) / 2; final ty = (vs.height - layout.size.height * s) / 2; _transform.value = Matrix4.identity()..translate(tx, ty)..scale(s); } + /// Picks a table from the search: centres it, closes the search and gives + /// the keyboard back to the canvas, so Esc then clears the pick. + void _pickFromSearch(ErdSchema schema, String name) { + _selectTable(schema, name); + _searchController.clear(); + setState(() { + _searchOpen = false; + _query = ''; + }); + _canvasFocus.requestFocus(); + } + /// Picks a table and centres the view on it, keeping the zoom. void _selectTable(ErdSchema schema, String name) { final layout = _layout; @@ -336,25 +373,39 @@ class _ErdViewState extends material.State { setState(() => _selected = name); } + /// A click on the empty canvas: clears the pick and takes the keyboard, so + /// the zoom and search shortcuts work after a click anywhere on it. void _clearSelection() { + _canvasFocus.requestFocus(); if (_selected == null) return; setState(() => _selected = null); } + /// A click on a card picks it. + void _pick(String name) { + _canvasFocus.requestFocus(); + setState(() => _selected = name); + } + void _openSearch() { setState(() => _searchOpen = true); material.WidgetsBinding.instance .addPostFrameCallback((_) => _searchFocus.requestFocus()); } - /// Escape: closes the search and clears the selection. + /// 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() { - _searchController.clear(); - setState(() { - _searchOpen = false; - _query = ''; - _selected = null; - }); + if (_searchOpen) { + _searchController.clear(); + setState(() { + _searchOpen = false; + _query = ''; + }); + _canvasFocus.requestFocus(); + return; + } + if (_selected != null) setState(() => _selected = null); } List _matches(ErdSchema schema) { @@ -368,12 +419,16 @@ class _ErdViewState extends material.State { /// Keyboard shortcuts for both Ctrl (Linux, Windows) and Cmd (macOS). Map _bindings() { final out = {}; - void both(LogicalKeyboardKey key, material.VoidCallback action) { - out[material.SingleActivator(key, control: true)] = action; - out[material.SingleActivator(key, meta: true)] = action; + void both(LogicalKeyboardKey key, material.VoidCallback action, + {bool shift = false}) { + out[material.SingleActivator(key, control: true, shift: shift)] = action; + out[material.SingleActivator(key, meta: true, shift: shift)] = action; } both(LogicalKeyboardKey.equal, () => _zoomBy(_zoomStep)); + // "+" is Shift+= on most layouts. + both(LogicalKeyboardKey.equal, () => _zoomBy(_zoomStep), shift: true); + both(LogicalKeyboardKey.add, () => _zoomBy(_zoomStep)); both(LogicalKeyboardKey.numpadAdd, () => _zoomBy(_zoomStep)); both(LogicalKeyboardKey.minus, () => _zoomBy(1 / _zoomStep)); both(LogicalKeyboardKey.numpadSubtract, () => _zoomBy(1 / _zoomStep)); @@ -552,7 +607,7 @@ class _ErdViewState extends material.State { debounceDuration: Duration.zero, onChanged: (v) => setState(() => _query = v.trim()), onSubmitted: (_) { - if (matches.isNotEmpty) _selectTable(schema, matches.first.name); + if (matches.isNotEmpty) _pickFromSearch(schema, matches.first.name); }, ), if (_query.isNotEmpty) @@ -577,7 +632,7 @@ class _ErdViewState extends material.State { material.GestureDetector( key: material.ValueKey('erd_search_result_${t.name}'), behavior: material.HitTestBehavior.opaque, - onTap: () => _selectTable(schema, t.name), + onTap: () => _pickFromSearch(schema, t.name), child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 10, vertical: 6), @@ -650,7 +705,7 @@ class _ErdViewState extends material.State { transformationController: _transform, // A card drag must not pan the canvas. panEnabled: _dragging == null, - minScale: _minScale, + minScale: _zoomFloor, maxScale: _maxScale, boundaryMargin: const material.EdgeInsets.all(400), child: material.RepaintBoundary( @@ -733,8 +788,7 @@ class _ErdViewState extends material.State { onOpen: widget.onOpenTable == null ? null : () => widget.onOpenTable!(t.name), - onSelect: () => - setState(() => _selected = t.name), + onSelect: () => _pick(t.name), onHover: (inside) => _hovered.value = inside ? t.name : (_hovered.value == t.name diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index 3ae3cf74..74065028 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -439,6 +439,16 @@ void main() { expect(svgFor('0'), isNot(contains(' t + .widget( + find.byKey(const material.ValueKey('erd_zoom_label'))) + .data!; + + testWidgets('a large diagram fits the viewport on first load', (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + final tall = FakeSqlExecutionDelegate(onExecute: (sql) { + if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) { + return SqlExecutionResult(rows: [ + for (final table in ['a', 'b', 'c']) + for (var i = 0; i < 400; i++) [table, 'col$i', 'INTEGER', '0'], + ]); + } + return const SqlExecutionResult(); + }); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: tall, dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + await t.pump(); + final percent = int.parse(zoomLabel(t).replaceAll('%', '')); + // A card of 400 rows is about 8 800 px tall: below the usual 20 % floor. + expect(percent, lessThan(20)); + expect(percent, greaterThan(0)); + }); + + testWidgets('Ctrl+Shift+= ("+") zooms in', (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: delegate(), dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + expect(zoomLabel(t), '100%'); + + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await t.sendKeyEvent(LogicalKeyboardKey.equal); + await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + expect(zoomLabel(t), '125%'); + }); + + testWidgets('Enter in the search picks the table and closes the search', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: threeTables(), dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyF); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + await t.pump(); + await t.enterText(find.byType(material.EditableText), 'ord'); + await t.pump(); + await t.testTextInput.receiveAction(TextInputAction.done); + await t.pump(); + await t.pump(); + + expect(find.byType(material.EditableText), findsNothing); + expect(opacityOf(t, 'lonely'), contains(0.35)); + + // The canvas has the keyboard again: Esc clears the pick. + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pump(); + expect(opacityOf(t, 'lonely'), isNot(contains(0.35))); + }); + + testWidgets('Esc closes an open search and keeps the picked table', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView( + source: SqlErdSource(delegate: threeTables(), dialect: SqlDialect.sqlite)), + )); + await t.pump(); + await t.pump(); + + await t.tap(find.byKey(const material.ValueKey('erd_table_orders'))); + await t.pump(const Duration(milliseconds: 400)); + expect(opacityOf(t, 'lonely'), contains(0.35)); + + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyF); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + await t.pump(); + expect(find.byType(material.EditableText), findsOneWidget); + + // The empty field drops the focus on Esc; the search closes. + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pump(); + await t.pump(); + expect(find.byType(material.EditableText), findsNothing); + expect(opacityOf(t, 'lonely'), contains(0.35)); + + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pump(); + expect(opacityOf(t, 'lonely'), isNot(contains(0.35))); + }); + testWidgets('keys only hides plain columns and the toggle brings them back', (t) async { await t.binding.setSurfaceSize(const material.Size(1200, 800));