From c6067513802a3a9188bfff41cde7ce59ff76f98b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 15:40:15 +0300 Subject: [PATCH 1/2] feat(ui): the interface scale zooms the whole UI, capped so 900x600 always fits; it applies at once and animates (zoom) --- lib/app/app.dart | 36 ++-- lib/core/layout/ui_scale_controller.dart | 15 +- lib/core/layout/ui_zoom.dart | 164 ++++++++++++++++++ .../settings/preferences_controls.dart | 23 ++- .../workspace/grid/grid_cell_actions.dart | 3 +- .../grid/grid_pointer_selection.dart | 4 +- lib/features/workspace/result_grid_view.dart | 7 +- test/app/ui_scale_overflow_test.dart | 93 ++++++++++ test/core/layout/ui_zoom_test.dart | 163 +++++++++++++++++ 9 files changed, 475 insertions(+), 33 deletions(-) create mode 100644 lib/core/layout/ui_zoom.dart create mode 100644 test/app/ui_scale_overflow_test.dart create mode 100644 test/core/layout/ui_zoom_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 09c3b189..f3556267 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,4 @@ -import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/ui_zoom.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/motion/querya_motion_controller.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; @@ -65,26 +65,22 @@ class QueryaApp extends StatelessWidget { themeAnimationCurve: themeCurve, // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. builder: (context, child) { - final mq = MediaQuery.maybeOf(context); - return QueryaUiScaleScope( + // One zoom for the whole interface (text, icons, paddings, + // hit areas), capped by the window size; dialogs and menus + // live in the Navigator below, so they are zoomed with it. + return QueryaUiZoom( scale: scale, - child: MediaQuery( - data: (mq ?? const MediaQueryData()).copyWith( - textScaler: TextScaler.linear( - (mq ?? const MediaQueryData()) - .textScaler - .scale(1.0) * - scale, - ), - ), - child: AnimatedQueryaTheme( - data: queryaTheme, - duration: themeDuration, - curve: themeCurve, - child: QueryaMotionScope( - level: motionLevel, - child: child ?? const SizedBox.shrink(), - ), + duration: disableAnimations || + motionLevel == QueryaMotionLevel.off + ? Duration.zero + : const Duration(milliseconds: 180), + child: AnimatedQueryaTheme( + data: queryaTheme, + duration: themeDuration, + curve: themeCurve, + child: QueryaMotionScope( + level: motionLevel, + child: child ?? const SizedBox.shrink(), ), ), ); diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index 39142046..1fa9354f 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; /// Loads and broadcasts [AppSettings] UI scale for the widget tree. class UiScaleController extends ChangeNotifier { @@ -14,11 +15,19 @@ class UiScaleController extends ChangeNotifier { notifyListeners(); } - /// Persist scale to SQLite (called on slider release). + /// Applies [value] at once and then stores it (called on slider release). + /// + /// The interface changes before the database is touched, so a slow or failing + /// write never delays or undoes what the user just chose; a failed write is + /// logged and the scale applies until the next start. Future commitScale(double value, {bool fine = false}) async { - await AppSettings.instance.setUiScale(value, fine: fine); - _scale = await AppSettings.instance.getUiScale(); + _scale = normalize(value, fine: fine); notifyListeners(); + try { + await AppSettings.instance.setUiScale(_scale, fine: true); + } catch (e, st) { + logIgnored('UiScaleController.commitScale', e, st); + } } Future setScale(double value, {bool fine = false}) => diff --git a/lib/core/layout/ui_zoom.dart b/lib/core/layout/ui_zoom.dart new file mode 100644 index 00000000..50841de5 --- /dev/null +++ b/lib/core/layout/ui_zoom.dart @@ -0,0 +1,164 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// The smallest layout the app is built for, in logical pixels: the minimum +/// window size. The interface scale never zooms the layout below it, so a big +/// scale in a small window is capped instead of overflowing. +const Size kMinUiLayoutSize = Size(900, 600); + +/// The lowest zoom ever applied, whatever the window. +const double kLowestUiZoom = 0.5; + +/// The zoom actually used for [requested] in a [window]: the requested scale, +/// but no higher than keeps [kMinUiLayoutSize] available to the layout. +double effectiveUiScale({required double requested, required Size window}) { + final cap = math.min( + window.width / kMinUiLayoutSize.width, + window.height / kMinUiLayoutSize.height, + ); + return math.min(requested, math.max(cap, kLowestUiZoom)); +} + +/// What [QueryaUiZoom] applied below it. +class QueryaUiZoomScope extends InheritedWidget { + const QueryaUiZoomScope({ + super.key, + required this.requested, + required this.effective, + required super.child, + }); + + /// The scale the user asked for (Preferences → Appearance). + final double requested; + + /// The zoom in use this frame: lower than [requested] when the window is too + /// small for it, and moving towards it while a change is animated. + final double effective; + + static QueryaUiZoomScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + /// True when the window is too small for the requested scale. + bool get isCapped => effective < requested - 0.005; + + @override + bool updateShouldNotify(QueryaUiZoomScope old) => + old.requested != requested || old.effective != effective; +} + +extension QueryaUiZoomContext on BuildContext { + /// The point of the app layout that sits under [windowPoint], a position in + /// window coordinates such as a pointer event's `position`. The zoom is + /// anchored at the top left, so this is a division. Use it before placing an + /// overlay entry or a menu at a pointer position. + Offset windowToApp(Offset windowPoint) { + final zoom = QueryaUiZoomScope.maybeOf(this)?.effective ?? 1.0; + return windowPoint / zoom; + } +} + +/// Zooms the whole interface by [scale]: the child is laid out in a window +/// `scale` times smaller and painted `scale` times larger, so text, icons, +/// paddings and hit areas grow together and every screen overflows (or not) +/// the same way at every scale. +/// +/// The zoom is capped by [effectiveUiScale] and a change of [scale] is animated +/// over [duration] (pass [Duration.zero] for none). Dialogs, menus and tooltips +/// belong to the Navigator below this widget, so they are zoomed with it. +class QueryaUiZoom extends StatelessWidget { + const QueryaUiZoom({ + super.key, + required this.scale, + required this.child, + this.duration = const Duration(milliseconds: 180), + this.curve = Curves.easeOutCubic, + }); + + final double scale; + final Duration duration; + final Curve curve; + final Widget child; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final window = constraints.biggest; + return TweenAnimationBuilder( + tween: Tween(end: scale), + duration: duration, + curve: curve, + child: child, + builder: (context, animated, child) { + // The window cap applies at once: a resize must not lag behind. + final effective = + effectiveUiScale(requested: animated, window: window); + return _Zoomed( + requested: scale, + effective: effective, + window: window, + child: child!, + ); + }, + ); + }, + ); + } +} + +class _Zoomed extends StatelessWidget { + const _Zoomed({ + required this.requested, + required this.effective, + required this.window, + required this.child, + }); + + final double requested; + final double effective; + final Size window; + final Widget child; + + @override + Widget build(BuildContext context) { + final logical = Size(window.width / effective, window.height / effective); + final media = MediaQuery.maybeOf(context) ?? const MediaQueryData(); + final inner = MediaQuery( + data: media.copyWith( + size: logical, + padding: media.padding / effective, + viewPadding: media.viewPadding / effective, + viewInsets: media.viewInsets / effective, + systemGestureInsets: media.systemGestureInsets / effective, + ), + child: QueryaUiZoomScope( + requested: requested, + effective: effective, + child: child, + ), + ); + return SizedBox( + width: window.width, + height: window.height, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0, + maxWidth: double.infinity, + minHeight: 0, + maxHeight: double.infinity, + child: Transform( + alignment: Alignment.topLeft, + transform: Matrix4.diagonal3Values(effective, effective, 1), + child: SizedBox( + width: logical.width, + height: logical.height, + child: inner, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 017a411c..3723165e 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -226,6 +226,11 @@ class _InterfaceScaleSliderState extends material.State { bool _fineControl = false; double? _dragScale; + /// Shift decides the slider's range (presets or 1% steps) only between + /// drags: letting go of it mid-drag would change the range under the thumb. + bool _dragging = false; + bool _dragFine = false; + static int get _fineDivisions => ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); @@ -270,6 +275,7 @@ class _InterfaceScaleSliderState extends material.State { } bool _onKeyEvent(KeyEvent event) { + if (_dragging) return false; final fine = _shiftHeld; if (fine != _fineControl) { setState(() => _fineControl = fine); @@ -313,18 +319,23 @@ class _InterfaceScaleSliderState extends material.State { min: fine ? kMinUiScale : 0, max: fine ? kMaxUiScale : (kUiScalePresets.length - 1).toDouble(), divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, + onChangeStart: (_) { + _dragging = true; + _dragFine = _fineControl; + }, onChanged: (value) { - final next = _scaleFromSlider(value, fine: _shiftHeld); + final next = _scaleFromSlider(value, fine: _dragFine); setState(() => _dragScale = next); }, onChangeEnd: (value) { - final next = _scaleFromSlider(value, fine: _shiftHeld); + final next = _scaleFromSlider(value, fine: _dragFine); + final fine = _dragFine; + _dragging = false; setState(() => _dragScale = null); + // The app zooms to it at once (the slider itself moves with + // the zoom, so there is no live preview while dragging). unawaited( - UiScaleController.instance.commitScale( - next, - fine: _shiftHeld, - ), + UiScaleController.instance.commitScale(next, fine: fine), ); }, ), diff --git a/lib/features/workspace/grid/grid_cell_actions.dart b/lib/features/workspace/grid/grid_cell_actions.dart index f98b164b..ce99e56d 100644 --- a/lib/features/workspace/grid/grid_cell_actions.dart +++ b/lib/features/workspace/grid/grid_cell_actions.dart @@ -96,7 +96,8 @@ extension _GridCellActions on _VirtualResultGridState { void _openCellContextMenu(int row, int col, material.Offset globalPosition) { showQueryaContextMenuBuilder( context: context, - position: globalPosition, + // The pointer position is in window coordinates; the menu is zoomed. + position: context.windowToApp(globalPosition), itemsBuilder: (ctx) => _buildCellContextMenuItems(ctx, row, col), ); } diff --git a/lib/features/workspace/grid/grid_pointer_selection.dart b/lib/features/workspace/grid/grid_pointer_selection.dart index 1fdba943..0d54d389 100644 --- a/lib/features/workspace/grid/grid_pointer_selection.dart +++ b/lib/features/workspace/grid/grid_pointer_selection.dart @@ -135,7 +135,9 @@ extension _GridPointerSelection on _VirtualResultGridState { _lastPointerLocalPos = event.localPosition; if (!_isDragSelecting) { - final delta = (event.position - _pointerDownGlobalPos).distance; + // Window pixels; the grid is zoomed, so compare in grid pixels. + final delta = + context.windowToApp(event.position - _pointerDownGlobalPos).distance; if (delta > 4.0) { _isDragSelecting = true; _hasJustDragSelected = false; diff --git a/lib/features/workspace/result_grid_view.dart b/lib/features/workspace/result_grid_view.dart index 42ecfde8..4d14383f 100644 --- a/lib/features/workspace/result_grid_view.dart +++ b/lib/features/workspace/result_grid_view.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/layout/ui_zoom.dart'; import 'package:flutter/widgets.dart'; import 'dart:async'; import 'dart:math' as math; @@ -337,8 +338,10 @@ class _VirtualResultGridState extends material.State { final screenW = media?.size.width ?? 1920.0; final screenH = media?.size.height ?? 1080.0; - final left = (globalPosition.dx + 10.0).clamp(8.0, screenW - 320.0); - final top = (globalPosition.dy + 18.0).clamp(8.0, screenH - 120.0); + // The pointer position is in window coordinates; the overlay is zoomed. + final at = context.windowToApp(globalPosition); + final left = (at.dx + 10.0).clamp(8.0, screenW - 320.0); + final top = (at.dy + 18.0).clamp(8.0, screenH - 120.0); _cellTooltipOverlayEntry = material.OverlayEntry( builder: (context) { diff --git a/test/app/ui_scale_overflow_test.dart b/test/app/ui_scale_overflow_test.dart new file mode 100644 index 00000000..cf553c48 --- /dev/null +++ b/test/app/ui_scale_overflow_test.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/app/app.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/features/main_screen/main_screen.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; + +import '../e2e/helpers/e2e_app_harness.dart'; +import '../e2e/helpers/e2e_command_palette_helper.dart'; +import '../support/layout_overflow.dart'; + +/// The interface scale never makes a screen overflow: the shell, Preferences, +/// the PostgreSQL form and the command palette are laid out at several scales +/// in the supported window sizes, and none may report a layout overflow. The +/// scale zooms the layout and is capped so that 900x600 always fits +/// (`QueryaUiZoom`). +void main() { + final app = E2eAppHarness(); + setUpAll(app.setUpAll); + tearDownAll(app.tearDownAll); + + final problems = []; + + String firstLine(FlutterErrorDetails d) { + final s = d.exceptionAsString().split('\n').first; + return s.length > 110 ? s.substring(0, 110) : s; + } + + Future measure( + WidgetTester tester, + String label, + Future Function() action, + ) async { + final errors = await collectOverflowErrorsDuring(() async { + await action(); + await pumpFrames(tester, count: 10); + }); + final distinct = {for (final e in errors) firstLine(e)}; + if (errors.isNotEmpty) { + problems.add('$label: ${errors.length} overflows, ' + 'e.g. ${distinct.take(2).join(' | ')}'); + } + // The probe reads the errors; they must not fail the test run. + tester.takeException(); + } + + for (final size in const [Size(1280, 720), Size(900, 600)]) { + for (final scale in const [0.75, 1.0, 1.25, 1.5, 2.0]) { + testWidgets('scale $scale at ${size.width.toInt()}x${size.height.toInt()}', + (tester) async { + await tester.runAsync(() => UiScaleController.instance.commitScale(scale)); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final tag = 'scale=$scale ${size.width.toInt()}x${size.height.toInt()}'; + + await measure(tester, '$tag shell', () async { + await tester.pumpWidget(const QueryaApp()); + await pumpFrames(tester, count: 20); + }); + final ctx = tester.element(find.byType(MainScreen)); + + await measure(tester, '$tag preferences', () async { + showPreferencesDialog(ctx); + await pumpFrames(tester, count: 20); + }); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await pumpFrames(tester, count: 10); + + await measure(tester, '$tag postgres form', () async { + unawaited(showPostgresConnectionForm(ctx)); + await pumpFrames(tester, count: 20); + }); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await pumpFrames(tester, count: 10); + + await measure(tester, '$tag command palette', () async { + await E2ePalette.open(tester); + }); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await app.close(tester); + }); + } + } + + test('no screen overflowed at any scale', () { + expect(problems, isEmpty, reason: problems.join('\n')); + }); +} diff --git a/test/core/layout/ui_zoom_test.dart b/test/core/layout/ui_zoom_test.dart new file mode 100644 index 00000000..03d1e4c8 --- /dev/null +++ b/test/core/layout/ui_zoom_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_zoom.dart'; + +void main() { + group('effectiveUiScale', () { + test('is the requested scale while the window has room for it', () { + expect( + effectiveUiScale(requested: 2.0, window: const Size(1800, 1200)), + 2.0, + ); + expect( + effectiveUiScale(requested: 0.75, window: const Size(1280, 720)), + 0.75, + ); + }); + + test('is capped so a 900x600 layout always fits', () { + // 1280x720 holds a 900x600 layout up to 1.2x (720 / 600). + expect( + effectiveUiScale(requested: 2.0, window: const Size(1280, 720)), + closeTo(1.2, 1e-9), + ); + expect( + effectiveUiScale(requested: 1.5, window: const Size(900, 600)), + 1.0, + ); + }); + + test('shrinks a window that is below the minimum, down to a floor', () { + expect( + effectiveUiScale(requested: 1.0, window: const Size(800, 500)), + closeTo(0.8333, 1e-3), + ); + expect( + effectiveUiScale(requested: 1.0, window: const Size(300, 200)), + kLowestUiZoom, + ); + }); + }); + + group('QueryaUiZoom', () { + Widget app({ + required double scale, + Duration duration = Duration.zero, + required Widget child, + }) => + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: QueryaUiZoom(scale: scale, duration: duration, child: child), + ), + ); + + testWidgets('lays the child out in a smaller window and paints it larger', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + Size? seen; + const key = Key('box'); + + await tester.pumpWidget(app( + scale: 2.0, + child: Builder(builder: (context) { + seen = MediaQuery.sizeOf(context); + return const Align( + alignment: Alignment.topLeft, + child: SizedBox(key: key, width: 100, height: 50), + ); + }), + )); + + expect(seen, const Size(900, 600)); + expect(tester.getRect(find.byKey(key)), const Rect.fromLTWH(0, 0, 200, 100)); + }); + + testWidgets('a tap lands on the widget under the pointer', (tester) async { + await tester.binding.setSurfaceSize(const Size(1800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + var taps = 0; + + await tester.pumpWidget(app( + scale: 2.0, + child: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onTap: () => taps++, + child: const SizedBox(key: Key('target'), width: 100, height: 50), + ), + ), + )); + // 100x50 at 2x covers (0,0)-(200,100) in the window. + await tester.tapAt(const Offset(150, 80)); + expect(taps, 1); + await tester.tapAt(const Offset(250, 80)); + expect(taps, 1); + }); + + testWidgets('windowToApp divides a window position by the zoom', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + Offset? app0; + + await tester.pumpWidget(app( + scale: 2.0, + child: Builder(builder: (context) { + app0 = context.windowToApp(const Offset(200, 100)); + return const SizedBox.shrink(); + }), + )); + + expect(app0, const Offset(100, 50)); + }); + + testWidgets('a small window caps the zoom and says so', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + Size? seen; + QueryaUiZoomScope? scope; + + await tester.pumpWidget(app( + scale: 2.0, + child: Builder(builder: (context) { + seen = MediaQuery.sizeOf(context); + scope = QueryaUiZoomScope.maybeOf(context); + return const SizedBox.shrink(); + }), + )); + + expect(seen!.width, closeTo(1280 / 1.2, 1e-6)); + expect(seen!.height, closeTo(600, 1e-6)); + expect(scope!.requested, 2.0); + expect(scope!.effective, closeTo(1.2, 1e-9)); + expect(scope!.isCapped, isTrue); + }); + + testWidgets('a change of scale is animated, not a jump', (tester) async { + await tester.binding.setSurfaceSize(const Size(1800, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final seen = []; + Widget build(double scale) => app( + scale: scale, + duration: const Duration(milliseconds: 200), + child: Builder(builder: (context) { + seen.add(QueryaUiZoomScope.maybeOf(context)!.effective); + return const SizedBox.shrink(); + }), + ); + + await tester.pumpWidget(build(1.0)); + await tester.pumpWidget(build(1.5)); + await tester.pump(const Duration(milliseconds: 100)); + final midway = seen.last; + await tester.pump(const Duration(milliseconds: 150)); + + expect(midway, greaterThan(1.0)); + expect(midway, lessThan(1.5)); + expect(seen.last, closeTo(1.5, 1e-9)); + }); + }); +} From 4aae21e707334f1db00db74c836406fa1d8afea3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 15:44:46 +0300 Subject: [PATCH 2/2] test(zoom): the tap target must be hit-testable --- test/core/layout/ui_zoom_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/core/layout/ui_zoom_test.dart b/test/core/layout/ui_zoom_test.dart index 03d1e4c8..296a4e6d 100644 --- a/test/core/layout/ui_zoom_test.dart +++ b/test/core/layout/ui_zoom_test.dart @@ -85,6 +85,8 @@ void main() { child: Align( alignment: Alignment.topLeft, child: GestureDetector( + // An empty SizedBox is not hit-testable by itself. + behavior: HitTestBehavior.opaque, onTap: () => taps++, child: const SizedBox(key: Key('target'), width: 100, height: 50), ),