Implementation of TestWindowingOwner for testing multi-window (#179355)

## What's new?
This pull request makes it so that multi window applications can be
tested via `testWidgets`. This PR implements the following:

- A `_TestWindowingOwner` that is used by the
`TestWidgetsFlutterBinding`
- A `_TestRegularWindowController` implementation
- A `_TestDialogWindowController` implementation
- A `_TestFlutterView` and `_TestDisplay` for use by the controllers
- Proper `isActivated` status across controllers
- Proper minimization, maximization, and fullscreen status
- Proper sizing (based on constraints)
- Using the new testing abilities in the multiple windows example app

I have purposefully not implemented tooltip windows yet, as they are
still awaiting an implementation in Win32 to be useful.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
This commit is contained in:
Matthew Kosarek 2025-12-08 09:17:38 -05:00 committed by GitHub
parent 0f591daf10
commit 4000ce2b4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 633 additions and 4 deletions

View File

@ -161,7 +161,7 @@ class _WindowCreatorCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton(
onPressed: () async {
onPressed: () {
final UniqueKey key = UniqueKey();
windowManager.add(
KeyedWindow(
@ -180,7 +180,7 @@ class _WindowCreatorCard extends StatelessWidget {
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () async {
onPressed: () {
final UniqueKey key = UniqueKey();
windowManager.add(
KeyedWindow(

View File

@ -64,8 +64,8 @@ class WindowManagerAccessor extends InheritedNotifier<WindowManager> {
/// Settings that control the behavior of newly created windows.
class WindowSettings {
WindowSettings({
this.regularSize = const Size(400, 300),
this.dialogSize = const Size(200, 200),
this.regularSize = const Size(800, 600),
this.dialogSize = const Size(400, 400),
});
/// The initial size for newly created regular windows.

View File

@ -0,0 +1,115 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: invalid_use_of_internal_member
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// ignore: avoid_relative_lib_imports
import '../lib/main.dart' as multiple_windows;
import 'package:flutter/src/foundation/_features.dart' show isWindowingEnabled;
void main() {
if (!isWindowingEnabled) {
const String windowingDisabledErrorMessage = '''
Skipping multiple_windows_test.dart because Windowing APIs are not enabled.
Windowing APIs are currently experimental. Do not use windowing APIs in
production applications or plugins published to pub.dev.
To try experimental windowing APIs:
1. Switch to Flutter's main release channel.
2. Turn on the windowing feature flag.
See: https://github.com/flutter/flutter/issues/30701.
''';
testWidgets(windowingDisabledErrorMessage, (WidgetTester tester) async {
// No-op test to avoid "no tests found" error.
});
return;
}
testWidgets('Multiple windows smoke test', (WidgetTester tester) async {
multiple_windows.main();
await tester.pump(); // triggers a frame
expect(
find.widgetWithText(AppBar, 'Multi Window Reference App'),
findsOneWidget,
);
});
testWidgets('Can create a regular window', (WidgetTester tester) async {
multiple_windows.main();
await tester.pump(); // triggers a frame
final toTap = find.widgetWithText(OutlinedButton, 'Regular');
expect(toTap, findsOneWidget);
await tester.tap(toTap);
await tester.pump();
expect(find.widgetWithText(AppBar, 'Regular Window'), findsOneWidget);
});
testWidgets('Can create a modeless dialog', (WidgetTester tester) async {
multiple_windows.main();
await tester.pump(); // triggers a frame
final toTap = find.widgetWithText(OutlinedButton, 'Modeless Dialog');
expect(toTap, findsOneWidget);
await tester.tap(toTap);
await tester.pump();
expect(find.widgetWithText(AppBar, 'Dialog'), findsOneWidget);
});
testWidgets('Can create a modal dialog of a regular window', (
WidgetTester tester,
) async {
multiple_windows.main();
await tester.pump(); // triggers a frame
final toTap = find.widgetWithText(OutlinedButton, 'Regular');
expect(toTap, findsOneWidget);
await tester.tap(toTap);
await tester.pump();
final createModalButton = find.widgetWithText(
ElevatedButton,
'Create Modal Dialog',
);
expect(createModalButton, findsOneWidget);
await tester.tap(createModalButton);
await tester.pump();
expect(find.widgetWithText(AppBar, 'Dialog'), findsOneWidget);
});
testWidgets('Can close a modal dialog of a regular window', (
WidgetTester tester,
) async {
multiple_windows.main();
await tester.pump(); // triggers a frame
final toTap = find.widgetWithText(OutlinedButton, 'Regular');
expect(toTap, findsOneWidget);
await tester.tap(toTap);
await tester.pump();
final createModalButton = find.widgetWithText(
ElevatedButton,
'Create Modal Dialog',
);
expect(createModalButton, findsOneWidget);
await tester.tap(createModalButton);
await tester.pump();
final closeModalButton = find.widgetWithText(ElevatedButton, 'Close');
expect(closeModalButton, findsOneWidget);
await tester.tap(closeModalButton);
await tester.pump();
expect(find.widgetWithText(AppBar, 'Dialog'), findsNothing);
});
}

View File

@ -2,6 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: implementation_imports
/// @docImport 'dart:io';
///
/// @docImport 'controller.dart';
@ -19,6 +22,9 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/src/foundation/_features.dart' show isWindowingEnabled;
import 'package:flutter/src/widgets/_window.dart';
import 'package:flutter/src/widgets/_window_positioner.dart';
import 'package:flutter/widgets.dart';
import 'package:matcher/expect.dart' show fail;
import 'package:stack_trace/stack_trace.dart' as stack_trace;
@ -148,6 +154,482 @@ class CapturedAccessibilityAnnouncement {
final Assertiveness assertiveness;
}
class _TestFlutterView implements FlutterView {
_TestFlutterView({required this.controller, required TestPlatformDispatcher platformDispatcher})
: _platformDispatcher = platformDispatcher,
_viewId = _nextViewId++ {
platformDispatcher.addTestView(this);
}
static int _nextViewId = 1;
final BaseWindowController controller;
final TestPlatformDispatcher _platformDispatcher;
final int _viewId;
@override
double get devicePixelRatio => display.devicePixelRatio;
@override
ui.Display get display => platformDispatcher.displays.first;
@override
List<ui.DisplayFeature> get displayFeatures => List<ui.DisplayFeature>.empty();
@override
ui.GestureSettings get gestureSettings => const ui.GestureSettings();
@override
ui.ViewPadding get padding => ui.ViewPadding.zero;
@override
ui.ViewConstraints get physicalConstraints => ui.ViewConstraints.tight(physicalSize);
@override
ui.Size get physicalSize => controller.contentSize * devicePixelRatio;
@override
ui.PlatformDispatcher get platformDispatcher => _platformDispatcher;
@override
ui.ViewPadding get systemGestureInsets => ui.ViewPadding.zero;
@override
int get viewId => _viewId;
@override
ui.ViewPadding get viewInsets => ui.ViewPadding.zero;
@override
ui.ViewPadding get viewPadding => ui.ViewPadding.zero;
@override
void render(ui.Scene scene, {ui.Size? size}) {}
@override
void updateSemantics(ui.SemanticsUpdate update) {}
}
mixin _ChildWindowHierarchyMixin {
final List<BaseWindowController> _children = <BaseWindowController>[];
/// Tracks a child window controller.
void addChild(BaseWindowController child) {
_children.add(child);
}
/// Stops tracking a child window controller.
void removeChild(BaseWindowController child) {
_children.remove(child);
}
/// Removes and destroys all child window controllers.
void removeAllChildren() {
for (final BaseWindowController child in _children) {
child.destroy();
}
_children.clear();
}
/// Returns the first activateable window in this window's hierarchy.
BaseWindowController getFirstActivatableChild() {
// If there are no children, this window is the first activateable window.
if (_children.isEmpty) {
return this as BaseWindowController;
}
// Otherwise, traverse the children to find the first activateable window.
//
// Regular windows are only activated if there are no dialog children.
// Dialog windows are activated before regular windows.
// Tooltips cannot be activated, so they are skipped.
var activateable = this as BaseWindowController;
for (final BaseWindowController child in _children) {
switch (child) {
case final RegularWindowController regularChild:
activateable = (regularChild as _TestRegularWindowController).getFirstActivatableChild();
case final DialogWindowController dialogChild:
return (dialogChild as _TestDialogWindowController).getFirstActivatableChild();
case final TooltipWindowController _:
// Tooltips cannot be activated.
break;
}
}
return activateable;
}
}
class _TestRegularWindowController extends RegularWindowController with _ChildWindowHierarchyMixin {
_TestRegularWindowController({
required RegularWindowControllerDelegate delegate,
required TestPlatformDispatcher platformDispatcher,
required this.windowingOwner,
Size? preferredSize,
BoxConstraints? preferredConstraints,
String? title,
}) : _delegate = delegate,
_size = preferredSize ?? const Size(800, 600),
_constraints = preferredConstraints ?? BoxConstraints.loose(const Size(1920, 1080)),
_title = title ?? 'Test Window',
super.empty() {
_constrainToBounds();
rootView = _TestFlutterView(controller: this, platformDispatcher: platformDispatcher);
// Automatically activate the window when created.
activate();
}
final RegularWindowControllerDelegate _delegate;
final _TestWindowingOwner windowingOwner;
Size _size;
BoxConstraints _constraints;
String _title;
bool _isMaximized = false;
bool _isMinimized = false;
bool _isFullscreen = false;
@override
Size get contentSize => isFullscreen || isMaximized ? rootView.display.size : _size;
@override
String get title => _title;
@override
bool get isActivated => windowingOwner.isWindowControllerActive(this);
@override
bool get isMaximized => _isMaximized;
@override
bool get isMinimized => _isMinimized;
@override
bool get isFullscreen => _isFullscreen;
@override
void setSize(Size size) {
_size = size;
_constrainToBounds();
notifyListeners();
}
@override
void setConstraints(BoxConstraints constraints) {
_constraints = constraints;
_constrainToBounds();
notifyListeners();
}
@override
void setTitle(String title) {
_title = title;
notifyListeners();
}
@override
void activate() {
final BaseWindowController activated = windowingOwner.activateWindowController(this);
activated.notifyListeners();
}
@override
void setMaximized(bool maximized) {
_isMaximized = maximized;
if (_isMaximized) {
_isMinimized = false;
_isFullscreen = false;
}
notifyListeners();
}
@override
void setMinimized(bool minimized) {
_isMinimized = minimized;
if (_isMinimized) {
windowingOwner.deactivateWindowController(this);
}
notifyListeners();
}
@override
void setFullscreen(bool fullscreen, {ui.Display? display}) {
_isFullscreen = fullscreen;
if (_isFullscreen) {
_isMaximized = false;
_isMinimized = false;
}
notifyListeners();
}
void _constrainToBounds() {
final double width = _constraints.constrainWidth(_size.width);
final double height = _constraints.constrainHeight(_size.height);
_size = Size(width, height);
}
@override
void destroy() {
_delegate.onWindowDestroyed();
removeAllChildren();
windowingOwner.deactivateWindowController(this);
}
}
class _TestDialogWindowController extends DialogWindowController with _ChildWindowHierarchyMixin {
_TestDialogWindowController({
required DialogWindowControllerDelegate delegate,
required TestPlatformDispatcher platformDispatcher,
required this.windowingOwner,
BaseWindowController? parent,
Size? preferredSize,
BoxConstraints? preferredConstraints,
String? title,
}) : _delegate = delegate,
_parent = parent,
_size = preferredSize ?? const Size(800, 600),
_constraints = preferredConstraints ?? BoxConstraints.loose(const Size(1920, 1080)),
_title = title ?? 'Test Window',
super.empty() {
_constrainToBounds();
rootView = _TestFlutterView(controller: this, platformDispatcher: platformDispatcher);
if (parent != null) {
switch (parent) {
case final _TestDialogWindowController testParent:
testParent.addChild(this);
case final _TestRegularWindowController testParent:
testParent.addChild(this);
default:
fail('Unknown window controller type: ${parent.runtimeType}');
}
}
// Automatically activate the window when created.
activate();
}
final DialogWindowControllerDelegate _delegate;
final BaseWindowController? _parent;
final _TestWindowingOwner windowingOwner;
Size _size;
BoxConstraints _constraints;
String _title;
bool _isMinimized = false;
@override
Size get contentSize => _size;
@override
BaseWindowController? get parent => _parent;
@override
String get title => _title;
@override
bool get isActivated => windowingOwner.isWindowControllerActive(this);
@override
bool get isMinimized => _isMinimized;
@override
void setSize(Size size) {
_size = size;
_constrainToBounds();
notifyListeners();
}
@override
void setConstraints(BoxConstraints constraints) {
_constraints = constraints;
_constrainToBounds();
notifyListeners();
}
@override
void setTitle(String title) {
_title = title;
notifyListeners();
}
@override
void activate() {
final BaseWindowController activated = windowingOwner.activateWindowController(this);
activated.notifyListeners();
}
@override
void setMinimized(bool minimized) {
if (_parent != null && minimized) {
fail('Cannot minimize a modal dialog window.');
}
_isMinimized = minimized;
if (_isMinimized) {
windowingOwner.deactivateWindowController(this);
}
notifyListeners();
}
void _constrainToBounds() {
final double width = _constraints.constrainWidth(_size.width);
final double height = _constraints.constrainHeight(_size.height);
_size = Size(width, height);
}
@override
void destroy() {
_delegate.onWindowDestroyed();
removeAllChildren();
windowingOwner.deactivateWindowController(this);
if (_parent != null) {
switch (_parent) {
case final RegularWindowController regularParent:
(regularParent as _TestRegularWindowController).removeChild(this);
case final DialogWindowController dialogParent:
(dialogParent as _TestDialogWindowController).removeChild(this);
case TooltipWindowController _:
fail('TooltipWindowController cannot be a parent of DialogWindowController.');
}
}
}
}
/// A [WindowingOwner] used for tests.
///
/// This windowing owner will behave as a perfect windowing system, with no
/// delays or failures.
///
/// See also:
/// * [TestWidgetsFlutterBinding], which uses this class to create window controllers
/// for tests.
/// * [WindowingOwner], the base class.
class _TestWindowingOwner extends WindowingOwner {
_TestWindowingOwner({required TestPlatformDispatcher platformDispatcher})
: _platformDispatcher = platformDispatcher;
final TestPlatformDispatcher _platformDispatcher;
BaseWindowController? _activeWindowController;
/// Activates the given [controller].
///
/// If the controller has children, the first activateable window in its hierarchy
/// will be activated.
///
/// Tooltips cannot be activated, so if a [TooltipWindowController] is passed in,
/// this method will throw an error.
///
/// Returns the activated [BaseWindowController].
BaseWindowController activateWindowController(BaseWindowController controller) {
switch (controller) {
case final RegularWindowController regularController:
final BaseWindowController leaf = (regularController as _TestRegularWindowController)
.getFirstActivatableChild();
_activeWindowController = leaf;
return _activeWindowController!;
case final DialogWindowController dialogController:
final BaseWindowController leaf = (dialogController as _TestDialogWindowController)
.getFirstActivatableChild();
_activeWindowController = leaf;
return _activeWindowController!;
case final TooltipWindowController _:
fail('Tooltips cannot be activated. Activate the parent window instead.');
}
}
/// Deactivates the given [controller] if it is currently active.
///
/// If the controller is not currently active, this method does nothing.
///
/// If the controller is a [DialogWindowController] with a parent, the parent
/// will be activated upon deactivation of the dialog.
///
/// If the controller is a [TooltipWindowController], this method will throw
/// an error, as tooltips cannot be deactivated because they cannot be activated.
void deactivateWindowController(BaseWindowController controller) {
if (_activeWindowController == controller) {
switch (controller) {
case final RegularWindowController _:
_activeWindowController = null;
case final DialogWindowController dialogController:
if (dialogController.parent == null) {
_activeWindowController = null;
break;
}
switch (dialogController.parent!) {
case final RegularWindowController regularParent:
regularParent.activate();
case final DialogWindowController dialogParent:
dialogParent.activate();
case final TooltipWindowController _:
fail('TooltipWindowController cannot be a parent of DialogWindowController.');
}
case final TooltipWindowController tooltipController:
fail(
'Tooltips cannot be deactivated. Deactivate the parent window instead: '
'${tooltipController.parent}.',
);
}
}
}
/// Returns whether the given [controller] is the currently active window.
bool isWindowControllerActive(BaseWindowController controller) {
return _activeWindowController == controller;
}
@internal
@override
RegularWindowController createRegularWindowController({
required RegularWindowControllerDelegate delegate,
Size? preferredSize,
BoxConstraints? preferredConstraints,
String? title,
}) {
return _TestRegularWindowController(
delegate: delegate,
platformDispatcher: _platformDispatcher,
windowingOwner: this,
preferredSize: preferredSize,
preferredConstraints: preferredConstraints,
title: title,
);
}
@internal
@override
DialogWindowController createDialogWindowController({
required DialogWindowControllerDelegate delegate,
Size? preferredSize,
BoxConstraints? preferredConstraints,
BaseWindowController? parent,
String? title,
}) {
return _TestDialogWindowController(
delegate: delegate,
platformDispatcher: _platformDispatcher,
windowingOwner: this,
parent: parent,
preferredSize: preferredSize,
preferredConstraints: preferredConstraints,
title: title,
);
}
@override
TooltipWindowController createTooltipWindowController({
required TooltipWindowControllerDelegate delegate,
required BoxConstraints preferredConstraints,
required ui.Rect anchorRect,
required WindowPositioner positioner,
required BaseWindowController parent,
}) {
// TODO(mattkae): implement createTooltipWindowController
throw UnimplementedError();
}
}
// Examples can assume:
// late TestWidgetsFlutterBinding binding;
// late Size someSize;
@ -332,6 +814,20 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
@protected
bool get registerTestTextInput => true;
/// Determines whether the binding automatically registers [windowingOwner] to
/// the fake windowing owner implementation.
///
/// Unit tests make use of this to mock out windowing system communication for
/// widgets. An integration test would set this to false, to test real windowing
/// system input.
///
/// This property should not change the value it returns during the lifetime
/// of the binding. Changing the value of this property risks very confusing
/// behavior as the [WindowingOwner] may be inconsistently registered or
/// unregistered.
@protected
bool get registerTestWindowingOwner => true;
/// Delay for `duration` of time.
///
/// In the automated test environment ([AutomatedTestWidgetsFlutterBinding],
@ -400,6 +896,10 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
binding.setupHttpOverrides();
}
_testTextInput = TestTextInput(onCleared: _resetFocusedEditable);
if (isWindowingEnabled && registerTestWindowingOwner) {
windowingOwner = _TestWindowingOwner(platformDispatcher: platformDispatcher);
}
}
@override

View File

@ -874,6 +874,20 @@ class TestPlatformDispatcher implements PlatformDispatcher {
extraViewKeys.forEach(_testViews.remove);
}
/// Adds a [TestFlutterView] that wraps the given [view] to the list of views
/// managed by this [TestPlatformDispatcher].
///
/// The added view will be associated with the first display in the list of
/// displays managed by this [TestPlatformDispatcher].
void addTestView(FlutterView view) {
_testViews[view.viewId] = TestFlutterView(
view: view,
platformDispatcher: this,
display: displays.first,
);
_updateViewsAndDisplays();
}
@override
ErrorCallback? get onError => _platformDispatcher.onError;
@override