diff --git a/examples/multiple_windows/lib/app/main_window.dart b/examples/multiple_windows/lib/app/main_window.dart index f293afc65ae..9c8b18e3375 100644 --- a/examples/multiple_windows/lib/app/main_window.dart +++ b/examples/multiple_windows/lib/app/main_window.dart @@ -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( diff --git a/examples/multiple_windows/lib/app/models.dart b/examples/multiple_windows/lib/app/models.dart index efa616431e5..cb41b5557e7 100644 --- a/examples/multiple_windows/lib/app/models.dart +++ b/examples/multiple_windows/lib/app/models.dart @@ -64,8 +64,8 @@ class WindowManagerAccessor extends InheritedNotifier { /// 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. diff --git a/examples/multiple_windows/test/multiple_windows_test.dart b/examples/multiple_windows/test/multiple_windows_test.dart new file mode 100644 index 00000000000..20975677d65 --- /dev/null +++ b/examples/multiple_windows/test/multiple_windows_test.dart @@ -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); + }); +} diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 0b7eccd2a7e..75e21edfd5e 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -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 get displayFeatures => List.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 _children = []; + + /// 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 diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart index cb863786465..335667c54ca 100644 --- a/packages/flutter_test/lib/src/window.dart +++ b/packages/flutter_test/lib/src/window.dart @@ -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