From e77a601e9a0a4a3e0796762e244be7f4c74cf2ef Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Jul 2026 23:07:57 +0300 Subject: [PATCH 1/2] fix(ui): persist sidebar width on keyboard resize and delay dropdown exit Keyboard/semantics split resize and dispose mid-settle no longer drop the connections panel width; item-pick closes menus after fade-slide exit. Closes #354 Co-authored-by: Cursor --- lib/core/layout/querya_split_handle.dart | 17 ++++- .../connections_panel_width_persist.dart | 49 ++++++++++++ lib/features/main_screen/main_screen.dart | 15 +++- lib/shared/widgets/querya_dropdown.dart | 44 ++++++++++- .../core/layout/querya_split_handle_test.dart | 41 ++++++++++ .../connections_panel_width_persist_test.dart | 76 +++++++++++++++++++ test/shared/querya_dropdown_test.dart | 46 +++++++++++ 7 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 lib/features/main_screen/connections_panel_width_persist.dart create mode 100644 test/features/main_screen/connections_panel_width_persist_test.dart diff --git a/lib/core/layout/querya_split_handle.dart b/lib/core/layout/querya_split_handle.dart index 7410a7a9..6126f426 100644 --- a/lib/core/layout/querya_split_handle.dart +++ b/lib/core/layout/querya_split_handle.dart @@ -17,6 +17,7 @@ class QueryaSplitHandle extends material.StatefulWidget { this.semanticsValue, this.keyboardStep = 10, this.onDragEnd, + this.onDiscreteResize, }); /// Direction in which the handle moves. @@ -29,6 +30,11 @@ class QueryaSplitHandle extends material.StatefulWidget { /// Called when a pointer drag ends (not keyboard). Use velocity for settle. final material.ValueChanged? onDragEnd; + /// Called after a keyboard / semantics step (not mid-pointer-drag). + /// + /// Use to persist layout when the user resizes without a drag-end event. + final material.VoidCallback? onDiscreteResize; + @override material.State createState() => _QueryaSplitHandleState(); } @@ -60,10 +66,15 @@ class _QueryaSplitHandleState extends material.State { _ => null, }; if (delta == null) return material.KeyEventResult.ignored; - widget.onDragDelta(delta); + _applyDiscrete(delta); return material.KeyEventResult.handled; } + void _applyDiscrete(double delta) { + widget.onDragDelta(delta); + widget.onDiscreteResize?.call(); + } + @override material.Widget build(material.BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -82,8 +93,8 @@ class _QueryaSplitHandleState extends material.State { decreasedValue: widget.semanticsValue, focusable: true, focused: _focused, - onIncrease: () => widget.onDragDelta(widget.keyboardStep), - onDecrease: () => widget.onDragDelta(-widget.keyboardStep), + onIncrease: () => _applyDiscrete(widget.keyboardStep), + onDecrease: () => _applyDiscrete(-widget.keyboardStep), child: material.MouseRegion( cursor: horizontal ? material.SystemMouseCursors.resizeColumn diff --git a/lib/features/main_screen/connections_panel_width_persist.dart b/lib/features/main_screen/connections_panel_width_persist.dart new file mode 100644 index 00000000..91f7af78 --- /dev/null +++ b/lib/features/main_screen/connections_panel_width_persist.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +/// Debounced + flush-on-dispose persistence for the connections sidebar width. +/// +/// Keyboard/semantics resize has no drag-end; drag settle may outlive the +/// widget. Callers mark dirty and either debounce, wait for settle, or flush. +class ConnectionsPanelWidthPersist { + ConnectionsPanelWidthPersist({ + required Future Function(double width) write, + this.discreteDebounce = const Duration(milliseconds: 300), + }) : _write = write; + + final Future Function(double width) _write; + final Duration discreteDebounce; + + Timer? _discreteTimer; + var dirty = false; + + void markDirty() => dirty = true; + + Future persist(double width) async { + await _write(width); + dirty = false; + } + + /// Keyboard / semantics step — no drag-end; debounce writes. + /// + /// [currentWidth] is read when the timer fires so rapid steps persist the + /// latest value, not a stale snapshot from the first keypress. + void onDiscreteResize(double Function() currentWidth) { + markDirty(); + _discreteTimer?.cancel(); + _discreteTimer = Timer(discreteDebounce, () { + unawaited(persist(currentWidth())); + }); + } + + void cancelDiscreteTimer() => _discreteTimer?.cancel(); + + /// Cancel timers and flush if a resize was not yet written. + void disposeFlush(double width) { + _discreteTimer?.cancel(); + _discreteTimer = null; + if (dirty) { + unawaited(_write(width)); + dirty = false; + } + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index abeaf84e..307fd937 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -17,6 +17,7 @@ import 'package:querya_desktop/features/connections/connection_creation_flow.dar import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; +import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; @@ -354,12 +355,16 @@ class _MainContentSplitState extends State<_MainContentSplit> final ValueNotifier _leftPanelWidth = ValueNotifier(kDefaultConnectionsPanelWidth); late final QueryaDragSettleController _widthSettle; + late final ConnectionsPanelWidthPersist _widthPersist; VoidCallback? _persistWhenSettled; double _lastMaxWidth = 1200; @override void initState() { super.initState(); + _widthPersist = ConnectionsPanelWidthPersist( + write: AppSettings.instance.setConnectionsPanelWidth, + ); _widthSettle = QueryaDragSettleController( vsync: this, value: kDefaultConnectionsPanelWidth, @@ -387,6 +392,7 @@ class _MainContentSplitState extends State<_MainContentSplit> } void _schedulePersistAfterSettle() { + _widthPersist.markDirty(); final pending = _persistWhenSettled; if (pending != null) { _widthSettle.removeListener(pending); @@ -395,9 +401,7 @@ class _MainContentSplitState extends State<_MainContentSplit> if (_widthSettle.isSettling) return; _widthSettle.removeListener(listener); _persistWhenSettled = null; - unawaited( - AppSettings.instance.setConnectionsPanelWidth(_leftPanelWidth.value), - ); + unawaited(_widthPersist.persist(_leftPanelWidth.value)); } _persistWhenSettled = listener; @@ -413,7 +417,9 @@ class _MainContentSplitState extends State<_MainContentSplit> final pending = _persistWhenSettled; if (pending != null) { _widthSettle.removeListener(pending); + _persistWhenSettled = null; } + _widthPersist.disposeFlush(_leftPanelWidth.value); _widthSettle.removeListener(_onWidthSettle); _widthSettle.dispose(); _leftPanelWidth.dispose(); @@ -473,7 +479,10 @@ class _MainContentSplitState extends State<_MainContentSplit> ), ); }, + onDiscreteResize: () => _widthPersist + .onDiscreteResize(() => _leftPanelWidth.value), onDragEnd: (details) { + _widthPersist.cancelDiscreteTimer(); final velocity = details.primaryVelocity ?? details.velocity.pixelsPerSecond.dx; _widthSettle.settle( diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 6c622b82..e47b127f 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; @@ -26,6 +28,10 @@ class QueryaDropdownItem { /// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). /// /// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [ColorScheme]. +/// +/// **Exit motion:** item pick and trigger-toggle delay [MenuController.close] +/// so fade-slide can run. Outside-tap / focus-loss closes via [MenuAnchor] +/// immediately (overlay removed) — that path snaps. class QueryaDropdown extends material.StatefulWidget { const QueryaDropdown({ super.key, @@ -63,6 +69,7 @@ class _QueryaDropdownState extends material.State> { List? _cachedMenuChildren; List>? _cachedMenuItems; T? _cachedMenuValue; + var _closingWithExit = false; @override void initState() { @@ -76,6 +83,22 @@ class _QueryaDropdownState extends material.State> { super.dispose(); } + /// Plays exit fade-slide, then removes the [MenuAnchor] overlay. + Future _closeWithExit() async { + if (!_controller.isOpen || _closingWithExit) return; + _closingWithExit = true; + _menuOpen.value = false; + final duration = context.motionDuration(QueryaMotion.standard); + if (duration > QueryaMotion.instant) { + await Future.delayed(duration); + } + if (!mounted) return; + if (_controller.isOpen) { + _controller.close(); + } + _closingWithExit = false; + } + @override void didUpdateWidget(covariant QueryaDropdown oldWidget) { super.didUpdateWidget(oldWidget); @@ -136,7 +159,7 @@ class _QueryaDropdownState extends material.State> { colorScheme: cs, onPick: () { widget.onSelected(item.value); - _controller.close(); + unawaited(_closeWithExit()); }, ); } @@ -208,7 +231,7 @@ class _QueryaDropdownState extends material.State> { onTap: widget.enabled ? () { if (controller.isOpen) { - controller.close(); + unawaited(_closeWithExit()); } else { controller.open(); } @@ -239,8 +262,15 @@ class _QueryaDropdownState extends material.State> { final anchor = material.MenuAnchor( controller: _controller, - onOpen: () => _menuOpen.value = true, - onClose: () => _menuOpen.value = false, + onOpen: () { + _closingWithExit = false; + _menuOpen.value = true; + }, + onClose: () { + // Outside-tap / focus loss: overlay already gone — snap state only. + _closingWithExit = false; + _menuOpen.value = false; + }, crossAxisUnconstrained: false, alignmentOffset: material.Offset( widget.alignmentOffset.dx, @@ -301,6 +331,10 @@ class _QueryaDropdownState extends material.State> { } } +/// Enter/exit fade-slide for menu body while the overlay stays mounted. +/// +/// Exit only runs when the parent delays [MenuController.close] (item pick / +/// trigger). Outside-tap removes the overlay immediately (snap). class _QueryaDropdownMenuEnter extends material.StatelessWidget { const _QueryaDropdownMenuEnter({ required this.openNotifier, @@ -394,6 +428,8 @@ class _QueryaDropdownMenuItemState onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null, onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, child: material.MenuItemButton( + // Keep overlay mounted so parent can play exit fade-slide before close. + closeOnActivate: false, style: material.MenuItemButton.styleFrom( minimumSize: material.Size(double.infinity, itemHeight), padding: material.EdgeInsets.zero, diff --git a/test/core/layout/querya_split_handle_test.dart b/test/core/layout/querya_split_handle_test.dart index 86dc0a04..bd46886d 100644 --- a/test/core/layout/querya_split_handle_test.dart +++ b/test/core/layout/querya_split_handle_test.dart @@ -54,6 +54,7 @@ void main() { testWidgets('horizontal split handle responds to Left and Right', (tester) async { var totalDelta = 0.0; + var discreteCount = 0; await tester.pumpWidget( queryaThemeTestShell( child: material.SizedBox( @@ -66,6 +67,7 @@ void main() { axis: material.Axis.horizontal, semanticsLabel: 'Resize connections and workspace panes', onDragDelta: (delta) => totalDelta += delta, + onDiscreteResize: () => discreteCount++, ), const material.Expanded(child: material.SizedBox()), ], @@ -80,8 +82,47 @@ void main() { await tester.tap(handle); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); expect(totalDelta, 10); + expect(discreteCount, 1); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); expect(totalDelta, 0); + expect(discreteCount, 2); + }); + + testWidgets('semantics increase/decrease call onDiscreteResize', + (tester) async { + var discreteCount = 0; + var totalDelta = 0.0; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 400, + height: 200, + child: material.Row( + children: [ + const material.Expanded(child: material.SizedBox()), + QueryaSplitHandle( + axis: material.Axis.horizontal, + semanticsLabel: 'Resize connections and workspace panes', + onDragDelta: (delta) => totalDelta += delta, + onDiscreteResize: () => discreteCount++, + ), + const material.Expanded(child: material.SizedBox()), + ], + ), + ), + ), + ); + + final node = tester.getSemantics( + find.bySemanticsLabel('Resize connections and workspace panes'), + ); + final owner = tester.binding.pipelineOwner.semanticsOwner!; + owner.performAction(node.id, SemanticsAction.increase); + expect(totalDelta, 10); + expect(discreteCount, 1); + owner.performAction(node.id, SemanticsAction.decrease); + expect(totalDelta, 0); + expect(discreteCount, 2); }); testWidgets('focus ring uses motion-aware AnimatedContainer', (tester) async { diff --git a/test/features/main_screen/connections_panel_width_persist_test.dart b/test/features/main_screen/connections_panel_width_persist_test.dart new file mode 100644 index 00000000..91165175 --- /dev/null +++ b/test/features/main_screen/connections_panel_width_persist_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; + +void main() { + test('discrete resize debounces and persists latest width', () async { + final written = []; + final persist = ConnectionsPanelWidthPersist( + write: (w) async => written.add(w), + discreteDebounce: const Duration(milliseconds: 50), + ); + + var width = 200.0; + persist.onDiscreteResize(() => width); + width = 220; + persist.onDiscreteResize(() => width); + width = 240; + persist.onDiscreteResize(() => width); + + expect(written, isEmpty); + expect(persist.dirty, isTrue); + + await Future.delayed(const Duration(milliseconds: 80)); + expect(written, [240]); + expect(persist.dirty, isFalse); + }); + + test('disposeFlush writes pending dirty width and cancels timer', () async { + final written = []; + final persist = ConnectionsPanelWidthPersist( + write: (w) async => written.add(w), + discreteDebounce: const Duration(milliseconds: 500), + ); + + persist.onDiscreteResize(() => 310); + expect(written, isEmpty); + + persist.disposeFlush(310); + // Allow microtask/future from unawaited write. + await Future.delayed(Duration.zero); + expect(written, [310]); + expect(persist.dirty, isFalse); + + await Future.delayed(const Duration(milliseconds: 520)); + expect(written, [310]); + }); + + test('disposeFlush is a no-op when not dirty', () async { + final written = []; + final persist = ConnectionsPanelWidthPersist( + write: (w) async => written.add(w), + ); + + persist.disposeFlush(999); + await Future.delayed(Duration.zero); + expect(written, isEmpty); + }); + + test('cancelDiscreteTimer prevents delayed write', () async { + final written = []; + final persist = ConnectionsPanelWidthPersist( + write: (w) async => written.add(w), + discreteDebounce: const Duration(milliseconds: 40), + ); + + persist.onDiscreteResize(() => 280); + persist.cancelDiscreteTimer(); + // Still dirty — dispose/settle path should flush. + expect(persist.dirty, isTrue); + + await Future.delayed(const Duration(milliseconds: 60)); + expect(written, isEmpty); + + await persist.persist(280); + expect(written, [280]); + }); +} diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 64d7cd39..0fb84aaf 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import '../support/querya_theme_test_shell.dart'; @@ -98,6 +100,50 @@ void main() { expect(find.text('Beta'), findsOneWidget); }); + testWidgets('item pick delays MenuAnchor close for exit fade-slide', + (tester) async { + var selected = 'a'; + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: material.Scaffold( + body: material.StatefulBuilder( + builder: (context, setState) { + return QueryaDropdown( + value: selected, + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (v) => setState(() => selected = v ?? selected), + ); + }, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + expect(find.text('Beta'), findsOneWidget); + + await tester.tap(find.text('Beta')); + // Exit starts; overlay still mounted mid-standard duration. + await tester.pump(); + expect(find.text('Beta'), findsWidgets); + expect(find.byType(material.AnimatedOpacity), findsWidgets); + + await tester.pump(QueryaMotion.standard); + await tester.pump(); // close() after delay + await tester.pumpAndSettle(); + // Menu closed; trigger shows selection. + expect(find.text('Beta'), findsOneWidget); + expect(find.text('Alpha'), findsNothing); + }); + testWidgets('menu anchor constrains width to trigger', (tester) async { await tester.pumpWidget( queryaThemeTestShell( From 5bbb23ee387446c6b8b993da7aee8329ad28f78e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Jul 2026 23:12:37 +0300 Subject: [PATCH 2/2] fix(test): avoid deprecated pipelineOwner in split handle semantics test Co-authored-by: Cursor --- test/core/layout/querya_split_handle_test.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/core/layout/querya_split_handle_test.dart b/test/core/layout/querya_split_handle_test.dart index bd46886d..33f67cc8 100644 --- a/test/core/layout/querya_split_handle_test.dart +++ b/test/core/layout/querya_split_handle_test.dart @@ -113,14 +113,13 @@ void main() { ), ); - final node = tester.getSemantics( - find.bySemanticsLabel('Resize connections and workspace panes'), + final semantics = find.semantics.byLabel( + 'Resize connections and workspace panes', ); - final owner = tester.binding.pipelineOwner.semanticsOwner!; - owner.performAction(node.id, SemanticsAction.increase); + tester.semantics.increase(semantics); expect(totalDelta, 10); expect(discreteCount, 1); - owner.performAction(node.id, SemanticsAction.decrease); + tester.semantics.decrease(semantics); expect(totalDelta, 0); expect(discreteCount, 2); });