diff --git a/lib/src/widgets/virtual_window_frame.dart b/lib/src/widgets/virtual_window_frame.dart index c504111f..5ff5dbca 100644 --- a/lib/src/widgets/virtual_window_frame.dart +++ b/lib/src/widgets/virtual_window_frame.dart @@ -26,6 +26,9 @@ class VirtualWindowFrame extends StatefulWidget { } class _VirtualWindowFrameState extends State { + // Where [_listenerId] is registered: the given window, or null for the + // process-wide window manager when the frame follows the current window. + nativeapi.Window? _subscribedWindow; nativeapi.ListenerId? _listenerId; bool _isFocused = true; bool _isMaximized = false; @@ -34,38 +37,65 @@ class _VirtualWindowFrameState extends State { @override void initState() { super.initState(); - _readState(); - _listenerId = tryNative( - () => nativeapi.WindowManager.instance.addListener((event) { - switch (event) { - case nativeapi.WindowFocusedEvent(): - case nativeapi.WindowBlurredEvent(): - case nativeapi.WindowMaximizedEvent(): - case nativeapi.WindowRestoredEvent(): - case nativeapi.WindowResizedEvent(): - case nativeapi.WindowEnteredFullScreenEvent(): - case nativeapi.WindowExitedFullScreenEvent(): - _readState(); - default: - break; - } - }), - ); + _subscribe(); + _readState(notify: false); } @override void didUpdateWidget(covariant VirtualWindowFrame oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.window != widget.window) _readState(); + if (oldWidget.window != widget.window) { + _unsubscribe(); + _subscribe(); + _readState(notify: false); + } } @override void dispose() { + _unsubscribe(); + super.dispose(); + } + + // A frame given its window hears only that window's events, so resizing or + // focusing another window of a multi-window app does not make every frame + // query its own window. Without a window it keeps following the current one. + void _subscribe() { + final window = widget.window; + _subscribedWindow = window; + _listenerId = tryNative( + () => window != null + ? window.addListener(_onWindowEvent) + : nativeapi.WindowManager.instance.addListener(_onWindowEvent), + ); + } + + void _unsubscribe() { + final window = _subscribedWindow; final listenerId = _listenerId; - if (listenerId != null) { - nativeapi.WindowManager.instance.removeListener(listenerId); + _subscribedWindow = null; + _listenerId = null; + if (listenerId == null) return; + tryNative( + () => window != null + ? window.removeListener(listenerId) + : nativeapi.WindowManager.instance.removeListener(listenerId), + ); + } + + void _onWindowEvent(nativeapi.WindowEvent event) { + switch (event) { + case nativeapi.WindowFocusedEvent(): + case nativeapi.WindowBlurredEvent(): + case nativeapi.WindowMaximizedEvent(): + case nativeapi.WindowRestoredEvent(): + case nativeapi.WindowResizedEvent(): + case nativeapi.WindowEnteredFullScreenEvent(): + case nativeapi.WindowExitedFullScreenEvent(): + _readState(); + default: + break; } - super.dispose(); } nativeapi.Window? get _window => @@ -74,7 +104,9 @@ class _VirtualWindowFrameState extends State { () => nativeapi.WindowManager.instance.getCurrent(), ); - void _readState() { + // [notify] is false while the framework is about to build anyway, as in + // initState, where setState is not allowed. + void _readState({bool notify = true}) { final window = _window; if (window == null) return; final isFocused = window.isFocused; @@ -85,6 +117,12 @@ class _VirtualWindowFrameState extends State { isFullScreen == _isFullScreen) { return; } + if (!notify) { + _isFocused = isFocused; + _isMaximized = isMaximized; + _isFullScreen = isFullScreen; + return; + } if (!mounted) return; setState(() { _isFocused = isFocused; diff --git a/lib/src/widgets/window_caption.dart b/lib/src/widgets/window_caption.dart index ebf0ff65..637959ba 100644 --- a/lib/src/widgets/window_caption.dart +++ b/lib/src/widgets/window_caption.dart @@ -50,44 +50,72 @@ class WindowCaption extends StatefulWidget { } class _WindowCaptionState extends State { + // Where [_listenerId] is registered: the given window, or null for the + // process-wide window manager when the caption follows the current window. + nativeapi.Window? _subscribedWindow; nativeapi.ListenerId? _listenerId; bool _isMaximized = false; @override void initState() { super.initState(); + _subscribe(); _isMaximized = _window?.isMaximized ?? false; - _listenerId = tryNative( - () => nativeapi.WindowManager.instance.addListener((event) { - switch (event) { - case nativeapi.WindowMaximizedEvent(): - case nativeapi.WindowRestoredEvent(): - final isMaximized = _window?.isMaximized ?? false; - if (isMaximized != _isMaximized && mounted) { - setState(() => _isMaximized = isMaximized); - } - default: - break; - } - }), - ); } @override void didUpdateWidget(covariant WindowCaption oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.window != widget.window) { + _unsubscribe(); + _subscribe(); _isMaximized = _window?.isMaximized ?? false; } } @override void dispose() { + _unsubscribe(); + super.dispose(); + } + + // A caption given its window hears only that window's events, so other + // windows of a multi-window app neither wake it nor leak their state into + // it. Without a window it keeps following the current one. + void _subscribe() { + final window = widget.window; + _subscribedWindow = window; + _listenerId = tryNative( + () => window != null + ? window.addListener(_onWindowEvent) + : nativeapi.WindowManager.instance.addListener(_onWindowEvent), + ); + } + + void _unsubscribe() { + final window = _subscribedWindow; final listenerId = _listenerId; - if (listenerId != null) { - nativeapi.WindowManager.instance.removeListener(listenerId); + _subscribedWindow = null; + _listenerId = null; + if (listenerId == null) return; + tryNative( + () => window != null + ? window.removeListener(listenerId) + : nativeapi.WindowManager.instance.removeListener(listenerId), + ); + } + + void _onWindowEvent(nativeapi.WindowEvent event) { + switch (event) { + case nativeapi.WindowMaximizedEvent(): + case nativeapi.WindowRestoredEvent(): + final isMaximized = _window?.isMaximized ?? false; + if (isMaximized != _isMaximized && mounted) { + setState(() => _isMaximized = isMaximized); + } + default: + break; } - super.dispose(); } nativeapi.Window? get _window => diff --git a/test/window_caption_test.dart b/test/window_caption_test.dart index d5ef991d..5dc6f1a5 100644 --- a/test/window_caption_test.dart +++ b/test/window_caption_test.dart @@ -1,17 +1,55 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:window_manager/window_manager.dart'; -// Only the caption's window state is needed; no native window is created. +// Only the widgets' window state is needed; no native window is created. class _Window extends Window { _Window({required this.maximized}) : super.borrowed(0); - final bool maximized; + bool maximized; int maximizes = 0; int unmaximizes = 0; + int stateReads = 0; + final _listeners = Function(WindowEvent)>{}; + var _nextListenerId = 0; + + int get listenerCount => _listeners.length; + + @override + ListenerId addListener(FutureOr Function(WindowEvent) callback) { + _listeners[++_nextListenerId] = callback; + return _nextListenerId; + } + + @override + bool removeListener(ListenerId listenerId) => + _listeners.remove(listenerId) != null; + + Future emit(WindowEvent event) async { + for (final listener in _listeners.values.toList()) { + await listener(event); + } + } @override - bool get isMaximized => maximized; + bool get isMaximized { + stateReads++; + return maximized; + } + + @override + bool get isFocused { + stateReads++; + return true; + } + + @override + bool get isFullScreen { + stateReads++; + return false; + } @override void maximize() { @@ -53,4 +91,103 @@ void main() { expect(normal.maximizes, 1); expect(normal.unmaximizes, 0); }); + + String maximizeIcon(WidgetTester tester) => tester + .widget(find.byType(WindowCaptionButton).at(1)) + .iconName!; + + Future showCaption(WidgetTester tester, Window? window) => + tester.pumpWidget( + MaterialApp( + home: SizedBox( + height: kWindowCaptionHeight, + child: WindowCaption(window: window), + ), + ), + ); + + testWidgets('caption listens only to its own window', (tester) async { + final a = _Window(maximized: false); + final b = _Window(maximized: false); + + await showCaption(tester, a); + expect(a.listenerCount, 1); + expect(b.listenerCount, 0); + + // Moving the caption to another window releases the old subscription. + await showCaption(tester, b); + expect(a.listenerCount, 0); + expect(b.listenerCount, 1); + + a.maximized = true; + await a.emit(const WindowMaximizedEvent(windowId: 1)); + await tester.pump(); + expect(maximizeIcon(tester), 'icon_chrome_maximize'); + + b.maximized = true; + await b.emit(const WindowMaximizedEvent(windowId: 2)); + await tester.pump(); + expect(maximizeIcon(tester), 'icon_chrome_unmaximize'); + + b.maximized = false; + await b.emit(const WindowRestoredEvent(windowId: 2)); + await tester.pump(); + expect(maximizeIcon(tester), 'icon_chrome_maximize'); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(b.listenerCount, 0); + }); + + testWidgets('caption keeps its subscription when rebuilt with its window', ( + tester, + ) async { + final window = _Window(maximized: false); + + await showCaption(tester, window); + await showCaption(tester, window); + expect(window.listenerCount, 1); + expect(window._nextListenerId, 1); + }); + + testWidgets('caption without a window builds and disposes cleanly', ( + tester, + ) async { + await showCaption(tester, null); + expect(maximizeIcon(tester), 'icon_chrome_maximize'); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + Future showFrame(WidgetTester tester, Window window) => + tester.pumpWidget( + MaterialApp( + home: VirtualWindowFrame(window: window, child: const SizedBox()), + ), + ); + + testWidgets('frame listens only to its own window', (tester) async { + final a = _Window(maximized: true); + final b = _Window(maximized: false); + + // A window that starts maximized is read without setState in initState. + await showFrame(tester, a); + expect(tester.takeException(), isNull); + expect(a.listenerCount, 1); + + await showFrame(tester, b); + expect(a.listenerCount, 0); + expect(b.listenerCount, 1); + + // Events of the window it left no longer reach the frame. + final readsOfB = b.stateReads; + await a.emit(const WindowBlurredEvent(windowId: 1)); + await a.emit(const WindowFocusedEvent(windowId: 1)); + expect(a.stateReads, 3, reason: 'only its initial read'); + expect(b.stateReads, readsOfB); + + await b.emit(const WindowMaximizedEvent(windowId: 2)); + expect(b.stateReads, readsOfB + 3); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(b.listenerCount, 0); + }); }