diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 49708ee4..aeb6e966 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart' as material; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// A compact, keyboard-operable tab strip using Querya's motion and theme. +/// +/// Selection uses a sliding pill indicator (spring when [QueryaSpring.springsEnabled]) +/// so tab changes feel continuous / redirectable. class QueryaTabStrip extends material.StatefulWidget { const QueryaTabStrip({ super.key, @@ -21,22 +27,46 @@ class QueryaTabStrip extends material.StatefulWidget { material.State createState() => _QueryaTabStripState(); } -class _QueryaTabStripState extends material.State { +class _QueryaTabStripState extends material.State + with material.TickerProviderStateMixin { late List _focusNodes; late List _focused; + late List _tabKeys; + final material.GlobalKey _stripKey = material.GlobalKey(); + + late final QueryaSpringController _indicatorLeft; + late final QueryaSpringController _indicatorWidth; + var _indicatorReady = false; + var _layoutScheduled = false; @override void initState() { super.initState(); + _indicatorLeft = QueryaSpringController(vsync: this); + _indicatorWidth = QueryaSpringController(vsync: this); _createFocusState(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final springs = QueryaSpring.springsEnabled(context); + _indicatorLeft.useSprings = springs; + _indicatorWidth.useSprings = springs; + } + @override void didUpdateWidget(covariant QueryaTabStrip oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.labels.length != widget.labels.length) { _disposeFocusNodes(); _createFocusState(); + _indicatorReady = false; + } + if (oldWidget.selectedIndex != widget.selectedIndex || + oldWidget.labels.length != widget.labels.length || + !_listEquals(oldWidget.labels, widget.labels)) { + _scheduleIndicatorSync(); } } @@ -47,6 +77,7 @@ class _QueryaTabStripState extends material.State { material.FocusNode(debugLabel: 'Querya tab ${widget.labels[index]}'), ); _focused = List.filled(widget.labels.length, false); + _tabKeys = List.generate(widget.labels.length, (_) => material.GlobalKey()); } void _disposeFocusNodes() { @@ -57,6 +88,8 @@ class _QueryaTabStripState extends material.State { @override void dispose() { + _indicatorLeft.dispose(); + _indicatorWidth.dispose(); _disposeFocusNodes(); super.dispose(); } @@ -82,64 +115,151 @@ class _QueryaTabStripState extends material.State { return material.KeyEventResult.handled; } + void _scheduleIndicatorSync() { + if (_layoutScheduled) return; + _layoutScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _layoutScheduled = false; + if (!mounted) return; + _syncIndicator(); + }); + } + + void _syncIndicator() { + final safeIndex = widget.selectedIndex.clamp(0, widget.labels.length - 1); + final tabContext = _tabKeys[safeIndex].currentContext; + final stripContext = _stripKey.currentContext; + if (tabContext == null || stripContext == null) return; + + final tabBox = tabContext.findRenderObject(); + final stripBox = stripContext.findRenderObject(); + if (tabBox is! material.RenderBox || stripBox is! material.RenderBox) { + return; + } + if (!tabBox.hasSize || !stripBox.hasSize) return; + + final offset = + tabBox.localToGlobal(material.Offset.zero, ancestor: stripBox); + final width = tabBox.size.width; + + if (!_indicatorReady) { + _indicatorLeft.jumpTo(offset.dx); + _indicatorWidth.jumpTo(width); + setState(() => _indicatorReady = true); + return; + } + + _indicatorLeft.animateTo(offset.dx); + _indicatorWidth.animateTo(width); + } + @override material.Widget build(material.BuildContext context) { final colors = Theme.of(context).colorScheme; - return material.Row( - mainAxisSize: material.MainAxisSize.min, - children: List.generate(widget.labels.length, (index) { - final selected = widget.selectedIndex == index; - final focused = _focused[index]; - final label = widget.labels[index]; - return material.Padding( - padding: material.EdgeInsets.only(left: index == 0 ? 0 : 6), - child: material.Focus( - focusNode: _focusNodes[index], - onFocusChange: (value) => setState(() => _focused[index] = value), - onKeyEvent: (_, event) => _onKeyEvent(index, event), - child: material.Semantics( - button: true, - selected: selected, - label: label, - onTap: () => _selectAndFocus(index), - child: material.ExcludeSemantics( - child: material.MouseRegion( - cursor: material.SystemMouseCursors.click, - child: material.GestureDetector( - excludeFromSemantics: true, - behavior: material.HitTestBehavior.opaque, - onTap: () => _selectAndFocus(index), - child: material.AnimatedContainer( - key: material.ValueKey('querya_tab_$label'), - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), + _scheduleIndicatorSync(); + + return material.KeyedSubtree( + key: _stripKey, + child: material.ListenableBuilder( + listenable: material.Listenable.merge([ + _indicatorLeft, + _indicatorWidth, + ]), + builder: (context, _) { + return material.Stack( + alignment: material.Alignment.centerLeft, + children: [ + if (_indicatorReady && _indicatorWidth.value > 0) + material.Positioned( + key: const material.ValueKey('querya_tab_indicator'), + left: _indicatorLeft.value, + width: _indicatorWidth.value, + top: 0, + bottom: 0, + child: material.IgnorePointer( + child: material.DecoratedBox( decoration: material.BoxDecoration( - color: selected - ? colors.background - : material.Colors.transparent, + color: colors.background, borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: focused - ? colors.ring - : material.Colors.transparent, - width: 2, - ), ), - child: selected - ? Text(label).small().semiBold() - : Text(label).small().muted(), ), ), ), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: List.generate(widget.labels.length, (index) { + final selected = widget.selectedIndex == index; + final focused = _focused[index]; + final label = widget.labels[index]; + return material.Padding( + padding: + material.EdgeInsets.only(left: index == 0 ? 0 : 6), + child: material.KeyedSubtree( + key: _tabKeys[index], + child: material.Focus( + focusNode: _focusNodes[index], + onFocusChange: (value) => + setState(() => _focused[index] = value), + onKeyEvent: (_, event) => _onKeyEvent(index, event), + child: material.Semantics( + button: true, + selected: selected, + label: label, + onTap: () => _selectAndFocus(index), + child: material.ExcludeSemantics( + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + excludeFromSemantics: true, + behavior: material.HitTestBehavior.opaque, + onTap: () => _selectAndFocus(index), + child: material.AnimatedContainer( + key: material.ValueKey('querya_tab_$label'), + duration: context + .motionDuration(QueryaMotion.fast), + curve: context + .motionCurve(QueryaMotion.enter), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: material.Colors.transparent, + borderRadius: + material.BorderRadius.circular(6), + border: material.Border.all( + color: focused + ? colors.ring + : material.Colors.transparent, + width: 2, + ), + ), + child: selected + ? Text(label).small().semiBold() + : Text(label).small().muted(), + ), + ), + ), + ), + ), + ), + ), + ); + }), ), - ), - ), - ); - }), + ], + ); + }, + ), ); } } + +bool _listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/test/features/main_screen/shell_chrome_golden_test.dart b/test/features/main_screen/shell_chrome_golden_test.dart index 3e31bc49..5207c68a 100644 --- a/test/features/main_screen/shell_chrome_golden_test.dart +++ b/test/features/main_screen/shell_chrome_golden_test.dart @@ -45,7 +45,9 @@ void main() { ), ), ); + // Wait for post-frame indicator layout + spring settle. await tester.pump(); + await tester.pumpAndSettle(); await expectLater( find.byType(QueryaTabStrip), diff --git a/test/shared/querya_tab_strip_test.dart b/test/shared/querya_tab_strip_test.dart index 83d8bc65..61fc4c6e 100644 --- a/test/shared/querya_tab_strip_test.dart +++ b/test/shared/querya_tab_strip_test.dart @@ -3,24 +3,61 @@ import 'dart:ui' show Tristate; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; import '../support/querya_theme_test_shell.dart'; void main() { - testWidgets('exposes button and selected semantics', (tester) async { - var selected = 0; + material.Widget stripShell({ + required material.Widget child, + QueryaMotionLevel level = QueryaMotionLevel.full, + }) { + return queryaThemeTestShell( + child: QueryaMotionScope( + level: level, + child: child, + ), + ); + } + + material.Positioned indicatorOf(WidgetTester tester) { + return tester.widget( + find.byKey(const material.ValueKey('querya_tab_indicator')), + ); + } + + Future pumpSettledStrip( + WidgetTester tester, { + required int selected, + required material.ValueChanged onSelected, + QueryaMotionLevel level = QueryaMotionLevel.full, + List labels = const ['Server', 'SQL', 'History'], + }) async { await tester.pumpWidget( - queryaThemeTestShell( - child: material.StatefulBuilder( - builder: (context, setState) => QueryaTabStrip( - labels: const ['Server', 'SQL'], + stripShell( + level: level, + child: material.Center( + child: QueryaTabStrip( + labels: labels, selectedIndex: selected, - onSelected: (index) => setState(() => selected = index), + onSelected: onSelected, ), ), ), ); + await tester.pump(); // post-frame indicator sync + await tester.pumpAndSettle(); + } + + testWidgets('exposes button and selected semantics', (tester) async { + var selected = 0; + await pumpSettledStrip( + tester, + selected: selected, + onSelected: (index) => selected = index, + labels: const ['Server', 'SQL'], + ); final server = tester.getSemantics(find.bySemanticsLabel('Server')); final sql = tester.getSemantics(find.bySemanticsLabel('SQL')); @@ -34,7 +71,7 @@ void main() { (tester) async { var selected = 0; await tester.pumpWidget( - queryaThemeTestShell( + stripShell( child: material.StatefulBuilder( builder: (context, setState) => QueryaTabStrip( labels: const ['Server', 'SQL', 'History'], @@ -44,6 +81,8 @@ void main() { ), ), ); + await tester.pump(); + await tester.pumpAndSettle(); await tester.tap(find.bySemanticsLabel('Server')); await tester.pump(); @@ -72,4 +111,163 @@ void main() { isNot(material.Colors.transparent), ); }); + + testWidgets('renders sliding indicator under selected tab', (tester) async { + await pumpSettledStrip( + tester, + selected: 1, + onSelected: (_) {}, + ); + + expect(find.byKey(const material.ValueKey('querya_tab_indicator')), + findsOneWidget); + final indicator = indicatorOf(tester); + final sql = tester.getRect(find.byKey(const material.ValueKey('querya_tab_SQL'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + + expect(indicator.left, closeTo(sql.left - strip.left, 1.0)); + expect(indicator.width, closeTo(sql.width, 1.0)); + }); + + testWidgets('indicator slides toward newly selected tab (full motion)', + (tester) async { + var selected = 0; + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final startLeft = indicatorOf(tester).left!; + + await tester.tap(find.bySemanticsLabel('History')); + await tester.pump(); // selection + schedule + await tester.pump(); // post-frame sync starts spring + await tester.pump(const Duration(milliseconds: 40)); + + final midLeft = indicatorOf(tester).left!; + expect(midLeft, greaterThan(startLeft)); + + await tester.pumpAndSettle(); + final history = + tester.getRect(find.byKey(const material.ValueKey('querya_tab_History'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + expect( + indicatorOf(tester).left, + closeTo(history.left - strip.left, 1.0), + ); + }); + + testWidgets('motion off snaps indicator without mid-flight offset', + (tester) async { + var selected = 0; + await tester.pumpWidget( + stripShell( + level: QueryaMotionLevel.off, + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel('History')); + await tester.pump(); + await tester.pump(); // post-frame jump + + final history = + tester.getRect(find.byKey(const material.ValueKey('querya_tab_History'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + expect( + indicatorOf(tester).left, + closeTo(history.left - strip.left, 1.0), + ); + }); + + testWidgets('redirect mid-slide settles on final selection', (tester) async { + var selected = 0; + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel('History')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + + await tester.tap(find.bySemanticsLabel('SQL')); + await tester.pump(); + await tester.pump(); + await tester.pumpAndSettle(); + + final sql = tester.getRect(find.byKey(const material.ValueKey('querya_tab_SQL'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + expect( + indicatorOf(tester).left, + closeTo(sql.left - strip.left, 1.0), + ); + expect(selected, 1); + }); + + testWidgets('OS disableAnimations snaps indicator', (tester) async { + var selected = 0; + await tester.pumpWidget( + material.MediaQuery( + data: const material.MediaQueryData(disableAnimations: true), + child: stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel('SQL')); + await tester.pump(); + await tester.pump(); + + final sql = tester.getRect(find.byKey(const material.ValueKey('querya_tab_SQL'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + expect( + indicatorOf(tester).left, + closeTo(sql.left - strip.left, 1.0), + ); + }); }