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));