From 608a294824da0499cbc1f1eae3ec457cfafb4f9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 21:28:54 +0300 Subject: [PATCH 1/2] perf(ui): accelerate menubar dropdown open and transition speed (#672) - Add customizable show/dismiss duration and animation curves to PopoverOverlayHandler - Configure high-performance menuHandler with 60ms open and 50ms dismiss duration using Curves.easeOutCubic - Update scale animation from 0.9->1.0 to 0.96->1.0 for subtle, desktop-native reveal - Unify TapRegion group IDs and allow immediate closure when switching sibling menus - Toggle close open dropdowns upon clicking menu button again - Add widget test verifying rapid open timing, toggle close, and sibling switch --- lib/app/app.dart | 6 ++ .../menubar_dropdown_speed_test.dart | 87 +++++++++++++++++++ .../lib/src/components/menu/menu.dart | 18 ++-- .../lib/src/components/overlay/popover.dart | 34 ++++++-- 4 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 test/features/main_screen/menubar_dropdown_speed_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 51f10be5..2e6b16c2 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -60,6 +60,12 @@ class QueryaApp extends StatelessWidget { themeMode: themeController.themeMode, materialTheme: themeController.materialThemeFor(colorScheme), debugShowCheckedModeBanner: false, + menuHandler: const PopoverOverlayHandler( + defaultShowDuration: Duration(milliseconds: 60), + defaultDismissDuration: Duration(milliseconds: 50), + showCurve: Curves.easeOutCubic, + dismissCurve: Curves.easeIn, + ), enableThemeAnimation: themeAnimEnabled, themeAnimationDuration: themeDuration, themeAnimationCurve: themeCurve, diff --git a/test/features/main_screen/menubar_dropdown_speed_test.dart b/test/features/main_screen/menubar_dropdown_speed_test.dart new file mode 100644 index 00000000..5c5d6fdb --- /dev/null +++ b/test/features/main_screen/menubar_dropdown_speed_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + testWidgets('Menubar dropdown opens fast and toggles on repeated tap', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + menuHandler: const PopoverOverlayHandler( + defaultShowDuration: Duration(milliseconds: 60), + defaultDismissDuration: Duration(milliseconds: 50), + showCurve: Curves.easeOutCubic, + dismissCurve: Curves.easeIn, + ), + home: material.Scaffold( + body: material.Column( + children: [ + Menubar( + border: false, + children: [ + MenuButton( + subMenu: [ + MenuButton( + onPressed: (_) {}, + child: const Text('New File'), + ), + MenuButton( + onPressed: (_) {}, + child: const Text('Save File'), + ), + ], + child: const Text('File'), + ), + MenuButton( + subMenu: [ + MenuButton( + onPressed: (_) {}, + child: const Text('Undo Action'), + ), + ], + child: const Text('Edit'), + ), + ], + ), + ], + ), + ), + ), + ); + + // Initial state: menu items are not visible + expect(find.text('New File'), findsNothing); + expect(find.text('Undo Action'), findsNothing); + + // Tap 'File' + await tester.tap(find.text('File')); + // Advance by 60ms (the fast open duration) + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('New File'), findsOneWidget); + expect(find.text('Save File'), findsOneWidget); + + // Tap 'File' again to toggle close + await tester.tap(find.text('File')); + await tester.pumpAndSettle(); + + expect(find.text('New File'), findsNothing); + + // Open 'File' again + await tester.tap(find.text('File')); + await tester.pump(const Duration(milliseconds: 60)); + expect(find.text('New File'), findsOneWidget); + + // Tap 'Edit' while 'File' is open -> closes 'File' immediately and opens 'Edit' + await tester.tap(find.text('Edit')); + await tester.pump(const Duration(milliseconds: 60)); + + expect(find.text('New File'), findsNothing); + expect(find.text('Undo Action'), findsOneWidget); + + // Tap outside -> closes 'Edit' menu + await tester.tapAt(const Offset(300, 300)); + await tester.pumpAndSettle(); + expect(find.text('Undo Action'), findsNothing); + }); +} diff --git a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart index 9fc69861..edc3aadd 100644 --- a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart +++ b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart @@ -600,14 +600,16 @@ class _MenuButtonState extends State { final isDialogOverlay = DialogOverlayHandler.isDialogOverlay(context); final isIndependentOverlay = isSheetOverlay || isDialogOverlay; void openSubMenu(BuildContext context, bool autofocus) { - menuGroupData!.closeOthers(); + menuGroupData!.closeOthers(true); final overlayManager = OverlayManager.of(context); + final effectiveRegionGroupId = + menuGroupData.regionGroupId ?? menuGroupData.root; menuData!.popoverController.show( context: context, - regionGroupId: menuGroupData.regionGroupId, + regionGroupId: effectiveRegionGroupId, consumeOutsideTaps: false, dismissBackdropFocus: false, - modal: true, + modal: false, handler: MenuOverlayHandler(overlayManager), overlayBarrier: OverlayBarrier( borderRadius: BorderRadius.circular(theme.radiusMd), @@ -633,7 +635,7 @@ class _MenuButtonState extends State { direction: menuGroupData.direction, parent: menuGroupData, onDismissed: menuGroupData.onDismissed, - regionGroupId: menuGroupData.regionGroupId, + regionGroupId: effectiveRegionGroupId, subMenuOffset: compTheme?.subMenuOffset ?? Offset(densityGap, -densityGap * 0.625), itemPadding: itemPadding, @@ -685,7 +687,7 @@ class _MenuButtonState extends State { return Data.boundary( child: Data.boundary( child: TapRegion( - groupId: menuGroupData!.root, + groupId: menuGroupData!.regionGroupId ?? menuGroupData.root, child: AnimatedBuilder( animation: menuData!.popoverController, builder: (context, child) { @@ -773,6 +775,8 @@ class _MenuButtonState extends State { widget.subMenu!.isNotEmpty) { if (!menuData.popoverController.hasOpenPopover) { openSubMenu(context, false); + } else { + menuData.popoverController.close(); } } else { if (widget.autoClose) { @@ -863,9 +867,9 @@ class MenuGroupData { /// Closes all open popovers in child menu items. /// /// Iterates through children and closes any open submenu popovers. - void closeOthers() { + void closeOthers([bool immediate = false]) { for (final child in children) { - child.popoverController.close(); + child.popoverController.close(immediate); } } diff --git a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart index b35b24ca..eae3450a 100644 --- a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart +++ b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart @@ -11,8 +11,25 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Manages the display, positioning, and lifecycle of popover overlays /// with support for alignment, constraints, and modal behavior. class PopoverOverlayHandler extends OverlayHandler { + /// Default animation duration when showing the popover. + final Duration? defaultShowDuration; + + /// Default animation duration when dismissing the popover. + final Duration? defaultDismissDuration; + + /// Animation curve when showing. + final Curve? showCurve; + + /// Animation curve when dismissing. + final Curve? dismissCurve; + /// Creates a [PopoverOverlayHandler]. - const PopoverOverlayHandler(); + const PopoverOverlayHandler({ + this.defaultShowDuration, + this.defaultDismissDuration, + this.showCurve, + this.dismissCurve, + }); @override OverlayCompleter show({ required BuildContext context, @@ -112,12 +129,15 @@ class PopoverOverlayHandler extends OverlayHandler { value: isClosed.value ? 0.0 : 1.0, initialValue: 0.0, curve: isClosed.value - ? const Interval(0, 2 / 3) - : Curves.linear, + ? (dismissCurve ?? const Interval(0, 2 / 3)) + : (showCurve ?? Curves.easeOutCubic), duration: isClosed.value - ? (showDuration ?? kDefaultDuration) - : (dismissDuration ?? - const Duration(milliseconds: 100)), + ? (dismissDuration ?? + defaultDismissDuration ?? + const Duration(milliseconds: 60)) + : (showDuration ?? + defaultShowDuration ?? + const Duration(milliseconds: 60)), onEnd: (value) { if (value == 0.0 && isClosed.value) { popoverEntry.remove(); @@ -685,7 +705,7 @@ class PopoverOverlayWidgetState extends State offset: _offset, margin: _margin?.optionallyResolve(context) ?? EdgeInsets.all(densityGap), - scale: tweenValue(0.9, 1.0, widget.animation.value), + scale: tweenValue(0.96, 1.0, widget.animation.value), scaleAlignment: (widget.transitionAlignment ?? _alignment) .optionallyResolve(context), allowInvertVertical: _allowInvertVertical, From 8ae21ff94adc722b0c54a1aed809c8c41768a32b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 30 Aug 2026 21:40:12 +0300 Subject: [PATCH 2/2] perf(ui): eliminate initial menubar dropdown delay on pointer down (#672) - Unwrap Menubar from MoveWindow in QueryaWindowTitleBar to prevent window drag gesture arena interception from delaying menu clicks - Trigger top-level Menubar opening on onTapDown (pointer down) rather than waiting for mouse release - Adjust entry scale to 0.98 for instantaneous perceived start of dropdown appearance --- .../main_screen/querya_window_title_bar.dart | 6 ++++-- .../lib/src/components/menu/menu.dart | 18 ++++++++++++++++++ .../lib/src/components/overlay/popover.dart | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index d30252b5..64c7801d 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -103,6 +103,7 @@ class QueryaWindowTitleBar extends StatelessWidget { final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context); final rowContent = Row( + mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: QueryaWindowTitleBar.titleBarLeadingInset( @@ -390,10 +391,11 @@ class QueryaWindowTitleBar extends StatelessWidget { final inner = Row( children: [ + rowContent, Expanded( child: useNativeWindowChrome - ? MoveWindow(child: rowContent) - : rowContent, + ? MoveWindow() + : const SizedBox(), ), Row( mainAxisSize: material.MainAxisSize.min, diff --git a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart index edc3aadd..e4053241 100644 --- a/third_party/shadcn_flutter/lib/src/components/menu/menu.dart +++ b/third_party/shadcn_flutter/lib/src/components/menu/menu.dart @@ -568,6 +568,7 @@ class MenuCheckbox extends StatelessWidget implements MenuItem { class _MenuButtonState extends State { final ValueNotifier> _children = ValueNotifier([]); + bool _justToggledOnTapDown = false; @override void initState() { @@ -769,7 +770,24 @@ class _MenuButtonState extends State { subFocusState.unfocus(); } }, + onTapDown: (details) { + if (menuBarData != null && + widget.subMenu != null && + widget.subMenu!.isNotEmpty) { + if (!menuData.popoverController.hasOpenPopover) { + _justToggledOnTapDown = true; + openSubMenu(context, false); + } else { + _justToggledOnTapDown = true; + menuData.popoverController.close(); + } + } + }, onPressed: () { + if (_justToggledOnTapDown) { + _justToggledOnTapDown = false; + return; + } widget.onPressed?.call(context); if (widget.subMenu != null && widget.subMenu!.isNotEmpty) { diff --git a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart index eae3450a..976898bc 100644 --- a/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart +++ b/third_party/shadcn_flutter/lib/src/components/overlay/popover.dart @@ -705,7 +705,7 @@ class PopoverOverlayWidgetState extends State offset: _offset, margin: _margin?.optionallyResolve(context) ?? EdgeInsets.all(densityGap), - scale: tweenValue(0.96, 1.0, widget.animation.value), + scale: tweenValue(0.98, 1.0, widget.animation.value), scaleAlignment: (widget.transitionAlignment ?? _alignment) .optionallyResolve(context), allowInvertVertical: _allowInvertVertical,