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
36 changes: 16 additions & 20 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
),
),
);
Expand Down
15 changes: 12 additions & 3 deletions lib/core/layout/ui_scale_controller.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<void> 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<void> setScale(double value, {bool fine = false}) =>
Expand Down
164 changes: 164 additions & 0 deletions lib/core/layout/ui_zoom.dart
Original file line number Diff line number Diff line change
@@ -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<QueryaUiZoomScope>();

/// 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<double>(
tween: Tween<double>(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,
),
),
),
),
);
}
}
23 changes: 17 additions & 6 deletions lib/features/settings/preferences_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ class _InterfaceScaleSliderState extends material.State<InterfaceScaleSlider> {
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();

Expand Down Expand Up @@ -270,6 +275,7 @@ class _InterfaceScaleSliderState extends material.State<InterfaceScaleSlider> {
}

bool _onKeyEvent(KeyEvent event) {
if (_dragging) return false;
final fine = _shiftHeld;
if (fine != _fineControl) {
setState(() => _fineControl = fine);
Expand Down Expand Up @@ -313,18 +319,23 @@ class _InterfaceScaleSliderState extends material.State<InterfaceScaleSlider> {
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),
);
},
),
Expand Down
3 changes: 2 additions & 1 deletion lib/features/workspace/grid/grid_cell_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
Expand Down
4 changes: 3 additions & 1 deletion lib/features/workspace/grid/grid_pointer_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions lib/features/workspace/result_grid_view.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -337,8 +338,10 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
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) {
Expand Down
Loading
Loading