diff --git a/lib/core/editor/querya_code_editor.dart b/lib/core/editor/querya_code_editor.dart index bf908898..0d0741e6 100644 --- a/lib/core/editor/querya_code_editor.dart +++ b/lib/core/editor/querya_code_editor.dart @@ -92,10 +92,18 @@ class _QueryaCodeEditorState extends State { widget.onChanged?.call(_effectiveController.text); } - void _syncThemeIfNeeded(QueryaTheme queryaTheme) { + void _syncThemeIfNeeded( + QueryaTheme queryaTheme, { + bool transitioning = false, + }) { final controller = _effectiveController; if (controller is! QueryaHighlightController) return; + // Mid-animation themes differ on every frame: building highlighters for + // them each time is wasted work and pushes real entries out of the pair + // cache. The final frame arrives with `transitioning` false (#1358). + if (transitioning && _lastEditorTheme != null) return; + final editor = queryaTheme.editor; final tokenHash = queryaTheme.tokenColorsHash; if (_lastEditorTheme == editor && _lastTokenColorsHash == tokenHash) { @@ -122,7 +130,10 @@ class _QueryaCodeEditorState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - _syncThemeIfNeeded(context.queryaTheme); + _syncThemeIfNeeded( + context.queryaTheme, + transitioning: QueryaThemeScope.isTransitioning(context), + ); } @override @@ -139,7 +150,10 @@ class _QueryaCodeEditorState extends State { _lastEditorTheme = null; _lastTokenColorsHash = 0; _attachListener(); - _syncThemeIfNeeded(context.queryaTheme); + _syncThemeIfNeeded( + context.queryaTheme, + transitioning: QueryaThemeScope.isTransitioning(context), + ); } } diff --git a/lib/core/theme/animated_querya_theme.dart b/lib/core/theme/animated_querya_theme.dart index 25659838..c259df7c 100644 --- a/lib/core/theme/animated_querya_theme.dart +++ b/lib/core/theme/animated_querya_theme.dart @@ -40,6 +40,9 @@ class _AnimatedQueryaThemeState Widget build(BuildContext context) { return QueryaThemeScope( data: _data!.evaluate(animation), + // True only while the controller runs: the final frame, and a theme + // that never animates, are settled. + transitioning: animation.isAnimating, child: widget.child, ); } diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index ddafa900..c05e451f 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -13,10 +13,16 @@ class QueryaThemeScope extends InheritedWidget { super.key, required this.data, required super.child, + this.transitioning = false, }); final QueryaTheme data; + /// Whether [data] is an intermediate frame of a theme animation. Consumers + /// that are costly to update (syntax highlighters) can wait for the final + /// frame instead of following every interpolated colour (#1358). + final bool transitioning; + static QueryaTheme of(BuildContext context) { final scope = context.dependOnInheritedWidgetOfExactType(); @@ -28,8 +34,18 @@ class QueryaThemeScope extends InheritedWidget { return context.dependOnInheritedWidgetOfExactType()?.data; } + /// Whether the theme is mid-animation. Registers a dependency, so the caller + /// is rebuilt when the animation ends. + static bool isTransitioning(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.transitioning ?? + false; + } + @override - bool updateShouldNotify(QueryaThemeScope oldWidget) => data != oldWidget.data; + bool updateShouldNotify(QueryaThemeScope oldWidget) => + data != oldWidget.data || transitioning != oldWidget.transitioning; } /// Convenient access to [QueryaTheme] tokens and [Theme] / [ColorScheme] from [BuildContext]. diff --git a/test/core/theme/animated_querya_theme_transition_test.dart b/test/core/theme/animated_querya_theme_transition_test.dart new file mode 100644 index 00000000..143c092d --- /dev/null +++ b/test/core/theme/animated_querya_theme_transition_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/animated_querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +void main() { + group('QueryaThemeScope.transitioning (#1358)', () { + testWidgets('is true only while the theme animation runs', (tester) async { + var theme = QueryaTheme.darkDefault; + var transitioning = false; + late StateSetter setOuterState; + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuterState = setState; + return Directionality( + textDirection: TextDirection.ltr, + child: AnimatedQueryaTheme( + data: theme, + duration: const Duration(milliseconds: 200), + child: Builder( + builder: (context) { + transitioning = QueryaThemeScope.isTransitioning(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + }, + ), + ); + expect(transitioning, isFalse, reason: 'initial theme is settled'); + + setOuterState(() => theme = QueryaTheme.lightDefault); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(transitioning, isTrue, reason: 'mid-animation'); + + await tester.pumpAndSettle(); + expect(transitioning, isFalse, reason: 'animation finished'); + }); + + testWidgets('a consumer is rebuilt when the transition ends', + (tester) async { + var theme = QueryaTheme.darkDefault; + final seen = []; + late StateSetter setOuterState; + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuterState = setState; + return Directionality( + textDirection: TextDirection.ltr, + child: AnimatedQueryaTheme( + data: theme, + duration: const Duration(milliseconds: 200), + child: Builder( + builder: (context) { + seen.add(QueryaThemeScope.isTransitioning(context)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }, + ), + ); + + setOuterState(() => theme = QueryaTheme.lightDefault); + await tester.pumpAndSettle(); + + expect(seen.last, isFalse); + expect(seen, contains(true)); + }); + + testWidgets('defaults to settled for a plain scope', (tester) async { + var transitioning = true; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: QueryaThemeScope( + data: QueryaTheme.darkDefault, + child: Builder( + builder: (context) { + transitioning = QueryaThemeScope.isTransitioning(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(transitioning, isFalse); + }); + }); +}