Skip to content

Commit 3c3e359

Browse files
committed
Fix emojify animations
1 parent 6c505cf commit 3c3e359

5 files changed

Lines changed: 119 additions & 13 deletions

File tree

lib/src/app/bootstrap.dart

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:yandex_keyboard_desktop/src/app_ui/app_controls.dart';
1717
import 'package:yandex_keyboard_desktop/src/app_ui/app_tokens.dart';
1818
import 'package:yandex_keyboard_desktop/src/app/desktop_surface.dart';
1919
import 'package:yandex_keyboard_desktop/src/app/diagnostic_log.dart';
20+
import 'package:yandex_keyboard_desktop/src/app/frame_settle.dart';
2021
import 'package:yandex_keyboard_desktop/src/app/desktop_visual_preferences.dart';
2122
import 'package:yandex_keyboard_desktop/src/app/ios_bootstrap.dart';
2223
import 'package:yandex_keyboard_desktop/src/features/settings/presentation/settings_window.dart';
@@ -413,14 +414,14 @@ class _AppState extends State<App>
413414
Future<void> _showHotKeyErrorSurfaceNow() async {
414415
if (mounted && _surface != DesktopSurface.overlay) {
415416
setState(() => _surface = DesktopSurface.overlay);
416-
await WidgetsBinding.instance.endOfFrame;
417+
await settleFrame();
417418
}
418419
const size = Size(460, 96);
419420
await windowManager.setMinimumSize(size);
420421
await windowManager.setSize(size);
421422
await windowManager.setAlwaysOnTop(true);
422423
await windowManager.center();
423-
await WidgetsBinding.instance.endOfFrame;
424+
await settleFrame();
424425
await _showOverlayWindowInactive(widget.overlayGateway);
425426
}
426427

@@ -464,6 +465,11 @@ class _AppState extends State<App>
464465
}
465466

466467
Future<void> _handleWindowHiddenNow() async {
468+
// Only react to the settings window being dismissed natively (its close
469+
// button hides the shared window). Overlay-driven hides are orchestrated
470+
// by the overlay itself; restoring here as well races the overlay's own
471+
// placement queue and clobbers the window bounds mid-presentation.
472+
if (_surface != DesktopSurface.settings) return;
467473
widget.textOperationGate.reset();
468474
if (_hotKeyErrorCode == null) await _restoreOverlayWindowNow();
469475
}
@@ -486,16 +492,23 @@ class _AppState extends State<App>
486492

487493
Future<bool> _ensureOverlayHostSurface() async {
488494
var ready = false;
495+
diag('ensureOverlayHostSurface: requested (surface=$_surface)');
489496
await _runSurfaceTransition(() async {
497+
diag('ensureOverlayHostSurface: transition started');
490498
if (_surface == DesktopSurface.overlay) {
491499
ready = true;
492500
return;
493501
}
494-
if (!await _confirmSettingsExitIfNeeded()) return;
502+
if (!await _confirmSettingsExitIfNeeded()) {
503+
diag('ensureOverlayHostSurface: discard declined');
504+
return;
505+
}
495506
if (!mounted) return;
496507
setState(() => _surface = DesktopSurface.overlay);
508+
diag('ensureOverlayHostSurface: hiding settings window');
497509
await windowManager.hide();
498-
await WidgetsBinding.instance.endOfFrame;
510+
await settleFrame();
511+
diag('ensureOverlayHostSurface: ready');
499512
ready = true;
500513
});
501514
return ready;
@@ -549,6 +562,16 @@ class _AppState extends State<App>
549562

550563
Future<bool> _confirmSettingsExitIfNeeded() async {
551564
if (!widget.settingsController.state.isDirty) return true;
565+
// The discard dialog renders inside the shared window; make sure it is
566+
// visible and focused, otherwise the confirmation silently blocks the
567+
// surface-transition queue while sitting in a background window.
568+
if (_surface == DesktopSurface.settings) {
569+
await _bestEffort(() async {
570+
await _setNativeWindowCanActivate(widget.overlayGateway, true);
571+
await windowManager.show();
572+
await windowManager.focus();
573+
});
574+
}
552575
return await _settingsWindowKey.currentState?.confirmDiscardIfNeeded() ??
553576
false;
554577
}
@@ -643,7 +666,7 @@ class _AppState extends State<App>
643666
await _bestEffort(() => windowManager.setMovable(true));
644667
try {
645668
await _setNativeWindowCanActivate(widget.overlayGateway, true);
646-
await WidgetsBinding.instance.endOfFrame;
669+
await settleFrame();
647670
await windowManager.show();
648671
await windowManager.focus();
649672
} catch (_) {
@@ -675,6 +698,9 @@ class _AppState extends State<App>
675698
if (!await _confirmSettingsExitIfNeeded()) return;
676699
}
677700
setState(() => _surface = DesktopSurface.overlay);
701+
// Hide the (large, activatable) settings window before morphing it
702+
// into the compact overlay so the resize never plays out on screen.
703+
await _bestEffort(windowManager.hide);
678704
}
679705
var resolvedTarget = targetHandle;
680706
if (resolvedTarget == null &&
@@ -691,7 +717,7 @@ class _AppState extends State<App>
691717
await OverlayWindowController.initialize(
692718
size: overlaySize,
693719
);
694-
await WidgetsBinding.instance.endOfFrame;
720+
await settleFrame();
695721
await _showOverlayWindowInactive(widget.overlayGateway);
696722
}
697723

@@ -702,7 +728,7 @@ class _AppState extends State<App>
702728
);
703729
if (mounted && _surface != DesktopSurface.overlay) {
704730
setState(() => _surface = DesktopSurface.overlay);
705-
await WidgetsBinding.instance.endOfFrame;
731+
await settleFrame();
706732
}
707733
await OverlayWindowController.initialize(size: overlaySize);
708734
if (show) {

lib/src/app/frame_settle.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/scheduler.dart';
4+
import 'package:flutter/widgets.dart';
5+
6+
/// Completes after the next rendered frame so native window operations
7+
/// (show/resize) never present a stale surface.
8+
///
9+
/// While the app window is hidden Flutter reports `AppLifecycleState.hidden`
10+
/// and disables frame scheduling, so a plain `await endOfFrame` deadlocks
11+
/// until something else makes the window visible. This helper forces a frame
12+
/// in that state and additionally bounds the wait, so window orchestration
13+
/// queues can never wedge permanently.
14+
Future<void> settleFrame({
15+
Duration timeout = const Duration(milliseconds: 350),
16+
}) {
17+
final binding = WidgetsBinding.instance;
18+
if (!binding.framesEnabled &&
19+
binding.schedulerPhase == SchedulerPhase.idle) {
20+
binding.scheduleForcedFrame();
21+
}
22+
return binding.endOfFrame.timeout(timeout, onTimeout: () {});
23+
}

lib/src/features/text_assistant/presentation/overlay_presenter.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@ class OverlayPresenter {
66
VoidCallback? _onShow;
77
Future<bool> Function()? _ensureHostSurface;
88

9+
// Note: comparisons use == rather than identical(): attach/detach receive
10+
// fresh tear-offs of the same instance method, which are equal but never
11+
// identical. With identical() a disposed overlay could never detach itself,
12+
// leaving _onShow pointing at a defunct State (so hotkeys silently no-op).
913
void attach(VoidCallback onShow) => _onShow = onShow;
1014

1115
void detach(VoidCallback onShow) {
12-
if (identical(_onShow, onShow)) _onShow = null;
16+
if (_onShow == onShow) _onShow = null;
1317
}
1418

1519
void attachHostGuard(Future<bool> Function() ensureHostSurface) =>
1620
_ensureHostSurface = ensureHostSurface;
1721

1822
void detachHostGuard(Future<bool> Function() ensureHostSurface) {
19-
if (identical(_ensureHostSurface, ensureHostSurface)) {
23+
if (_ensureHostSurface == ensureHostSurface) {
2024
_ensureHostSurface = null;
2125
}
2226
}

lib/src/features/text_assistant/presentation/text_assistant_overlay.dart

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:lucide_flutter/lucide_flutter.dart';
66
import 'package:provider/provider.dart';
77
import 'package:window_manager/window_manager.dart';
88
import 'package:yandex_keyboard_desktop/src/app/diagnostic_log.dart';
9+
import 'package:yandex_keyboard_desktop/src/app/frame_settle.dart';
910
import 'package:yandex_keyboard_desktop/src/platform/overlay/overlay_interaction_channel.dart';
1011
import 'package:yandex_keyboard_desktop/src/platform/overlay/overlay_window_gateway.dart';
1112
import 'package:yandex_keyboard_desktop/src/features/text_assistant/application/text_replacement_controller.dart';
@@ -510,6 +511,11 @@ class TextAssistantOverlayState extends State<TextAssistantOverlay> {
510511
_noticeKindForState(controller.state) != kind) {
511512
return;
512513
}
514+
// Busy stages emit several states that all map to the same notice kind
515+
// (loading). Re-running the whole native placement for each of them
516+
// (SetWindowPos + FRAMECHANGED + ShowWindow) makes the loading animation
517+
// stutter, so skip when this notice is already presented.
518+
if (_presentedNoticeKind == kind && _sessionShown) return;
513519
final desiredSize = noticeWindowSizeFor(
514520
MediaQuery.maybeOf(context)?.textScaler ?? TextScaler.noScaling,
515521
kind: kind,
@@ -548,9 +554,14 @@ class TextAssistantOverlayState extends State<TextAssistantOverlay> {
548554
);
549555
if (!_placementIsCurrent(generation)) return false;
550556
final previousBounds = _lastNativeBounds;
551-
if (!freshPresentation &&
552-
previousBounds != null &&
553-
previousBounds != placement.nativeBounds) {
557+
// Only animate pure moves: every animation step that changes the window
558+
// size forces a synchronous swapchain resize, which visibly stutters
559+
// any in-flight content animation (e.g. the processing indicator).
560+
final isPureMove = previousBounds != null &&
561+
previousBounds != placement.nativeBounds &&
562+
(previousBounds.width - placement.nativeBounds.width).abs() < 1 &&
563+
(previousBounds.height - placement.nativeBounds.height).abs() < 1;
564+
if (!freshPresentation && isPureMove) {
554565
await _animateNativeBounds(
555566
nativePlacement,
556567
placement,
@@ -586,7 +597,7 @@ class TextAssistantOverlayState extends State<TextAssistantOverlay> {
586597
await windowManager.setMinimumSize(fittedSize);
587598
if (!_placementIsCurrent(generation)) return false;
588599
if (freshPresentation) _replayEntrance();
589-
await WidgetsBinding.instance.endOfFrame;
600+
await settleFrame();
590601
if (!_placementIsCurrent(generation)) return false;
591602
if (gateway case final NativeOwnedOverlayActivationGateway activation) {
592603
await activation.showOwnedWindowInactive();

test/features/text_assistant/presentation/overlay_presenter_test.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,46 @@ void main() {
6262

6363
expect(await presenter.ensureHostSurface(), isTrue);
6464
});
65+
66+
test('detach accepts a fresh tear-off of the attached instance method',
67+
() async {
68+
// A disposed overlay State detaches with a new tear-off of the same
69+
// method; it must still clear the stored callback or hotkeys keep
70+
// invoking a defunct State (regression: settings left open broke the
71+
// overlay shortcut until the settings window was closed via its button).
72+
final presenter = OverlayPresenter();
73+
final host = _ShowHost();
74+
presenter.attach(host.show);
75+
presenter.detach(host.show);
76+
var restored = 0;
77+
presenter.attachHostGuard(() async {
78+
restored++;
79+
return true;
80+
});
81+
82+
presenter.show();
83+
await pumpEventQueue();
84+
85+
expect(host.shown, 0);
86+
expect(restored, 1);
87+
});
88+
89+
test('detach ignores a callback from a different host', () async {
90+
final presenter = OverlayPresenter();
91+
final attached = _ShowHost();
92+
final other = _ShowHost();
93+
presenter.attach(attached.show);
94+
presenter.detach(other.show);
95+
96+
presenter.show();
97+
98+
expect(attached.shown, 1);
99+
expect(other.shown, 0);
100+
});
101+
}
102+
103+
final class _ShowHost {
104+
var shown = 0;
105+
106+
void show() => shown++;
65107
}

0 commit comments

Comments
 (0)