Adding the SatelliteWindowController interface to `_window.dart` (#182903)
## What's new?
- Added the concept of a `SatelliteWindowController` and
`SatelliteWindow` widget to the `_window.dart` API
- Wrote tests for it
- Throwing "unsupported" for all existing `WindowingOwner`s when trying
to create it
- Stubbed it in the example application
- Implemented a "happy" implementation in the `flutter_test` package
## 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.
diff --git a/examples/api/lib/widgets/windows/satellite.0.dart b/examples/api/lib/widgets/windows/satellite.0.dart
new file mode 100644
index 0000000..6ed7262
--- /dev/null
+++ b/examples/api/lib/widgets/windows/satellite.0.dart
@@ -0,0 +1,125 @@
+// 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.
+
+// TODO(mattkae): remove invalid_use_of_internal_member ignore comment when this API is stable.
+// See: https://github.com/flutter/flutter/issues/177586
+// ignore_for_file: invalid_use_of_internal_member
+// ignore_for_file: implementation_imports
+import 'package:flutter/material.dart';
+import 'package:flutter/src/widgets/_window.dart';
+import 'package:flutter/src/widgets/_window_positioner.dart';
+
+void main() {
+ try {
+ WidgetsFlutterBinding.ensureInitialized();
+ runWidget(
+ RegularWindow(
+ controller: RegularWindowController(
+ preferredSize: const Size(800, 600),
+ preferredConstraints: const BoxConstraints(
+ minWidth: 640,
+ minHeight: 480,
+ ),
+ title: 'Example Window',
+ ),
+ child: const MaterialApp(home: MyApp()),
+ ),
+ );
+ } on UnsupportedError catch (_) {
+ // TODO(mattkae): Remove this catch block when satellite windows are supported in tests.
+ // For now, we need to catch the error so that the API smoke tests pass.
+ runApp(
+ MaterialApp(
+ home: Scaffold(body: Center(child: Text('Unsupported'))),
+ ),
+ );
+ }
+}
+
+class MyApp extends StatefulWidget {
+ const MyApp({super.key});
+
+ @override
+ State<MyApp> createState() => _MyAppState();
+}
+
+class _CallbackSatelliteDelegate extends SatelliteWindowControllerDelegate {
+ _CallbackSatelliteDelegate({required this.onDestroyCallback});
+
+ final VoidCallback onDestroyCallback;
+
+ @override
+ void onWindowDestroyed() {
+ onDestroyCallback();
+ }
+}
+
+class _MyAppState extends State<MyApp> {
+ SatelliteWindowController? _satelliteController;
+
+ @override
+ Widget build(BuildContext context) {
+ final List<Widget> children = <Widget>[
+ ElevatedButton(
+ onPressed: () {
+ setState(() {
+ _satelliteController ??= SatelliteWindowController(
+ parent: WindowScope.of(context),
+ initialPositioner: const WindowPositioner(
+ parentAnchor: WindowPositionerAnchor.right,
+ childAnchor: WindowPositionerAnchor.left,
+ ),
+ preferredSize: const Size(300, 200),
+ title: 'Satellite Window',
+ delegate: _CallbackSatelliteDelegate(
+ onDestroyCallback: () {
+ setState(() {
+ _satelliteController = null;
+ });
+ },
+ ),
+ );
+ });
+ },
+ child: const Text('Show Satellite'),
+ ),
+ ];
+
+ if (_satelliteController != null) {
+ children.add(
+ SatelliteWindow(
+ controller: _satelliteController!,
+ child: Container(
+ padding: const EdgeInsets.all(8),
+ color: Colors.black,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ const Text(
+ 'This is a satellite window',
+ style: TextStyle(color: Colors.white),
+ ),
+ const SizedBox(height: 8),
+ ElevatedButton(
+ onPressed: () {
+ setState(() {
+ _satelliteController?.destroy();
+ });
+ },
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ return Scaffold(
+ body: Center(
+ child: Row(mainAxisSize: MainAxisSize.min, children: children),
+ ),
+ );
+ }
+}
diff --git a/examples/api/test/widgets/windows/satellite.0_test.dart b/examples/api/test/widgets/windows/satellite.0_test.dart
new file mode 100644
index 0000000..f2f3bef
--- /dev/null
+++ b/examples/api/test/widgets/windows/satellite.0_test.dart
@@ -0,0 +1,16 @@
+// 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.
+
+import 'package:flutter_api_samples/widgets/windows/satellite.0.dart'
+ as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('Calling satellite main returns normally', (
+ WidgetTester tester,
+ ) async {
+ expect(() => example.main(), returnsNormally);
+ expect(find.text('Unsupported'), findsOneWidget);
+ });
+}
diff --git a/examples/multiple_windows/lib/app/main_window.dart b/examples/multiple_windows/lib/app/main_window.dart
index 35e636e..e83ce70 100644
--- a/examples/multiple_windows/lib/app/main_window.dart
+++ b/examples/multiple_windows/lib/app/main_window.dart
@@ -141,6 +141,7 @@
controller: tooltip,
),
PopupWindowController() => null,
+ SatelliteWindowController() => null,
};
}
@@ -150,6 +151,7 @@
DialogWindowController() => 'Dialog',
TooltipWindowController() => 'Tooltip',
PopupWindowController() => 'Popup',
+ SatelliteWindowController() => 'Satellite',
};
}
diff --git a/examples/multiple_windows/lib/app/window_content.dart b/examples/multiple_windows/lib/app/window_content.dart
index 5b1d7c1..dac69e1 100644
--- a/examples/multiple_windows/lib/app/window_content.dart
+++ b/examples/multiple_windows/lib/app/window_content.dart
@@ -49,6 +49,7 @@
),
),
PopupWindowController() => throw UnimplementedError(),
+ SatelliteWindowController() => throw UnimplementedError(),
};
}
}
diff --git a/packages/flutter/lib/src/widgets/_window.dart b/packages/flutter/lib/src/widgets/_window.dart
index a108d56..bfec2df 100644
--- a/packages/flutter/lib/src/widgets/_window.dart
+++ b/packages/flutter/lib/src/widgets/_window.dart
@@ -890,6 +890,248 @@
void setConstraints(BoxConstraints constraints);
}
+/// Delegate class for satellite window controller.
+///
+/// {@macro flutter.widgets.windowing.experimental}
+///
+/// See also:
+///
+/// * [SatelliteWindowController], the controller that creates and manages a satellite window.
+/// * [SatelliteWindow], the widget for a satellite window.
+@internal
+mixin class SatelliteWindowControllerDelegate {
+ /// Invoked when the user attempts to close the window.
+ ///
+ /// The default implementation destroys the window. Subclasses
+ /// can override the behavior to delay or prevent the window from closing.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ ///
+ /// See also:
+ ///
+ /// * [onWindowDestroyed], which is invoked after the window is closed.
+ @internal
+ void onWindowCloseRequested(SatelliteWindowController controller) {
+ if (!isWindowingEnabled) {
+ throw UnsupportedError(_kWindowingDisabledErrorMessage);
+ }
+
+ controller.destroy();
+ }
+
+ /// Invoked after the window is closed.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ ///
+ /// See also:
+ ///
+ /// * [onWindowCloseRequested], which is invoked when the user attempts to close the window.
+ @internal
+ void onWindowDestroyed() {
+ if (!isWindowingEnabled) {
+ throw UnsupportedError(_kWindowingDisabledErrorMessage);
+ }
+ }
+}
+
+/// A controller for a satellite window.
+///
+/// A satellite window is an auxiliary window to a regular or dialog window.
+/// Satellite windows are initiially placed using a [WindowPositioner]. Afterwards,
+/// the satellite window maintains its position relative to its parent. It is
+/// hidden if the application becomes fullscreen or maximized.
+///
+/// Satellite windows may be resized and moved by the user. After being moved by
+/// the user, the satellite window will retain its new position relative to its
+/// parent such that when its parent moves, the satellite will be moved by the same
+/// offset.
+///
+/// A satellite may be reparented. For example, if an application has two documents
+/// open, the satellite may choose to reparent to the active document such that
+/// closing one document will not cause the satellite to close. This behavior
+/// may be implemented at the library level, such as in the Material API.
+/// Reparenting a satellite will not change the current absolute position of the
+/// satellite.
+///
+/// Upon construction, the window is created for the platform with the provided
+/// properties.
+///
+/// This class does not interact with the widget tree. Instead, it is typically
+/// provided to the [SatelliteWindow] widget, who does the work of rendering the
+/// content inside of this window.
+///
+/// The user of this class is responsible for managing the lifecycle of the window.
+/// When the window is no longer needed, the user should call [destroy] on this
+/// controller to release the resources associated with the window.
+///
+/// If the parent window of the satellite is destroyed, then the satellite will
+/// be destroyed as well. The user does not need to explicitly call [destroy]
+/// in this case.
+///
+/// [SatelliteWindowControllerDelegate.onWindowDestroyed]
+/// will be called when the window is destroyed.
+///
+/// {@tool snippet}
+/// An example usage of [SatelliteWindowController] looks like:
+///
+/// ** See code in examples/api/lib/widgets/windows/satellite.0.dart **
+/// {@end-tool}
+///
+/// Children of a [SatelliteWindow] widget can access the [SatelliteWindowController]
+/// via the [WindowScope] inherited widget.
+///
+/// {@macro flutter.widgets.windowing.experimental}
+@internal
+abstract class SatelliteWindowController extends BaseWindowController {
+ /// Creates a [SatelliteWindowController] with the provided properties.
+ ///
+ /// Upon construction, the window is created by the platform.
+ ///
+ /// The [parent] argument specifies the parent window of this satellite.
+ ///
+ /// The [initialPositioner] argument specifies how the satellite should be positioned
+ /// relative to the [initialAnchorRect]. The positioner is only applied the first
+ /// time that the window is shown. Afterwards, the user may move and resize
+ /// the window to their preference. If the [parent] of the satellite moves, the
+ /// satellite is moved relative to its parent. The satellite will always retain
+ /// its current offset from its parent unless it is moved independently.
+ ///
+ /// The [initialAnchorRect] argument specifies the rectangle in the parent's coordinate
+ /// space to which the tooltip is anchored. If it is `null`, then the satellite
+ /// is position relative to the parent window, including its decorations.
+ ///
+ /// {@macro flutter.widgets.windowing.constraints}
+ ///
+ /// The [title] argument configures the window's title.
+ /// If omitted, some platforms might fall back to the app's name.
+ ///
+ /// The [delegate] argument can be used to listen to the window's
+ /// lifecycle. For example, it can be used to save state before
+ /// a window is closed.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ factory SatelliteWindowController({
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ SatelliteWindowControllerDelegate? delegate,
+ }) {
+ if (!isWindowingEnabled) {
+ throw UnsupportedError(_kWindowingDisabledErrorMessage);
+ }
+
+ if (preferredSize != null && preferredConstraints != null) {
+ assert(preferredConstraints.isSatisfiedBy(preferredSize));
+ }
+
+ final WindowingOwner owner = WidgetsBinding.instance.windowingOwner;
+ return owner.createSatelliteWindowController(
+ delegate: delegate ?? SatelliteWindowControllerDelegate(),
+ parent: parent,
+ initialAnchorRect: initialAnchorRect,
+ initialPositioner: initialPositioner,
+ preferredSize: preferredSize,
+ preferredConstraints: preferredConstraints,
+ title: title,
+ );
+ }
+
+ /// Creates an empty [SatelliteWindowController].
+ ///
+ /// This method is only intended to be used by subclasses of the
+ /// [SatelliteWindowController].
+ ///
+ /// Users who want to instantiate a new [SatelliteWindowController] should
+ /// always use the factory method to create a controller that is valid
+ /// for their particular platform.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ @protected
+ SatelliteWindowController.empty();
+
+ /// The parent controller of this satellite.
+ ///
+ /// The satellite will be destroyed if its parent is destroyed.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ BaseWindowController get parent;
+
+ /// The current title of the window.
+ ///
+ /// The title shown in the window is controlled by the platform and may differ
+ /// from the given `title`.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ String get title;
+
+ /// Whether the window is currently activated.
+ ///
+ /// If `true` this means that the window is currently focused and
+ /// can receive user input.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ bool get isActivated;
+
+ /// Request to change the parent of the window.
+ ///
+ /// The satellite will maintain its current position after being reparented.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ void setParent(BaseWindowController parent);
+
+ /// Request change to the content size of the window.
+ ///
+ /// The [size] describes the new requested window size. If the size disagrees
+ /// with the current constraints placed upon the window, the platform might
+ /// clamp the size within the constraints.
+ ///
+ /// The platform is free to ignore this request.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ void setSize(Size size);
+
+ /// Request change to the constraints of the window.
+ ///
+ /// The [constraints] describes the new constraints that the window should
+ /// satisfy. If the constraints disagree with the current size of the window,
+ /// the platform might resize the window to satisfy the new constraints.
+ ///
+ /// The platform is free to ignore this request.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ void setConstraints(BoxConstraints constraints);
+
+ /// Request change for the window title.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ void setTitle(String title);
+
+ /// Requests that the window be displayed in its current size and position.
+ ///
+ /// The platform may also give the window input focus and bring it to the
+ /// top of the window stack. However, this behavior is platform-dependent.
+ ///
+ /// If the window is minimized, the window returns to the size and position
+ /// that it had before that state was applied. The window will also be
+ /// brought to the top of the window stack.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ void activate();
+}
+
/// [WindowingOwner] is responsible for creating and managing window controllers.
///
/// A custom implementation can be provided by setting [WidgetsBinding.windowingOwner].
@@ -960,6 +1202,24 @@
required WindowPositioner positioner,
required BaseWindowController parent,
});
+
+ /// Creates a [SatelliteWindowController] with the provided properties.
+ ///
+ /// Most app developers should use [SatelliteWindowController]'s constructor
+ /// instead of calling this method directly. This method allows platforms
+ /// to inject platform-specific logic.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ SatelliteWindowController createSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ });
}
/// Creates default windowing owner for standard desktop embedders.
@@ -1028,6 +1288,19 @@
}) {
throw UnimplementedError(errorMessage);
}
+
+ @override
+ SatelliteWindowController createSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ }) {
+ throw UnimplementedError(errorMessage);
+ }
}
/// The [RegularWindow] widget provides a way to render a regular window in the
@@ -1320,6 +1593,68 @@
}
}
+/// The [SatelliteWindow] widget provides a way to render a satellite window in the
+/// widget tree.
+///
+/// The provided [controller] creates the native window that backs
+/// the widget. The [child] widget is rendered into this newly created window.
+///
+/// When a [SatelliteWindow] widget is removed from the tree, the window that was created
+/// by the [controller] remains valid until the caller destroys it by calling
+/// [SatelliteWindowController.destroy].
+///
+/// Widgets in the same tree as the [child] widget will have access to the
+/// [SatelliteWindowController] via the [WindowScope] widget.
+///
+/// {@macro flutter.widgets.windowing.experimental}
+///
+/// See also:
+///
+/// * [SatelliteWindowController], the controller that creates and manages satellite windows.
+@internal
+class SatelliteWindow extends StatelessWidget {
+ /// Creates a satellite window widget.
+ ///
+ /// The [controller] creates the native backing window into which the
+ /// [child] widget is rendered.
+ ///
+ /// It is up to the caller to destroy the window by calling
+ /// [SatelliteWindowController.destroy] when the window is no longer needed.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ SatelliteWindow({super.key, required this.controller, required this.child}) {
+ if (!isWindowingEnabled) {
+ throw UnsupportedError(_kWindowingDisabledErrorMessage);
+ }
+ }
+
+ /// Controller for this widget.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ final SatelliteWindowController controller;
+
+ /// The content rendered into this window.
+ ///
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ final Widget child;
+
+ /// {@macro flutter.widgets.windowing.experimental}
+ @internal
+ @override
+ Widget build(BuildContext context) {
+ return ListenableBuilder(
+ listenable: controller,
+ builder: (BuildContext context, Widget? widget) => WindowScope(
+ controller: controller,
+ child: View(view: controller.rootView, child: child),
+ ),
+ );
+ }
+}
+
enum _WindowControllerAspect { contentSize, title, activated, maximized, minimized, fullscreen }
/// Provides descendants with access to the [BaseWindowController] associated with
@@ -1453,6 +1788,7 @@
DialogWindowController() => controller.title,
TooltipWindowController() => '',
PopupWindowController() => '',
+ SatelliteWindowController() => controller.title,
};
}
@@ -1476,6 +1812,7 @@
DialogWindowController() => controller.title,
TooltipWindowController() => '',
PopupWindowController() => '',
+ SatelliteWindowController() => controller.title,
};
}
@@ -1500,6 +1837,7 @@
DialogWindowController() => controller.isActivated,
TooltipWindowController() => false,
PopupWindowController() => controller.isActivated,
+ SatelliteWindowController() => controller.isActivated,
};
}
@@ -1524,6 +1862,7 @@
DialogWindowController() => controller.isActivated,
TooltipWindowController() => false,
PopupWindowController() => controller.isActivated,
+ SatelliteWindowController() => controller.isActivated,
};
}
@@ -1548,6 +1887,7 @@
DialogWindowController() => controller.isMinimized,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
};
}
@@ -1572,6 +1912,7 @@
DialogWindowController() => controller.isMinimized,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
};
}
@@ -1596,6 +1937,7 @@
DialogWindowController() => false,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
};
}
@@ -1620,6 +1962,7 @@
DialogWindowController() => false,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
};
}
@@ -1645,6 +1988,7 @@
DialogWindowController() => false,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
};
}
@@ -1669,6 +2013,7 @@
DialogWindowController() => false,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
};
}
@@ -1734,6 +2079,8 @@
dialog.title != (oldWidget.controller as DialogWindowController).title,
TooltipWindowController() => false,
PopupWindowController() => false,
+ final SatelliteWindowController satellite =>
+ satellite.title != (oldWidget.controller as SatelliteWindowController).title,
},
_WindowControllerAspect.activated => switch (controller) {
final RegularWindowController regular =>
@@ -1744,6 +2091,9 @@
TooltipWindowController() => false,
final PopupWindowController popup =>
popup.isActivated != (oldWidget.controller as PopupWindowController).isActivated,
+ final SatelliteWindowController satellite =>
+ satellite.isActivated !=
+ (oldWidget.controller as SatelliteWindowController).isActivated,
},
_WindowControllerAspect.maximized => switch (controller) {
final RegularWindowController regular =>
@@ -1752,6 +2102,7 @@
DialogWindowController() => false,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
},
_WindowControllerAspect.minimized => switch (controller) {
final RegularWindowController regular =>
@@ -1761,6 +2112,7 @@
dialog.isMinimized != (oldWidget.controller as DialogWindowController).isMinimized,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
},
_WindowControllerAspect.fullscreen => switch (controller) {
final RegularWindowController regular =>
@@ -1769,6 +2121,7 @@
DialogWindowController() => false,
TooltipWindowController() => false,
PopupWindowController() => false,
+ SatelliteWindowController() => false,
},
},
);
@@ -1988,6 +2341,10 @@
controller: popup,
child: entry.builder(context),
),
+ final SatelliteWindowController satellite => SatelliteWindow(
+ controller: satellite,
+ child: entry.builder(context),
+ ),
};
}).toList();
diff --git a/packages/flutter/lib/src/widgets/_window_linux.dart b/packages/flutter/lib/src/widgets/_window_linux.dart
index ca9fccd..9f8a99b 100644
--- a/packages/flutter/lib/src/widgets/_window_linux.dart
+++ b/packages/flutter/lib/src/widgets/_window_linux.dart
@@ -148,6 +148,20 @@
}) {
throw UnimplementedError('Popup windows are not yet implemented on Linux.');
}
+
+ @internal
+ @override
+ SatelliteWindowController createSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ }) {
+ throw UnimplementedError('Satellite windows are not yet implemented on Linux.');
+ }
}
/// Implementation of [RegularWindowController] for the Linux platform.
diff --git a/packages/flutter/lib/src/widgets/_window_macos.dart b/packages/flutter/lib/src/widgets/_window_macos.dart
index a4c71f2..ee5d947 100644
--- a/packages/flutter/lib/src/widgets/_window_macos.dart
+++ b/packages/flutter/lib/src/widgets/_window_macos.dart
@@ -156,6 +156,20 @@
view.viewId,
);
}
+
+ @internal
+ @override
+ SatelliteWindowController createSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ }) {
+ throw UnimplementedError('Satellite windows are not yet implemented on macOS.');
+ }
}
mixin _WindowControllerMixin {
diff --git a/packages/flutter/lib/src/widgets/_window_win32.dart b/packages/flutter/lib/src/widgets/_window_win32.dart
index 1b2684c..aff7e52 100644
--- a/packages/flutter/lib/src/widgets/_window_win32.dart
+++ b/packages/flutter/lib/src/widgets/_window_win32.dart
@@ -206,6 +206,20 @@
throw UnimplementedError('Popup windows are not yet implemented on Windows.');
}
+ @internal
+ @override
+ SatelliteWindowController createSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ }) {
+ throw UnimplementedError('Satellite windows are not yet implemented on Windows.');
+ }
+
/// Register a new [WindowsMessageHandler].
///
/// The handler will be triggered for unhandled messages for all top level
diff --git a/packages/flutter/test/widgets/windowing_test.dart b/packages/flutter/test/widgets/windowing_test.dart
index 5e7d425..fe47210 100644
--- a/packages/flutter/test/widgets/windowing_test.dart
+++ b/packages/flutter/test/widgets/windowing_test.dart
@@ -15,6 +15,8 @@
RegularWindow,
RegularWindowController,
RegularWindowControllerDelegate,
+ SatelliteWindow,
+ SatelliteWindowController,
TooltipWindow,
TooltipWindowController,
WindowScope,
@@ -162,6 +164,44 @@
void destroy() {}
}
+class _StubSatelliteWindowController extends SatelliteWindowController {
+ _StubSatelliteWindowController({required this.tester}) : super.empty() {
+ rootView = FakeView(tester.view);
+ }
+
+ final WidgetTester tester;
+
+ @override
+ BaseWindowController get parent => _StubRegularWindowController(tester);
+
+ @override
+ Size get contentSize => Size.zero;
+
+ @override
+ String get title => 'Stub Satellite Window';
+
+ @override
+ bool get isActivated => true;
+
+ @override
+ void setParent(BaseWindowController parent) {}
+
+ @override
+ void setSize(Size size) {}
+
+ @override
+ void setConstraints(BoxConstraints constraints) {}
+
+ @override
+ void setTitle(String title) {}
+
+ @override
+ void activate() {}
+
+ @override
+ void destroy() {}
+}
+
void main() {
group('Windowing', () {
group('isWindowingEnabled is false', () {
@@ -220,6 +260,16 @@
);
});
+ testWidgets('SatelliteWindow throws UnsupportedError', (WidgetTester tester) async {
+ expect(
+ () => SatelliteWindow(
+ controller: _StubSatelliteWindowController(tester: tester),
+ child: const Text('Test'),
+ ),
+ throwsUnsupportedError,
+ );
+ });
+
testWidgets('Accessing WindowScope.of throws UnsupportedError', (WidgetTester tester) async {
await tester.pumpWidget(LookupBoundary(child: Container()));
final BuildContext context = tester.element(find.byType(Container));
@@ -331,6 +381,26 @@
expect(scope, isA<PopupWindowController>());
});
+ testWidgets('Can access WindowScope.of for satellite windows', (WidgetTester tester) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ BaseWindowController? scope;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ scope = WindowScope.of(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(scope, isA<SatelliteWindowController>());
+ });
+
testWidgets('Can access WindowScope.maybeOf for regular windows', (
WidgetTester tester,
) async {
@@ -415,6 +485,28 @@
expect(scope, isA<PopupWindowController>());
});
+ testWidgets('Can access WindowScope.maybeOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ BaseWindowController? scope;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ scope = WindowScope.maybeOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(scope, isA<SatelliteWindowController>());
+ });
+
testWidgets('Can access WindowScope.contentSizeOf for regular windows', (
WidgetTester tester,
) async {
@@ -503,6 +595,28 @@
expect(size, equals(Size.zero));
});
+ testWidgets('Can access WindowScope.contentSizeOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ Size? size;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ size = WindowScope.contentSizeOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(size, equals(Size.zero));
+ });
+
testWidgets('Can access WindowScope.maybeContentSizeOf for regular windows', (
WidgetTester tester,
) async {
@@ -591,6 +705,28 @@
expect(size, equals(Size.zero));
});
+ testWidgets('Can access WindowScope.maybeContentSizeOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ Size? size;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ size = WindowScope.maybeContentSizeOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(size, equals(Size.zero));
+ });
+
testWidgets('Can access WindowScope.titleOf for regular windows', (
WidgetTester tester,
) async {
@@ -675,6 +811,28 @@
expect(title, equals(''));
});
+ testWidgets('Can access WindowScope.titleOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ String? title;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ title = WindowScope.titleOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(title, equals('Stub Satellite Window'));
+ });
+
testWidgets('Can access WindowScope.maybeTitleOf for regular windows', (
WidgetTester tester,
) async {
@@ -763,6 +921,28 @@
expect(title, equals(''));
});
+ testWidgets('Can access WindowScope.maybeTitleOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ String? title;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ title = WindowScope.maybeTitleOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(title, equals('Stub Satellite Window'));
+ });
+
testWidgets('Can access WindowScope.isActivatedOf for regular windows', (
WidgetTester tester,
) async {
@@ -851,6 +1031,28 @@
expect(isActivated, equals(true));
});
+ testWidgets('Can access WindowScope.isActivatedOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ bool? isActivated;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ isActivated = WindowScope.isActivatedOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(isActivated, equals(true));
+ });
+
testWidgets('Can access WindowScope.maybeIsActivatedOf for regular windows', (
WidgetTester tester,
) async {
@@ -939,6 +1141,28 @@
expect(isActivated, equals(true));
});
+ testWidgets('Can access WindowScope.maybeIsActivatedOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ bool? isActivated;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ isActivated = WindowScope.maybeIsActivatedOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(isActivated, equals(true));
+ });
+
testWidgets('Can access WindowScope.isMinimizedOf for regular windows', (
WidgetTester tester,
) async {
@@ -1203,6 +1427,28 @@
expect(isMaximized, equals(false));
});
+ testWidgets('Can access WindowScope.isMaximizedOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ bool? isMaximized;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ isMaximized = WindowScope.isMaximizedOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(isMaximized, equals(false));
+ });
+
testWidgets('Can access WindowScope.maybeIsMaximizedOf for regular windows', (
WidgetTester tester,
) async {
@@ -1291,6 +1537,28 @@
expect(isMaximized, equals(false));
});
+ testWidgets('Can access WindowScope.maybeIsMaximizedOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ bool? isMaximized;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ isMaximized = WindowScope.maybeIsMaximizedOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(isMaximized, equals(false));
+ });
+
testWidgets('Can access WindowScope.isFullscreenOf for regular windows', (
WidgetTester tester,
) async {
@@ -1379,6 +1647,28 @@
expect(isFullscreen, equals(false));
});
+ testWidgets('Can access WindowScope.isFullscreenOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ bool? isFullscreen;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ isFullscreen = WindowScope.isFullscreenOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(isFullscreen, equals(false));
+ });
+
testWidgets('Can access WindowScope.maybeIsFullscreenOf for regular windows', (
WidgetTester tester,
) async {
@@ -1466,6 +1756,37 @@
expect(isFullscreen, equals(false));
});
+
+ testWidgets('Can access WindowScope.maybeIsFullscreenOf for satellite windows', (
+ WidgetTester tester,
+ ) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ bool? isFullscreen;
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(
+ controller: controller,
+ child: Builder(
+ builder: (BuildContext context) {
+ isFullscreen = WindowScope.maybeIsFullscreenOf(context);
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ );
+
+ expect(isFullscreen, equals(false));
+ });
+
+ testWidgets('SatelliteWindow does not throw', (WidgetTester tester) async {
+ final controller = _StubSatelliteWindowController(tester: tester);
+ addTearDown(controller.dispose);
+ await tester.pumpWidget(
+ wrapWithView: false,
+ SatelliteWindow(controller: controller, child: Container()),
+ );
+ });
});
});
}
diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart
index 8ec5372..0ffc907 100644
--- a/packages/flutter_test/lib/src/binding.dart
+++ b/packages/flutter_test/lib/src/binding.dart
@@ -291,6 +291,13 @@
}
activateable = (popupChild as _TestPopupWindowController).getFirstActivatableChild();
foundPopup = true;
+ case final SatelliteWindowController satelliteChild:
+ if (foundPopup) {
+ // Already found a popup, skip anything else.
+ break;
+ }
+ activateable = (satelliteChild as _TestSatelliteWindowController)
+ .getFirstActivatableChild();
}
}
@@ -427,6 +434,8 @@
(testParent as _TestRegularWindowController).addChild(child);
case final PopupWindowController testParent:
(testParent as _TestPopupWindowController).addChild(child);
+ case final SatelliteWindowController testParent:
+ (testParent as _TestSatelliteWindowController).addChild(child);
case TooltipWindowController _:
fail('TooltipWindowController cannot be a parent of another window controller.');
}
@@ -442,6 +451,8 @@
(testParent as _TestRegularWindowController).removeChild(child);
case final PopupWindowController testParent:
(testParent as _TestPopupWindowController).removeChild(child);
+ case final SatelliteWindowController testParent:
+ (testParent as _TestSatelliteWindowController).removeChild(child);
case TooltipWindowController _:
fail('TooltipWindowController cannot be a parent of another window controller.');
}
@@ -687,6 +698,112 @@
}
}
+class _TestSatelliteWindowController extends SatelliteWindowController
+ with _ChildWindowHierarchyMixin {
+ _TestSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required TestPlatformDispatcher platformDispatcher,
+ required this.windowingOwner,
+ required BaseWindowController parent,
+ ui.Rect? anchorRect,
+ required WindowPositioner positioner,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ }) : _delegate = delegate,
+ _parent = parent,
+ // ignore: unused_field
+ _anchorRect = anchorRect,
+ // ignore: unused_field
+ _positioner = positioner,
+ _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,
+ constraints: _constraints,
+ );
+ _addChildToParent(parent, this);
+
+ // Automatically activate the window when created.
+ activate();
+ }
+
+ final SatelliteWindowControllerDelegate _delegate;
+ BaseWindowController _parent;
+ final _TestWindowingOwner windowingOwner;
+ // ignore: unused_field
+ final ui.Rect? _anchorRect;
+ // ignore: unused_field
+ final WindowPositioner _positioner;
+ Size _size;
+ BoxConstraints _constraints;
+ String _title;
+
+ @override
+ Size get contentSize => _size;
+
+ @override
+ BaseWindowController get parent => _parent;
+
+ @override
+ String get title => _title;
+
+ @override
+ bool get isActivated => windowingOwner.isWindowControllerActive(this);
+
+ @override
+ void setParent(BaseWindowController parent) {
+ _removeChildFromParent(_parent, this);
+ _parent = parent;
+ _addChildToParent(parent, this);
+ notifyListeners();
+ }
+
+ @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();
+ }
+
+ 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);
+ _removeChildFromParent(_parent, this);
+ }
+}
+
/// A [WindowingOwner] used for tests.
///
/// This windowing owner will behave as a perfect windowing system, with no
@@ -731,6 +848,11 @@
.getFirstActivatableChild();
_activeWindowController = leaf;
return _activeWindowController!;
+ case final SatelliteWindowController _:
+ final BaseWindowController leaf = (controller as _TestSatelliteWindowController)
+ .getFirstActivatableChild();
+ _activeWindowController = leaf;
+ return _activeWindowController!;
}
}
@@ -748,6 +870,8 @@
fail('TooltipWindowController cannot be a parent of DialogWindowController.');
case final PopupWindowController popupParent:
popupParent.activate();
+ case final SatelliteWindowController satelliteParent:
+ satelliteParent.activate();
}
return true;
@@ -783,6 +907,10 @@
if (!_tryActivateParent(popupController.parent)) {
_activeWindowController = null;
}
+ case final SatelliteWindowController satelliteController:
+ if (!_tryActivateParent(satelliteController.parent)) {
+ _activeWindowController = null;
+ }
}
}
@@ -868,6 +996,30 @@
parent: parent,
);
}
+
+ @internal
+ @override
+ SatelliteWindowController createSatelliteWindowController({
+ required SatelliteWindowControllerDelegate delegate,
+ required BaseWindowController parent,
+ required WindowPositioner initialPositioner,
+ Rect? initialAnchorRect,
+ Size? preferredSize,
+ BoxConstraints? preferredConstraints,
+ String? title,
+ }) {
+ return _TestSatelliteWindowController(
+ delegate: delegate,
+ platformDispatcher: _platformDispatcher,
+ windowingOwner: this,
+ parent: parent,
+ anchorRect: initialAnchorRect,
+ positioner: initialPositioner,
+ preferredSize: preferredSize,
+ preferredConstraints: preferredConstraints,
+ title: title,
+ );
+ }
}
// Examples can assume: