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
11 changes: 11 additions & 0 deletions lib/features/erd/erd_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
98 changes: 76 additions & 22 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ class _ErdViewState extends material.State<ErdView> {
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();
Expand All @@ -136,9 +140,28 @@ class _ErdViewState extends material.State<ErdView> {
@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<void> _load() async {
setState(() {
_loading = true;
Expand Down Expand Up @@ -234,7 +257,11 @@ class _ErdViewState extends material.State<ErdView> {
}

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

Expand Down Expand Up @@ -292,7 +319,7 @@ class _ErdViewState extends material.State<ErdView> {

/// 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);
Expand All @@ -310,16 +337,26 @@ class _ErdViewState extends material.State<ErdView> {
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;
Expand All @@ -336,25 +373,39 @@ class _ErdViewState extends material.State<ErdView> {
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<ErdTable> _matches(ErdSchema schema) {
Expand All @@ -368,12 +419,16 @@ class _ErdViewState extends material.State<ErdView> {
/// Keyboard shortcuts for both Ctrl (Linux, Windows) and Cmd (macOS).
Map<material.ShortcutActivator, material.VoidCallback> _bindings() {
final out = <material.ShortcutActivator, material.VoidCallback>{};
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));
Expand Down Expand Up @@ -552,7 +607,7 @@ class _ErdViewState extends material.State<ErdView> {
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)
Expand All @@ -577,7 +632,7 @@ class _ErdViewState extends material.State<ErdView> {
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),
Expand Down Expand Up @@ -650,7 +705,7 @@ class _ErdViewState extends material.State<ErdView> {
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(
Expand Down Expand Up @@ -733,8 +788,7 @@ class _ErdViewState extends material.State<ErdView> {
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
Expand Down
121 changes: 121 additions & 0 deletions test/features/erd/erd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,16 @@ void main() {
expect(svgFor('0'), isNot(contains('<circle')));
});

test('fit goes below the usual minimum zoom for a large diagram', () {
final big = ErdLayout.fitScale(
const material.Size(20000, 9000), const material.Size(1200, 700));
expect(big, lessThan(0.2));
expect(big, greaterThan(0));
expect(20000 * big, lessThanOrEqualTo(1200));
expect(ErdLayout.fitScale(
const material.Size(400, 300), const material.Size(1200, 700)), 1);
});

test('png ratio keeps the longest side within 8192 px', () {
final ratio = ErdExport.pngPixelRatio(const material.Size(20000, 9000));
expect((20000 * ratio).round(), lessThanOrEqualTo(8192));
Expand Down Expand Up @@ -761,6 +771,117 @@ void main() {
expect(opacityOf(t, 'orders'), isNot(contains(0.35)));
});

String zoomLabel(WidgetTester t) => t
.widget<material.Text>(
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));
Expand Down
Loading