diff --git a/benchmark/grid_perf_bench.dart b/benchmark/grid_perf_bench.dart index 87c493d..d8a5e4a 100644 --- a/benchmark/grid_perf_bench.dart +++ b/benchmark/grid_perf_bench.dart @@ -1,9 +1,10 @@ -// ignore_for_file: depend_on_referenced_packages +// ignore_for_file: depend_on_referenced_packages, use_build_context_synchronously // Frame-timing benchmark for VirtualResultGrid. Run in profile mode: // flutter run --profile -d linux -t benchmark/grid_perf_bench.dart --dart-define=MODE=scroll // MODE: scroll (horizontal pan) | edit (staged cell edits on a sorted grid) // | select (mouse-drag selection through ResultsTab, incl. stats) +// | dialog (open / close showAppDialog repeatedly over a busy grid) import 'dart:async'; import 'dart:developer' as developer; import 'dart:io'; @@ -17,6 +18,7 @@ import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:querya_desktop/features/workspace/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:vm_service/vm_service.dart' as vms; import 'package:vm_service/vm_service_io.dart' as vms_io; @@ -25,6 +27,7 @@ const mode = String.fromEnvironment('MODE', defaultValue: 'scroll'); const rowCount = int.fromEnvironment('ROWS', defaultValue: 5000); const colCount = int.fromEnvironment('COLS', defaultValue: 120); const seconds = int.fromEnvironment('SECS', defaultValue: 6); +final dialogHost = GlobalKey(); const profile = bool.fromEnvironment('PROFILE', defaultValue: false); vms.VmService? _vm; @@ -57,7 +60,19 @@ void main() { runApp(ShadcnApp( theme: td, home: material.Scaffold( - body: mode == 'select' + body: mode == 'dialog' + ? KeyedSubtree( + key: dialogHost, + child: ListenableBuilder( + listenable: buffer, + builder: (_, __) => VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ), + ) + : mode == 'select' ? ListenableBuilder( listenable: buffer, builder: (_, __) => ResultsTab( @@ -137,7 +152,25 @@ Future _run(DataGridStagingBuffer buffer) async { : null; } var dir = 1.0; - while (DateTime.now().isBefore(end)) { + if (mode == 'dialog') { + final ctx = dialogHost.currentContext!; + while (DateTime.now().isBefore(end)) { + unawaited(showAppDialog( + context: ctx, + builder: (_) => const Center( + child: SizedBox( + width: 420, + height: 260, + child: Card(child: Center(child: Text('bench dialog'))), + ), + ), + )); + await Future.delayed(const Duration(milliseconds: 600)); + Navigator.of(ctx).pop(); + await Future.delayed(const Duration(milliseconds: 600)); + } + } + while (mode != 'dialog' && DateTime.now().isBefore(end)) { await SchedulerBinding.instance.endOfFrame; if (mode == 'scroll') { final p = pos!; diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index ec8a2a3..bfb9e35 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -101,27 +101,16 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { child: Stack( fit: StackFit.expand, children: [ - // Backdrop: static blur; only opacity/dim animate (blur sigma is expensive). + // Backdrop: blur strength and tint animate directly. No Opacity wraps + // the BackdropFilter, so no extra full-screen offscreen layer is + // composited on every frame of the enter / exit transition. Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.barrierDismissible ? widget.onDismiss : null, child: AnimatedBuilder( animation: curved, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: const ColoredBox( - color: Color(0x52000000), // black @ 0.32 - ), - ), - ), - builder: (ctx, child) { - return Opacity( - opacity: curved.value.clamp(0.0, 1.0), - child: child, - ); - }, + builder: (ctx, _) => _DialogBackdrop(progress: curved.value), ), ), ), @@ -140,3 +129,33 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { ); } } + +/// Frosted, dimmed dialog backdrop at [progress] (0 = none, 1 = full). +/// +/// Both blur sigma and tint alpha scale with [progress], so the frost fades in +/// and out without pop-in and without an enclosing `Opacity`. +class _DialogBackdrop extends StatelessWidget { + const _DialogBackdrop({required this.progress}); + + /// Full-strength blur sigma. + static const double sigma = 8; + + /// Full-strength tint alpha (black @ 0.32). + static const double tintAlpha = 0.32; + + final double progress; + + @override + Widget build(BuildContext context) { + final t = progress.clamp(0.0, 1.0); + if (t <= 0) return const SizedBox.expand(); + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma * t, sigmaY: sigma * t), + child: ColoredBox( + color: const Color(0xFF000000).withValues(alpha: tintAlpha * t), + ), + ), + ); + } +} diff --git a/test/shared/app_dialog_test.dart b/test/shared/app_dialog_test.dart index 6a6b8bb..c112f2c 100644 --- a/test/shared/app_dialog_test.dart +++ b/test/shared/app_dialog_test.dart @@ -121,27 +121,95 @@ void main() { builder: (c) => const SimpleDialog(title: Text('X')), ); await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); expect(find.byType(BackdropFilter), findsWidgets); expect(find.byType(FadeTransition), findsWidgets); expect(find.byType(SlideTransition), findsWidgets); expect(find.byType(ScaleTransition), findsNothing); }); - testWidgets('backdrop keeps static blur sigma; opacity carries enter', - (tester) async { - final ctx = await pumpHost(tester); - - showAppDialog( - context: ctx, - builder: (c) => const SimpleDialog(title: Text('Blur')), - ); - // Mid-enter: blur must stay sigma 8 (not sigma * t). - await tester.pump(); - await tester.pump(const Duration(milliseconds: 40)); + group('backdrop transition (#886)', () { + final fullFilter = ImageFilter.blur(sigmaX: 8, sigmaY: 8); + final fullTint = const Color(0xFF000000).withValues(alpha: 0.32); - final filter = tester.widget(find.byType(BackdropFilter)); - expect(filter.filter, ImageFilter.blur(sigmaX: 8, sigmaY: 8)); - expect(find.byType(Opacity), findsWidgets); + Color tintOf(WidgetTester tester) { + final box = tester.widget( + find.descendant( + of: find.byType(BackdropFilter), + matching: find.byType(ColoredBox), + ), + ); + return box.color; + } + + testWidgets('blur and tint scale in during enter; no Opacity wraps the filter', + (tester) async { + final ctx = await pumpHost(tester); + + showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('Blur')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNot(fullFilter), reason: 'sigma is still ramping'); + expect(tintOf(tester).a, greaterThan(0)); + expect(tintOf(tester).a, lessThan(fullTint.a)); + + // The frost must not sit under an Opacity (that forces an offscreen layer). + expect( + find.ancestor( + of: find.byType(BackdropFilter), + matching: find.byType(Opacity), + ), + findsNothing, + ); + }); + + testWidgets('settles at full blur and tint', (tester) async { + final ctx = await pumpHost(tester); + + showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('Settled')), + ); + await tester.pumpAndSettle(); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, fullFilter); + expect(tintOf(tester), fullTint); + expect( + find.ancestor( + of: find.byType(BackdropFilter), + matching: find.byType(Opacity), + ), + findsNothing, + ); + }); + + testWidgets('blur and tint fade out again on dismiss', (tester) async { + final ctx = await pumpHost(tester); + + final future = showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('Leaving')), + ); + await tester.pumpAndSettle(); + + Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNot(fullFilter)); + expect(tintOf(tester).a, lessThan(fullTint.a)); + + await tester.pumpAndSettle(); + expect(find.byType(BackdropFilter), findsNothing); + await future; + }); }); testWidgets('enter uses standard duration under full motion', (tester) async {