Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 60 additions & 22 deletions lib/src/widgets/virtual_window_frame.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class VirtualWindowFrame extends StatefulWidget {
}

class _VirtualWindowFrameState extends State<VirtualWindowFrame> {
// 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;
Expand All @@ -34,38 +37,65 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame> {
@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 =>
Expand All @@ -74,7 +104,9 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame> {
() => 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;
Expand All @@ -85,6 +117,12 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame> {
isFullScreen == _isFullScreen) {
return;
}
if (!notify) {
_isFocused = isFocused;
_isMaximized = isMaximized;
_isFullScreen = isFullScreen;
return;
}
if (!mounted) return;
setState(() {
_isFocused = isFocused;
Expand Down
62 changes: 45 additions & 17 deletions lib/src/widgets/window_caption.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,44 +50,72 @@ class WindowCaption extends StatefulWidget {
}

class _WindowCaptionState extends State<WindowCaption> {
// 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 =>
Expand Down
143 changes: 140 additions & 3 deletions test/window_caption_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <ListenerId, FutureOr<void> Function(WindowEvent)>{};
var _nextListenerId = 0;

int get listenerCount => _listeners.length;

@override
ListenerId addListener(FutureOr<void> Function(WindowEvent) callback) {
_listeners[++_nextListenerId] = callback;
return _nextListenerId;
}

@override
bool removeListener(ListenerId listenerId) =>
_listeners.remove(listenerId) != null;

Future<void> 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() {
Expand Down Expand Up @@ -53,4 +91,103 @@ void main() {
expect(normal.maximizes, 1);
expect(normal.unmaximizes, 0);
});

String maximizeIcon(WidgetTester tester) => tester
.widget<WindowCaptionButton>(find.byType(WindowCaptionButton).at(1))
.iconName!;

Future<void> 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<void> 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);
});
}