diff --git a/lib/core/layout/ui_zoom.dart b/lib/core/layout/ui_zoom.dart index 50841de5..692b241c 100644 --- a/lib/core/layout/ui_zoom.dart +++ b/lib/core/layout/ui_zoom.dart @@ -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!, ); @@ -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, ), ); @@ -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, diff --git a/lib/core/motion/querya_cross_fade_stack.dart b/lib/core/motion/querya_cross_fade_stack.dart index 9459d9cf..9bfa6b72 100644 --- a/lib/core/motion/querya_cross_fade_stack.dart +++ b/lib/core/motion/querya_cross_fade_stack.dart @@ -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 @@ -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]), diff --git a/lib/core/motion/querya_fade_slide.dart b/lib/core/motion/querya_fade_slide.dart index d954b6d5..a871b722 100644 --- a/lib/core/motion/querya_fade_slide.dart +++ b/lib/core/motion/querya_fade_slide.dart @@ -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, @@ -43,8 +44,14 @@ class QueryaFadeSlide extends StatelessWidget { transitionBuilder: (child, animation) { final slide = Tween(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, diff --git a/lib/features/main_screen/main_content_split.dart b/lib/features/main_screen/main_content_split.dart index 808ae8bc..00141215 100644 --- a/lib/features/main_screen/main_content_split.dart +++ b/lib/features/main_screen/main_content_split.dart @@ -391,8 +391,21 @@ class MainContentSplitState extends State 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: [ @@ -423,7 +436,8 @@ class MainContentSplitState extends State 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( diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 7d60bdf1..69fa12aa 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -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, diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index 3a1efac0..c1ba4d5f 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -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( begin: material.Offset(0.04 * side, 0), @@ -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: diff --git a/lib/features/workspace/grid/grid_cell_renderer.dart b/lib/features/workspace/grid/grid_cell_renderer.dart index 562f7ccc..3343f4da 100644 --- a/lib/features/workspace/grid/grid_cell_renderer.dart +++ b/lib/features/workspace/grid/grid_cell_renderer.dart @@ -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, @@ -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, @@ -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), @@ -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, ), ); } diff --git a/test/core/layout/ui_zoom_test.dart b/test/core/layout/ui_zoom_test.dart index 296a4e6d..599c142c 100644 --- a/test/core/layout/ui_zoom_test.dart +++ b/test/core/layout/ui_zoom_test.dart @@ -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 = []; + 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))); + }); }); } diff --git a/test/features/main_screen/sidebar_toggle_test.dart b/test/features/main_screen/sidebar_toggle_test.dart index c2bd0e5c..9c51bd26 100644 --- a/test/features/main_screen/sidebar_toggle_test.dart +++ b/test/features/main_screen/sidebar_toggle_test.dart @@ -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(find.byType(WorkspacePanel)); - expect(intermediateBox.constraints.maxWidth, totalWidth); + expect( + intermediateBox.constraints.maxWidth, + closeTo(expectedExpandedWidth, 0.5), + ); } await tester.pumpAndSettle(); diff --git a/test/features/workspace/result_grid_cell_ux_test.dart b/test/features/workspace/result_grid_cell_ux_test.dart index 5c23352a..13c0cd92 100644 --- a/test/features/workspace/result_grid_cell_ux_test.dart +++ b/test/features/workspace/result_grid_cell_ux_test.dart @@ -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(