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
34 changes: 21 additions & 13 deletions lib/core/layout/ui_zoom.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ class QueryaUiZoom extends StatelessWidget {
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);
// The layout is done once, for the zoom the change ends at; only
// the painting follows the animation. Laying out at every step
// re-wrapped text and moved cards on each frame, which read as
// jumps. The window cap applies at once: a resize must not lag.
return _Zoomed(
requested: scale,
effective: effective,
layoutZoom: effectiveUiScale(requested: scale, window: window),
paintZoom: effectiveUiScale(requested: animated, window: window),
window: window,
child: child!,
);
Expand All @@ -110,31 +112,37 @@ class QueryaUiZoom extends StatelessWidget {
class _Zoomed extends StatelessWidget {
const _Zoomed({
required this.requested,
required this.effective,
required this.layoutZoom,
required this.paintZoom,
required this.window,
required this.child,
});

final double requested;
final double effective;

/// The zoom the layout is made for: the one an animation ends at.
final double layoutZoom;

/// The zoom the layout is painted at this frame.
final double paintZoom;
final Size window;
final Widget child;

@override
Widget build(BuildContext context) {
final logical = Size(window.width / effective, window.height / effective);
final logical = Size(window.width / layoutZoom, window.height / layoutZoom);
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,
padding: media.padding / layoutZoom,
viewPadding: media.viewPadding / layoutZoom,
viewInsets: media.viewInsets / layoutZoom,
systemGestureInsets: media.systemGestureInsets / layoutZoom,
),
child: QueryaUiZoomScope(
requested: requested,
effective: effective,
effective: paintZoom,
child: child,
),
);
Expand All @@ -150,7 +158,7 @@ class _Zoomed extends StatelessWidget {
maxHeight: double.infinity,
child: Transform(
alignment: Alignment.topLeft,
transform: Matrix4.diagonal3Values(effective, effective, 1),
transform: Matrix4.diagonal3Values(paintZoom, paintZoom, 1),
child: SizedBox(
width: logical.width,
height: logical.height,
Expand Down
10 changes: 8 additions & 2 deletions lib/core/motion/querya_cross_fade_stack.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
import 'querya_motion.dart';
import 'querya_motion_context.dart';

/// Like [IndexedStack] but cross-fades the active child; off-screen children
/// Like [IndexedStack] but fades through to the active child (the old one out,
/// then the new one in); off-screen children
/// stay mounted (preserves SQL editor state, etc.).
///
/// Enter/exit curves and index clamping match [QueryaSwitchingBody] (without
Expand Down Expand Up @@ -37,10 +38,15 @@ class QueryaCrossFadeStack extends StatelessWidget {
excluding: i != safeIndex,
child: ExcludeSemantics(
excluding: i != safeIndex,
// Fade through: the outgoing child is gone by the half-way
// point and the incoming one starts there, so the two pages
// are never seen through each other.
child: AnimatedOpacity(
opacity: i == safeIndex ? 1 : 0,
duration: duration,
curve: i == safeIndex ? inCurve : outCurve,
curve: i == safeIndex
? Interval(0.5, 1.0, curve: inCurve)
: Interval(0.0, 0.5, curve: outCurve),
child: TickerMode(
enabled: i == safeIndex,
child: RepaintBoundary(child: children[i]),
Expand Down
11 changes: 9 additions & 2 deletions lib/core/motion/querya_fade_slide.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import 'package:flutter/material.dart';
import 'querya_motion.dart';
import 'querya_motion_context.dart';

/// Fades and optionally slides [child] when the keyed child changes.
/// Fades through (out, then in) and optionally slides [child] when the keyed
/// child changes.
///
/// Uses duration-token cubic curves ([QueryaMotion.standard] / [QueryaMotion.enter]),
/// not [QueryaSpring] — reserve springs for interruptible physics (tab indicator,
Expand Down Expand Up @@ -43,8 +44,14 @@ class QueryaFadeSlide extends StatelessWidget {
transitionBuilder: (child, animation) {
final slide = Tween<Offset>(begin: offset, end: Offset.zero)
.animate(animation);
// Fade through, not cross-fade: the outgoing child (its animation runs
// back from 1) is gone by the half-way point and the incoming one only
// shows after it, so two pages are never seen through each other.
return FadeTransition(
opacity: animation,
opacity: CurvedAnimation(
parent: animation,
curve: const Interval(0.5, 1.0),
),
child: SlideTransition(
position: slide,
child: child,
Expand Down
18 changes: 16 additions & 2 deletions lib/features/main_screen/main_content_split.dart
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,21 @@ class MainContentSplitState extends State<MainContentSplit>
final isAnimating = _widthSpring.isAnimating;
final remainingWidth =
math.max(0.0, constraints.maxWidth - currentW - currentHandleW);
// While the sidebar slides, the workspace is laid out once at the width
// it ends with and pinned to the right edge: it stands still and the
// sidebar covers or uncovers its left side. Laid out at the full window
// width and pinned left, its right side ran past the window and it
// snapped to the narrower width when the sidebar finished opening.
final settledWidth = _sidebarVisible
? math.max(
0.0,
constraints.maxWidth -
_clampLeftWidth(_lastExpandedWidth, constraints.maxWidth) -
_resizeHandleWidth,
)
: constraints.maxWidth;
final workspaceLaidOutWidth =
isAnimating ? constraints.maxWidth : remainingWidth;
isAnimating ? settledWidth : remainingWidth;

return Row(
children: [
Expand Down Expand Up @@ -423,7 +436,8 @@ class MainContentSplitState extends State<MainContentSplit>
key: const ValueKey('main_content_workspace_expanded'),
child: ClipRect(
child: OverflowBox(
alignment: Alignment.topLeft,
alignment:
isAnimating ? Alignment.topRight : Alignment.topLeft,
minWidth: workspaceLaidOutWidth,
maxWidth: workspaceLaidOutWidth,
child: material.RepaintBoundary(
Expand Down
2 changes: 1 addition & 1 deletion lib/features/main_screen/workspace_empty_hero.dart
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ class _QuickStartSection extends StatelessWidget {
icon: material.Icons.auto_awesome_rounded,
title: 'Interactive Quick Tour',
description:
'Learn connections, shortcuts & grid navigation in 4 visual steps',
'Learn connections, shortcuts & grid navigation in 6 visual steps',
color: primary,
actionLabel: 'Start Tour',
onTap: openTour,
Expand Down
20 changes: 18 additions & 2 deletions lib/features/onboarding/welcome_tour_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,13 @@ onPressed: _finish,
final incoming =
child.key == material.ValueKey(_currentStep);
final side = (_forward ? 1.0 : -1.0) * (incoming ? 1 : -1);
// Fade through: the old step is gone before the new one
// shows, so their texts never overlap.
return material.FadeTransition(
opacity: animation,
opacity: material.CurvedAnimation(
parent: animation,
curve: const material.Interval(0.5, 1.0),
),
child: material.SlideTransition(
position: material.Tween<material.Offset>(
begin: material.Offset(0.04 * side, 0),
Expand Down Expand Up @@ -440,7 +445,18 @@ onPressed: _finish,
'Execute scripts with $cmdCtrl+Enter or F5. Open multiple query tabs, format SQL with $cmdCtrl+Shift+F, and access persistent query history.',
tipText:
'Use $cmdCtrl+T or $cmdCtrl+Shift+N to spawn a fresh SQL editor tab.',
shortcutBadge: KbdBadge(['F5', 'or', '$cmdCtrl+Enter']),
// Two shortcuts, not one chord: KbdBadge joins its keys with "+".
shortcutBadge: material.Row(
mainAxisSize: material.MainAxisSize.min,
children: [
const KbdBadge(['F5']),
const material.Padding(
padding: material.EdgeInsets.symmetric(horizontal: 6),
child: Text('or'),
),
KbdBadge([cmdCtrl, 'Enter']),
],
),
wb: wb,
);
case 3:
Expand Down
65 changes: 40 additions & 25 deletions lib/features/workspace/grid/grid_cell_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ class _GridCell extends material.StatelessWidget {
: (striped
? _CellBackground.striped
: _CellBackground.none)))),
);
final selectionEdge = _CellLook.selectionEdge(
colors,
right: isSelectionRight,
left: isSelectionLeft,
top: isSelectionTop,
Expand All @@ -144,6 +147,9 @@ class _GridCell extends material.StatelessWidget {
padding: const material.EdgeInsets.symmetric(horizontal: 10),
alignment: material.Alignment.centerLeft,
decoration: decoration,
// On top, not in [decoration]: a border there is added to the padding,
// so the text of a selected cell moved by the border's width.
foregroundDecoration: selectionEdge,
child: material.Stack(
clipBehavior: material.Clip.none,
alignment: material.Alignment.centerLeft,
Expand Down Expand Up @@ -215,18 +221,11 @@ abstract final class _CellLook {
static material.BoxDecoration decoration(
QueryaUiColors scheme, {
required _CellBackground background,
required bool right,
required bool left,
required bool top,
required bool bottom,
}) {
_forget(scheme);
final key = background.index |
(right ? 1 << 3 : 0) |
(left ? 1 << 4 : 0) |
(top ? 1 << 5 : 0) |
(bottom ? 1 << 6 : 0);
return _decorations[key] ??= material.BoxDecoration(
// The grid lines are the same in every cell, so the content of every cell
// sits at the same place; the selection outline is [selectionEdge].
return _decorations[background.index] ??= material.BoxDecoration(
color: switch (background) {
_CellBackground.selected => scheme.primary.withValues(alpha: 0.18),
_CellBackground.deleted => scheme.destructive.withValues(alpha: 0.08),
Expand All @@ -237,22 +236,38 @@ abstract final class _CellLook {
},
border: material.Border(
right: material.BorderSide(
color: right
? scheme.primary
: scheme.border.withValues(alpha: 0.3),
width: right ? 1.5 : 1.0,
color: scheme.border.withValues(alpha: 0.3),
),
left: left
? material.BorderSide(color: scheme.primary, width: 1.5)
: material.BorderSide.none,
top: top
? material.BorderSide(color: scheme.primary, width: 1.5)
: material.BorderSide.none,
bottom: bottom
? material.BorderSide(color: scheme.primary, width: 1.5)
: material.BorderSide(
color: scheme.border.withValues(alpha: 0.15),
),
bottom: material.BorderSide(
color: scheme.border.withValues(alpha: 0.15),
),
),
);
}

/// The outline of the selected range on the sides of this cell that are an
/// edge of it, painted over the cell; null when the cell has none.
static material.BoxDecoration? selectionEdge(
QueryaUiColors scheme, {
required bool right,
required bool left,
required bool top,
required bool bottom,
}) {
if (!(right || left || top || bottom)) return null;
_forget(scheme);
final key = 1 << 8 |
(right ? 1 : 0) |
(left ? 2 : 0) |
(top ? 4 : 0) |
(bottom ? 8 : 0);
final edge = material.BorderSide(color: scheme.primary, width: 1.5);
return _decorations[key] ??= material.BoxDecoration(
border: material.Border(
right: right ? edge : material.BorderSide.none,
left: left ? edge : material.BorderSide.none,
top: top ? edge : material.BorderSide.none,
bottom: bottom ? edge : material.BorderSide.none,
),
);
}
Expand Down
29 changes: 29 additions & 0 deletions test/core/layout/ui_zoom_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,5 +161,34 @@ void main() {
expect(midway, lessThan(1.5));
expect(seen.last, closeTo(1.5, 1e-9));
});

testWidgets('the layout is made once for the zoom a change ends at',
(tester) async {
await tester.binding.setSurfaceSize(const Size(1800, 1200));
addTearDown(() => tester.binding.setSurfaceSize(null));
final sizes = <Size>[];
Widget build(double scale) => app(
scale: scale,
duration: const Duration(milliseconds: 200),
child: Builder(builder: (context) {
// Depends on the zoom scope too, so it rebuilds every frame.
QueryaUiZoomScope.maybeOf(context);
sizes.add(MediaQuery.sizeOf(context));
return const SizedBox.shrink();
}),
);

await tester.pumpWidget(build(1.0));
await tester.pumpWidget(build(1.5));
sizes.clear();
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(const Duration(milliseconds: 150));

// Only the painting animates: every frame lays out at the final size,
// so text does not re-wrap on each frame.
expect(sizes, isNotEmpty);
expect(sizes, everyElement(const Size(1200, 800)));
});
});
}
9 changes: 7 additions & 2 deletions test/features/main_screen/sidebar_toggle_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -308,12 +308,17 @@ void main() {
await tester.pump();

// Intermediate animation frames while expanding: workspace constraints
// must remain held stable at constraints.maxWidth instead of shrinking every frame.
// stay held at the width the layout ends with (not shrinking every
// frame, and not the full window width, which ran the content past the
// window edge until the animation finished).
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 16));
final intermediateBox =
tester.renderObject<RenderBox>(find.byType(WorkspacePanel));
expect(intermediateBox.constraints.maxWidth, totalWidth);
expect(
intermediateBox.constraints.maxWidth,
closeTo(expectedExpandedWidth, 0.5),
);
}

await tester.pumpAndSettle();
Expand Down
27 changes: 27 additions & 0 deletions test/features/workspace/result_grid_cell_ux_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@ void main() {
});
});

testWidgets('selecting a cell does not move its text', (tester) async {
await tester.pumpWidget(
queryaThemeTestShell(
child: const material.SizedBox(
width: 800,
height: 400,
child: VirtualResultGrid(
columns: ['id', 'name'],
rows: [
['1', 'Ada'],
['2', 'Bob'],
],
),
),
),
);
await tester.pumpAndSettle();
final before = tester.getTopLeft(find.text('Ada'));

await tester.tap(find.text('Ada'));
await tester.pumpAndSettle();

// The selection outline is painted over the cell, not added to its
// padding: the text stays where it was (it moved by the border width).
expect(tester.getTopLeft(find.text('Ada')), before);
});

group('VirtualResultGrid cell hover', () {
testWidgets('editable cells use the text cursor', (tester) async {
final staging = DataGridStagingBuffer(
Expand Down
Loading