From 084deda0ba3fe425eaaed8bd22c4a198f427316e Mon Sep 17 00:00:00 2001 From: Matthew Kosarek Date: Tue, 25 Nov 2025 10:23:04 -0500 Subject: [PATCH] Add tooltip windows to the windowing API alongside the window positioning logic (#177404) ## What's new? - Added tooltips to the windowing API, but they have no implementation for the time being - Added the `WindowPostiioner` logic to the windowing API - Wrote a lot of tests :test_tube: ## 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. --- .../api/lib/widgets/windows/tooltip.0.dart | 109 ++++ .../test/widgets/windows/tooltip.0_test.dart | 12 + .../multiple_windows/lib/app/main_window.dart | 2 + .../lib/app/window_content.dart | 1 + packages/flutter/lib/src/widgets/_window.dart | 229 ++++++++ .../lib/src/widgets/_window_linux.dart | 13 + .../lib/src/widgets/_window_macos.dart | 13 + .../lib/src/widgets/_window_positioner.dart | 520 ++++++++++++++++++ .../lib/src/widgets/_window_win32.dart | 13 + .../test/widgets/window_positioner_test.dart | 503 +++++++++++++++++ .../flutter/test/widgets/windowing_test.dart | 338 ++++++++++++ 11 files changed, 1753 insertions(+) create mode 100644 examples/api/lib/widgets/windows/tooltip.0.dart create mode 100644 examples/api/test/widgets/windows/tooltip.0_test.dart create mode 100644 packages/flutter/lib/src/widgets/_window_positioner.dart create mode 100644 packages/flutter/test/widgets/window_positioner_test.dart diff --git a/examples/api/lib/widgets/windows/tooltip.0.dart b/examples/api/lib/widgets/windows/tooltip.0.dart new file mode 100644 index 00000000000..0de42af4c9c --- /dev/null +++ b/examples/api/lib/widgets/windows/tooltip.0.dart @@ -0,0 +1,109 @@ +// 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 +// TODO(mattkae): refactor this example for better widget position tracking +// This positioning logic is simpler than you might want in production. See https://github.com/flutter/flutter/issues/178829. +// 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 { + 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 (e) { + // TODO(mattkae): Remove this catch block when Windows tooltips 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(e.message ?? 'Unsupported'))), + ), + ); + } +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() { + return _MyAppState(); + } +} + +class _MyAppState extends State { + final GlobalKey _key = GlobalKey(); + TooltipWindowController? _tooltipController; + + @override + Widget build(BuildContext context) { + final List children = [ + Text( + key: _key, + 'Hover Me', + style: const TextStyle(color: Colors.white), + ), + ]; + + if (_tooltipController != null) { + children.add( + TooltipWindow( + controller: _tooltipController!, + child: Container( + padding: const EdgeInsets.all(8), + color: Colors.black, + child: const Text('This is a tooltip', style: TextStyle(color: Colors.white)), + ), + ), + ); + } + + return MouseRegion( + onEnter: (_) => setState( + () => _tooltipController = TooltipWindowController( + parent: WindowScope.of(context), + anchorRect: _getAnchorRect()!, + positioner: const WindowPositioner( + parentAnchor: WindowPositionerAnchor.right, + childAnchor: WindowPositionerAnchor.left, + ), + ), + ), + onExit: (_) => setState(() { + _tooltipController?.destroy(); + _tooltipController = null; + }), + cursor: SystemMouseCursors.click, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + color: _tooltipController != null ? Colors.blueAccent : Colors.blue, + padding: const EdgeInsets.all(12), + child: Row(children: children), + ), + ); + } + + Rect? _getAnchorRect() { + final RenderBox? renderBox = _key.currentContext?.findRenderObject() as RenderBox?; + if (renderBox != null) { + final Offset position = renderBox.localToGlobal(Offset.zero); + final Size size = renderBox.size; + return position & size; // creates a Rect + } + + return null; + } +} diff --git a/examples/api/test/widgets/windows/tooltip.0_test.dart b/examples/api/test/widgets/windows/tooltip.0_test.dart new file mode 100644 index 00000000000..ef4e6016160 --- /dev/null +++ b/examples/api/test/widgets/windows/tooltip.0_test.dart @@ -0,0 +1,12 @@ +// 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/tooltip.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Calling tooltip main returns normally', (WidgetTester tester) async { + expect(() => example.main(), returnsNormally); + }); +} diff --git a/examples/multiple_windows/lib/app/main_window.dart b/examples/multiple_windows/lib/app/main_window.dart index cbcd19fa52d..f293afc65ae 100644 --- a/examples/multiple_windows/lib/app/main_window.dart +++ b/examples/multiple_windows/lib/app/main_window.dart @@ -101,6 +101,7 @@ class _WindowsTable extends StatelessWidget { context: context, controller: dialog, ), + TooltipWindowController() => null, }; } @@ -108,6 +109,7 @@ class _WindowsTable extends StatelessWidget { return switch (controller) { RegularWindowController() => 'Regular', DialogWindowController() => 'Dialog', + TooltipWindowController() => 'Tooltip', }; } diff --git a/examples/multiple_windows/lib/app/window_content.dart b/examples/multiple_windows/lib/app/window_content.dart index cc30e03a5d2..fa4ddd4180d 100644 --- a/examples/multiple_windows/lib/app/window_content.dart +++ b/examples/multiple_windows/lib/app/window_content.dart @@ -39,6 +39,7 @@ class WindowContent extends StatelessWidget { controller: dialog, child: MaterialApp(home: DialogWindowContent(window: dialog)), ), + TooltipWindowController() => throw UnimplementedError(), }; } } diff --git a/packages/flutter/lib/src/widgets/_window.dart b/packages/flutter/lib/src/widgets/_window.dart index c4929bdfe58..f69d561c237 100644 --- a/packages/flutter/lib/src/widgets/_window.dart +++ b/packages/flutter/lib/src/widgets/_window.dart @@ -20,6 +20,7 @@ import 'package:flutter/foundation.dart'; import '../foundation/_features.dart'; import '_window_io.dart' if (dart.library.js_interop) '_window_web.dart' as window_impl; +import '_window_positioner.dart'; import 'basic.dart'; import 'binding.dart'; import 'framework.dart'; @@ -620,6 +621,148 @@ abstract class DialogWindowController extends BaseWindowController { void setMinimized(bool minimized); } +/// Delegate class for tooltip window controller. +/// +/// {@macro flutter.widgets.windowing.experimental} +/// +/// See also: +/// +/// * [TooltipWindowController], the controller that creates and manages tooltip windows. +/// * [TooltipWindow], the widget for a tooltip window. +/// * [RegularWindowControllerDelegate], the delegate for regular window controllers. +mixin class TooltipWindowControllerDelegate { + /// 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(TooltipWindowController 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 tooltip window. +/// +/// A tooltip window is a small window that displays brief, informative text +/// when a user hovers over or focuses on a UI element. Tooltip windows are +/// typically used to provide additional context or explanations for UI elements +/// without cluttering the main interface. As such, it may not receive input +/// focus from the user. It will however stay open when another window receives +/// input focus. +/// +/// This class does not interact with the widget tree. Instead, it is typically +/// provided to the [TooltipWindow] widget, which renders the content inside the +/// tooltip 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. +/// +/// {@tool dartpad} +/// An example usage of [TooltipWindowController] looks like: +/// +/// ** See code in examples/api/lib/widgets/windows/tooltip.0.dart ** +/// {@end-tool} +/// +/// Children of a [TooltipWindow] widget can access the [TooltipWindowController] +/// via the [WindowScope] inherited widget. +/// +/// {@macro flutter.widgets.windowing.experimental} +abstract class TooltipWindowController extends BaseWindowController { + /// Creates a [TooltipWindowController] with the provided properties. + /// + /// Upon construction, the window is created by the platform. + /// + /// The [parent] argument specifies the parent window of this tooltip. + /// + /// The [anchorRect] argument specifies the rectangle in the parent's coordinate + /// space to which the tooltip is anchored. + /// + /// The [positioner] argument specifies how the tooltip should be positioned + /// relative to the [anchorRect]. + /// + /// {@macro flutter.widgets.windowing.constraints} + /// + /// 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} + factory TooltipWindowController({ + required BaseWindowController parent, + required Rect anchorRect, + required WindowPositioner positioner, + BoxConstraints? preferredConstraints, + TooltipWindowControllerDelegate? delegate, + }) { + WidgetsFlutterBinding.ensureInitialized(); + final WindowingOwner owner = WidgetsBinding.instance.windowingOwner; + final TooltipWindowController controller = owner.createTooltipWindowController( + parent: parent, + preferredConstraints: preferredConstraints ?? const BoxConstraints(), + delegate: delegate ?? TooltipWindowControllerDelegate(), + anchorRect: anchorRect, + positioner: positioner, + ); + return controller; + } + + /// Creates an empty [TooltipWindowController]. + /// + /// This method is only intended to be used by subclasses of the + /// [TooltipWindowController]. + /// + /// Users who want to instantiate a new [TooltipWindowController] should + /// always use the factory method to create a controller that is valid + /// for their particular platform. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + @protected + TooltipWindowController.empty(); + + /// The parent controller of this tooltip. + /// + /// The tooltip will be destroyed if its parent is destroyed. + BaseWindowController get parent; + + /// 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); +} + /// [WindowingOwner] is responsible for creating and managing window controllers. /// /// A custom implementation can be provided by setting [WidgetsBinding.windowingOwner]. @@ -657,6 +800,22 @@ abstract class WindowingOwner { BaseWindowController? parent, String? title, }); + + /// Creates a [TooltipWindowController] with the provided properties. + /// + /// Most app developers should use [TooltipWindowController]'s constructor + /// instead of calling this method directly. This method allows platforms + /// to inject platform-specific logic. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + TooltipWindowController createTooltipWindowController({ + required TooltipWindowControllerDelegate delegate, + required BoxConstraints preferredConstraints, + required Rect anchorRect, + required WindowPositioner positioner, + required BaseWindowController parent, + }); } /// Creates default windowing owner for standard desktop embedders. @@ -702,6 +861,17 @@ class _WindowingOwnerUnsupported extends WindowingOwner { }) { throw UnsupportedError(errorMessage); } + + @override + TooltipWindowController createTooltipWindowController({ + required TooltipWindowControllerDelegate delegate, + required BoxConstraints preferredConstraints, + required Rect anchorRect, + required WindowPositioner positioner, + required BaseWindowController parent, + }) { + throw UnimplementedError(errorMessage); + } } /// The [RegularWindow] widget provides a way to render a regular window in the @@ -888,6 +1058,50 @@ class DialogWindow extends StatelessWidget { } } +@internal +class TooltipWindow extends StatelessWidget { + /// Creates a tooltip 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 + /// [TooltipWindowController.destroy] when the window is no longer needed. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + TooltipWindow({super.key, required this.controller, required this.child}) { + if (!isWindowingEnabled) { + throw UnsupportedError(_kWindowingDisabledErrorMessage); + } + } + + /// Controller for this widget. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final TooltipWindowController 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 @@ -1019,6 +1233,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.title, DialogWindowController() => controller.title, + TooltipWindowController() => '', }; } @@ -1040,6 +1255,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.title, DialogWindowController() => controller.title, + TooltipWindowController() => '', }; } @@ -1062,6 +1278,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isActivated, DialogWindowController() => controller.isActivated, + TooltipWindowController() => false, }; } @@ -1084,6 +1301,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isActivated, DialogWindowController() => controller.isActivated, + TooltipWindowController() => false, }; } @@ -1106,6 +1324,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isMinimized, DialogWindowController() => controller.isMinimized, + TooltipWindowController() => false, }; } @@ -1128,6 +1347,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isMinimized, DialogWindowController() => controller.isMinimized, + TooltipWindowController() => false, }; } @@ -1150,6 +1370,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isMaximized, DialogWindowController() => false, + TooltipWindowController() => false, }; } @@ -1172,6 +1393,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isMaximized, DialogWindowController() => false, + TooltipWindowController() => false, }; } @@ -1195,6 +1417,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isFullscreen, DialogWindowController() => false, + TooltipWindowController() => false, }; } @@ -1217,6 +1440,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { return switch (controller) { RegularWindowController() => controller.isFullscreen, DialogWindowController() => false, + TooltipWindowController() => false, }; } @@ -1280,6 +1504,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { regular.title != (oldWidget.controller as RegularWindowController).title, final DialogWindowController dialog => dialog.title != (oldWidget.controller as DialogWindowController).title, + TooltipWindowController() => false, }, _WindowControllerAspect.activated => switch (controller) { final RegularWindowController regular => @@ -1287,12 +1512,14 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { (oldWidget.controller as RegularWindowController).isActivated, final DialogWindowController dialog => dialog.isActivated != (oldWidget.controller as DialogWindowController).isActivated, + TooltipWindowController() => false, }, _WindowControllerAspect.maximized => switch (controller) { final RegularWindowController regular => regular.isMaximized != (oldWidget.controller as RegularWindowController).isMaximized, DialogWindowController() => false, + TooltipWindowController() => false, }, _WindowControllerAspect.minimized => switch (controller) { final RegularWindowController regular => @@ -1300,12 +1527,14 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { (oldWidget.controller as RegularWindowController).isMinimized, final DialogWindowController dialog => dialog.isMinimized != (oldWidget.controller as DialogWindowController).isMinimized, + TooltipWindowController() => false, }, _WindowControllerAspect.fullscreen => switch (controller) { final RegularWindowController regular => regular.isFullscreen != (oldWidget.controller as RegularWindowController).isFullscreen, DialogWindowController() => false, + TooltipWindowController() => false, }, }, ); diff --git a/packages/flutter/lib/src/widgets/_window_linux.dart b/packages/flutter/lib/src/widgets/_window_linux.dart index ffddb6436b6..ce245ece39a 100644 --- a/packages/flutter/lib/src/widgets/_window_linux.dart +++ b/packages/flutter/lib/src/widgets/_window_linux.dart @@ -23,6 +23,7 @@ import 'package:flutter/rendering.dart'; import '../foundation/_features.dart'; import '_window.dart'; +import '_window_positioner.dart'; import 'binding.dart'; // Maximum width and height a window can be. @@ -591,6 +592,18 @@ class WindowingOwnerLinux extends WindowingOwner { _windows[controller.rootView.viewId] = controller._window; return controller; } + + @internal + @override + TooltipWindowController createTooltipWindowController({ + required TooltipWindowControllerDelegate delegate, + required BoxConstraints preferredConstraints, + required Rect anchorRect, + required WindowPositioner positioner, + required BaseWindowController parent, + }) { + throw UnimplementedError('Tooltip 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 ec3aeb150c9..fe379708565 100644 --- a/packages/flutter/lib/src/widgets/_window_macos.dart +++ b/packages/flutter/lib/src/widgets/_window_macos.dart @@ -12,6 +12,7 @@ import 'package:flutter/rendering.dart'; import '../foundation/_features.dart'; import '_window.dart'; +import '_window_positioner.dart'; import 'binding.dart'; // Do not import this file in production applications or packages published @@ -109,6 +110,18 @@ class WindowingOwnerMacOS extends WindowingOwner { return res; } + @internal + @override + TooltipWindowController createTooltipWindowController({ + required TooltipWindowControllerDelegate delegate, + required BoxConstraints preferredConstraints, + required Rect anchorRect, + required WindowPositioner positioner, + required BaseWindowController parent, + }) { + throw UnimplementedError('Tooltip windows are not yet implemented on MacOS.'); + } + final List _activeControllers = []; /// Returns the window handle for the given [view], or null is the window diff --git a/packages/flutter/lib/src/widgets/_window_positioner.dart b/packages/flutter/lib/src/widgets/_window_positioner.dart new file mode 100644 index 00000000000..dc5ac312d06 --- /dev/null +++ b/packages/flutter/lib/src/widgets/_window_positioner.dart @@ -0,0 +1,520 @@ +// 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 'dart:ui'; +import 'package:flutter/foundation.dart'; +import '_window.dart'; + +/// Defines how a child window will be placed relative to the anchor rectangle +/// of its parent. +/// +/// The specified anchor is used to derive an anchor point on the anchor rectangle that +/// the child [BaseWindowController] will be positioned relative to. +/// If a corner anchor is set (e.g. [topLeft] or [bottomRight]), +/// the anchor point will be at the specified corner; otherwise, the derived anchor point +/// will be centered on the specified edge, or in the center of the anchor rectangle +/// if no edge is specified. +/// +/// {@macro flutter.widgets.windowing.experimental} +@internal +enum WindowPositionerAnchor { + /// If the [WindowPositioner.parentAnchor] is set to [center], then the + /// child window will be positioned relative to the center + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [center], then the middle + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + center, + + /// If the [WindowPositioner.parentAnchor] is set to [top], then the + /// child window will be positioned relative to the top + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [top], then the top + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + top, + + /// If the [WindowPositioner.parentAnchor] is set to [bottom], then the + /// child window will be positioned relative to the bottom + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [bottom], then the bottom + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + bottom, + + /// If the [WindowPositioner.parentAnchor] is set to [left], then the + /// child window will be positioned relative to the left + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [left], then the left + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + left, + + /// If the [WindowPositioner.parentAnchor] is set to [right], then the + /// child window will be positioned relative to the right + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [right], then the right + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + right, + + /// If the [WindowPositioner.parentAnchor] is set to [topLeft], then the + /// child window will be positioned relative to the top left + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [topLeft], then the top left + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + topLeft, + + /// If the [WindowPositioner.parentAnchor] is set to [bottomLeft], then the + /// child window will be positioned relative to the bottom left + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [bottomLeft], then the bottom left + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + bottomLeft, + + /// If the [WindowPositioner.parentAnchor] is set to [topRight], then the + /// child window will be positioned relative to the top right + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [topRight], then the top right + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + topRight, + + /// If the [WindowPositioner.parentAnchor] is set to [bottomRight], then the + /// child window will be positioned relative to the bottom right + /// of the parent window. + /// + /// If [WindowPositioner.childAnchor] is set to [bottomRight], then the bottom right + /// of the child window will be positioned relative to + /// [WindowPositioner.parentAnchor]. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + bottomRight; + + WindowPositionerAnchor _flipX() { + return switch (this) { + WindowPositionerAnchor.center => WindowPositionerAnchor.center, + WindowPositionerAnchor.top => WindowPositionerAnchor.top, + WindowPositionerAnchor.bottom => WindowPositionerAnchor.bottom, + WindowPositionerAnchor.left => WindowPositionerAnchor.right, + WindowPositionerAnchor.right => WindowPositionerAnchor.left, + WindowPositionerAnchor.topLeft => WindowPositionerAnchor.topRight, + WindowPositionerAnchor.bottomLeft => WindowPositionerAnchor.bottomRight, + WindowPositionerAnchor.topRight => WindowPositionerAnchor.topLeft, + WindowPositionerAnchor.bottomRight => WindowPositionerAnchor.bottomLeft, + }; + } + + WindowPositionerAnchor _flipY() { + return switch (this) { + WindowPositionerAnchor.center => WindowPositionerAnchor.center, + WindowPositionerAnchor.top => WindowPositionerAnchor.bottom, + WindowPositionerAnchor.bottom => WindowPositionerAnchor.top, + WindowPositionerAnchor.left => WindowPositionerAnchor.left, + WindowPositionerAnchor.right => WindowPositionerAnchor.right, + WindowPositionerAnchor.topLeft => WindowPositionerAnchor.bottomLeft, + WindowPositionerAnchor.bottomLeft => WindowPositionerAnchor.topLeft, + WindowPositionerAnchor.topRight => WindowPositionerAnchor.bottomRight, + WindowPositionerAnchor.bottomRight => WindowPositionerAnchor.topRight, + }; + } + + Offset _offsetFor(Size size) { + return switch (this) { + WindowPositionerAnchor.center => Offset(-size.width / 2.0, -size.height / 2.0), + WindowPositionerAnchor.top => Offset(-size.width / 2.0, 0.0), + WindowPositionerAnchor.bottom => Offset(-size.width / 2.0, -size.height), + WindowPositionerAnchor.left => Offset(0.0, -size.height / 2.0), + WindowPositionerAnchor.right => Offset(-size.width, -size.height / 2.0), + WindowPositionerAnchor.topLeft => Offset.zero, + WindowPositionerAnchor.bottomLeft => Offset(0.0, -size.height), + WindowPositionerAnchor.topRight => Offset(-size.width, 0.0), + WindowPositionerAnchor.bottomRight => Offset(-size.width, -size.height), + }; + } + + Offset _anchorPositionFor(Rect rect) { + return switch (this) { + WindowPositionerAnchor.center => rect.center, + WindowPositionerAnchor.top => rect.topCenter, + WindowPositionerAnchor.bottom => rect.bottomCenter, + WindowPositionerAnchor.left => rect.centerLeft, + WindowPositionerAnchor.right => rect.centerRight, + WindowPositionerAnchor.topLeft => rect.topLeft, + WindowPositionerAnchor.bottomLeft => rect.bottomLeft, + WindowPositionerAnchor.topRight => rect.topRight, + WindowPositionerAnchor.bottomRight => rect.bottomRight, + }; + } +} + +/// The [WindowPositionerConstraintAdjustment] describes how a window will adjust +/// its position when it would be partly constrained by the platform. +/// +/// {@template flutter.widgets.window_positioner.constraint_adjustment} +/// Whether a window is considered "constrained" is left to the platform +/// to determine. For example, the window may be partly outside the +/// output's 'work area', thus necessitating the child window's +/// position be adjusted until it is entirely inside the work area. +/// +/// The adjustments can be combined, according to a defined precedence: +/// +/// 1. [WindowPositionerConstraintAdjustment.flipX] and [WindowPositionerConstraintAdjustment.flipY] +/// 2. [WindowPositionerConstraintAdjustment.slideX] and [WindowPositionerConstraintAdjustment.slideY] +/// 3. [WindowPositionerConstraintAdjustment.resizeX] and [WindowPositionerConstraintAdjustment.resizeY] +/// +/// The first adjustment that results in the child window being entirely inside the work area will be picked. +/// {@endtemplate} +/// +/// {@macro flutter.widgets.windowing.experimental} +@internal +class WindowPositionerConstraintAdjustment { + const WindowPositionerConstraintAdjustment({ + this.flipX = false, + this.flipY = false, + this.slideX = false, + this.slideY = false, + this.resizeX = false, + this.resizeY = false, + }); + + /// If [slideX] is `true` and the window would be displayed off the screen in the X-axis,then it will be + /// translated in the X-direction (either negative or positive) in order + /// to best display the window on screen. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final bool slideX; + + /// If [slideY] is `true` and the window would be displayed off the screen in the Y-axis, then it will be + /// translated in the Y-direction (either negative or positive) in order + /// to best display the window on screen. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final bool slideY; + + /// If [flipX] is `true` and the window would be displayed off the screen in the X-axis in one direction, then + /// it will be flipped to the opposite side of its parent in order + /// to best display the window on screen. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final bool flipX; + + /// If [flipY] is `true` and the window would be displayed off the screen in the Y-axis in one direction, then + /// it will be flipped to the opposite side of its parent in order + /// to best display the window on screen. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final bool flipY; + + /// If [resizeX] is `true` and the window would be displayed off the screen in the X-axis, then + /// its width will be reduced such that it fits on screen. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final bool resizeX; + + /// If `true` and the window would be displayed off the screen in the Y-axis, then + /// its height will be reduced such that it fits on screen. + /// + /// {@macro flutter.widgets.windowing.experimental} + @internal + final bool resizeY; +} + +/// The [WindowPositioner] defines how child windows are placed relative to +/// their parent window. +/// +/// For example, the rules may be defined such that the child window remains +/// within the visible area's borders, and to specify how the child window +/// changes its position, such as sliding along an axis, or flipping around a +/// rectangle. +/// +/// See also: +/// +/// * [TooltipWindowController], a subclass of [BaseWindowController] that +/// uses [WindowPositioner] to position tooltip windows. +/// * [WindowPositionerAnchor], which defines anchor points for positioning +/// windows. +/// * [WindowPositionerConstraintAdjustment], which defines how windows adjust +/// their position when they would be partly constrained by the platform. +/// +/// {@macro flutter.widgets.windowing.experimental} +@internal +class WindowPositioner { + /// Const constructor for [WindowPositioner]. + const WindowPositioner({ + this.parentAnchor = WindowPositionerAnchor.center, + this.childAnchor = WindowPositionerAnchor.center, + this.offset = Offset.zero, + this.constraintAdjustment = const WindowPositionerConstraintAdjustment(), + }); + + /// Copy a [WindowPositioner] with some fields replaced. + WindowPositioner copyWith({ + WindowPositionerAnchor? parentAnchor, + WindowPositionerAnchor? childAnchor, + Offset? offset, + WindowPositionerConstraintAdjustment? constraintAdjustment, + }) { + return WindowPositioner( + parentAnchor: parentAnchor ?? this.parentAnchor, + childAnchor: childAnchor ?? this.childAnchor, + offset: offset ?? this.offset, + constraintAdjustment: constraintAdjustment ?? this.constraintAdjustment, + ); + } + + /// Defines the point on the parent from which to position the child. + /// + /// The specified anchor is used to derive an anchor point that the child + /// window will be positioned relative to. If a corner anchor is set + /// (e.g. [WindowPositionerAnchor.topLeft] or [WindowPositionerAnchor.bottomRight]), + /// the anchor point will be at the specified corner; + /// otherwise, the derived anchor point will be centered on the specified + /// edge, or in the center of the anchor rectangle if no edge is specified. + /// + /// The child is positioned by placing [childAnchor] on top of [parentAnchor] and then translating by [offset]. + /// + /// Defaults to [WindowPositionerAnchor.center]. + /// + /// {@macro flutter.widgets.windowing.experimental} + final WindowPositionerAnchor parentAnchor; + + /// Defines the point on the child that is positioned relative to the parent. + /// + /// The specified anchor is used to derive an anchor point that will be positioned + /// relative to the [parentAnchor]. If a corner anchor is set (e.g. [WindowPositionerAnchor.topLeft] or + /// [WindowPositionerAnchor.bottomRight]), the anchor point will be at the specified corner; + /// otherwise, the derived anchor point will be centered on the specified + /// edge, or in the center of the anchor rectangle if no edge is specified. + /// + /// The child is positioned by placing [childAnchor] on top of [parentAnchor] and then translating by [offset]. + /// + /// Defaults to [WindowPositionerAnchor.center]. + /// + /// {@macro flutter.widgets.windowing.experimental} + final WindowPositionerAnchor childAnchor; + + /// The offset with which to place the child relative to the parent. + /// + /// The child is positioned by placing [childAnchor] on top of [parentAnchor] and then translating by [offset]. + /// + /// For example if the anchor of the anchor rectangle is at (x, y), the window + /// has a [childAnchor] of [WindowPositionerAnchor.topLeft], and the [offset] + /// is (ox, oy), the calculated window position will be (x + ox, y + oy). + /// The offset position of the window is the one used for constraint testing. + /// See [constraintAdjustment]. + /// + /// An example use case is placing a popup menu on top of a user interface + /// element, while aligning the user interface element of the parent window + /// with some user interface element placed somewhere in the popup window. + /// + /// Defaults to [Offset.zero]. + /// + /// {@macro flutter.widgets.windowing.experimental} + final Offset offset; + + /// Defines how Flutter will adjust the position of the window if the unadjusted + /// position would result in the window being partly constrained by the platform. + /// + /// {@macro flutter.widgets.window_positioner.constraint_adjustment} + /// + /// The first adjustment that results in the child window being entirely inside the work area will be picked. + /// + /// See also: + /// + /// * [WindowPositionerConstraintAdjustment] for details on each adjustment type. + /// + /// {@macro flutter.widgets.windowing.experimental} + final WindowPositionerConstraintAdjustment constraintAdjustment; + + /// Computes the screen-space rectangle for a child window placed according to + /// this [WindowPositioner]. + /// + /// [childSize] is the frame size of the child window. + /// + /// [anchorRect] is the rectangle relative to which the child window is placed. + /// + /// [parentRect] is the parent window's rectangle. + /// + /// [displayRect] is the output display area where the child window will be placed. + /// + /// All sizes and rectangles are in physical coordinates. + /// + /// {@macro flutter.widgets.windowing.experimental} + Rect placeWindow({ + required Size childSize, + required Rect anchorRect, + required Rect parentRect, + required Rect displayRect, + }) { + Rect defaultResult; + { + final Offset result = + _constrainTo(parentRect, parentAnchor._anchorPositionFor(anchorRect) + offset) + + childAnchor._offsetFor(childSize); + defaultResult = result & childSize; + if (_rectContains(displayRect, defaultResult)) { + return defaultResult; + } + } + + if (constraintAdjustment.flipX) { + final Offset result = + _constrainTo( + parentRect, + parentAnchor._flipX()._anchorPositionFor(anchorRect) + _flipX(offset), + ) + + childAnchor._flipX()._offsetFor(childSize); + if (_rectContains(displayRect, result & childSize)) { + return result & childSize; + } + } + + if (constraintAdjustment.flipY) { + final Offset result = + _constrainTo( + parentRect, + parentAnchor._flipY()._anchorPositionFor(anchorRect) + _flipY(offset), + ) + + childAnchor._flipY()._offsetFor(childSize); + if (_rectContains(displayRect, result & childSize)) { + return result & childSize; + } + } + + if (constraintAdjustment.flipX && constraintAdjustment.flipY) { + final Offset result = + _constrainTo( + parentRect, + parentAnchor._flipY()._flipX()._anchorPositionFor(anchorRect) + _flipX(_flipY(offset)), + ) + + childAnchor._flipY()._flipX()._offsetFor(childSize); + if (_rectContains(displayRect, result & childSize)) { + return result & childSize; + } + } + + { + Offset result = + _constrainTo(parentRect, parentAnchor._anchorPositionFor(anchorRect) + offset) + + childAnchor._offsetFor(childSize); + + if (constraintAdjustment.slideX) { + final double leftOverhang = result.dx - displayRect.left; + final double rightOverhang = result.dx + childSize.width - displayRect.right; + if (leftOverhang < 0.0) { + result = result.translate(-leftOverhang, 0.0); + } else if (rightOverhang > 0.0) { + result = result.translate(-rightOverhang, 0.0); + } + } + + if (constraintAdjustment.slideY) { + final double topOverhang = result.dy - displayRect.top; + final double bottomOverhang = result.dy + childSize.height - displayRect.bottom; + if (topOverhang < 0.0) { + result = result.translate(0.0, -topOverhang); + } else if (bottomOverhang > 0.0) { + result = result.translate(0.0, -bottomOverhang); + } + } + + if (_rectContains(displayRect, result & childSize)) { + return result & childSize; + } + } + + { + Offset result = + _constrainTo(parentRect, parentAnchor._anchorPositionFor(anchorRect) + offset) + + childAnchor._offsetFor(childSize); + + if (constraintAdjustment.resizeX) { + final double leftOverhang = result.dx - displayRect.left; + final double rightOverhang = result.dx + childSize.width - displayRect.right; + if (leftOverhang < 0.0) { + result = result.translate(-leftOverhang, 0.0); + childSize = Size(childSize.width + leftOverhang, childSize.height); + } + if (rightOverhang > 0.0) { + childSize = Size(childSize.width - rightOverhang, childSize.height); + } + } + + if (constraintAdjustment.resizeY) { + final double topOverhang = result.dy - displayRect.top; + final double bottomOverhang = result.dy + childSize.height - displayRect.bottom; + if (topOverhang < 0.0) { + result = result.translate(0.0, -topOverhang); + childSize = Size(childSize.width, childSize.height + topOverhang); + } + if (bottomOverhang > 0.0) { + childSize = Size(childSize.width, childSize.height - bottomOverhang); + } + } + + if (_rectContains(displayRect, result & childSize)) { + return result & childSize; + } + } + + return defaultResult; + } +} + +bool _rectContains(Rect r1, Rect r2) { + return r1.left <= r2.left && r1.right >= r2.right && r1.top <= r2.top && r1.bottom >= r2.bottom; +} + +Offset _constrainTo(Rect r, Offset p) { + return Offset(clampDouble(p.dx, r.left, r.right), clampDouble(p.dy, r.top, r.bottom)); +} + +Offset _flipX(Offset offset) { + return Offset(-offset.dx, offset.dy); +} + +Offset _flipY(Offset offset) { + return Offset(offset.dx, -offset.dy); +} diff --git a/packages/flutter/lib/src/widgets/_window_win32.dart b/packages/flutter/lib/src/widgets/_window_win32.dart index df60f779089..8ff607e11e5 100644 --- a/packages/flutter/lib/src/widgets/_window_win32.dart +++ b/packages/flutter/lib/src/widgets/_window_win32.dart @@ -23,6 +23,7 @@ import 'package:flutter/rendering.dart'; import '../foundation/_features.dart'; import '_window.dart'; +import '_window_positioner.dart'; import 'binding.dart'; /// A Win32 window handle. @@ -169,6 +170,18 @@ class WindowingOwnerWin32 extends WindowingOwner { ); } + @internal + @override + TooltipWindowController createTooltipWindowController({ + required TooltipWindowControllerDelegate delegate, + required BoxConstraints preferredConstraints, + required Rect anchorRect, + required WindowPositioner positioner, + required BaseWindowController parent, + }) { + throw UnimplementedError('Tooltip 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/window_positioner_test.dart b/packages/flutter/test/widgets/window_positioner_test.dart new file mode 100644 index 00000000000..c51b6cc712a --- /dev/null +++ b/packages/flutter/test/widgets/window_positioner_test.dart @@ -0,0 +1,503 @@ +// 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 'dart:ui'; +import 'package:flutter/src/widgets/_window_positioner.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('WindowPlacementTest', () { + const Rect clientDisplayArea = Rect.fromLTWH(0, 0, 800, 600); + const Size clientParentSize = Size(400, 300); + const Size clientChildSize = Size(100, 50); + final Offset clientParentPosition = Offset( + (clientDisplayArea.width - clientParentSize.width) / 2, + (clientDisplayArea.height - clientParentSize.height) / 2, + ); + + final Rect clientParentRect = Rect.fromLTWH( + clientParentPosition.dx, + clientParentPosition.dy, + clientParentSize.width, + clientParentSize.height, + ); + + const Rect displayArea = Rect.fromLTWH(0, 0, 640, 480); + const Size parentSize = Size(600, 400); + const Size childSize = Size(300, 300); + const Rect rectangleNearRhs = Rect.fromLTWH(590, 20, 10, 20); + const Rect rectangleNearLeftSide = Rect.fromLTWH(0, 20, 20, 20); + const Rect rectangleNearAllSides = Rect.fromLTWH(0, 20, 600, 380); + const Rect rectangleNearBottom = Rect.fromLTWH(20, 380, 20, 20); + const Rect rectangleNearBothBottomRight = Rect.fromLTWH(400, 380, 200, 20); + + final Offset parentPosition = Offset( + (displayArea.width - parentSize.width) / 2, + (displayArea.height - parentSize.height) / 2, + ); + + final Rect parentRect = Rect.fromLTWH( + parentPosition.dx, + parentPosition.dy, + parentSize.width, + parentSize.height, + ); + + Rect anchorRectFor(Rect rect) => rect.translate(parentPosition.dx, parentPosition.dy); + Offset onTopEdge(Rect rect, Size childSize) => rect.topLeft - Offset(0, childSize.height); + Offset onLeftEdge(Rect rect, Size childSize) => rect.topLeft - Offset(childSize.width, 0); + + test('Client anchors to parent given anchor rectangle right of parent', () { + const double rectSize = 10.0; + final Rect overlappingRight = Rect.fromCenter( + center: clientParentRect.topRight.translate(-rectSize / 2, clientParentRect.height / 2), + width: rectSize, + height: rectSize, + ); + + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideY: true, resizeX: true), + ); + + final Rect childRect = positioner.placeWindow( + childSize: clientChildSize, + anchorRect: overlappingRight, + parentRect: clientParentRect, + displayRect: clientDisplayArea, + ); + + final Offset expectedPosition = overlappingRight.topRight; + + expect(childRect.topLeft, expectedPosition); + expect(childRect.size, clientChildSize); + }); + + test('Client anchors to parent given anchor rectangle above parent', () { + const double rectSize = 10.0; + final Rect overlappingAbove = Rect.fromCenter( + center: clientParentRect.topCenter.translate(0, -rectSize / 2), + width: rectSize, + height: rectSize, + ); + + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.bottomRight, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideX: true), + ); + + final Rect childRect = positioner.placeWindow( + childSize: clientChildSize, + anchorRect: overlappingAbove, + parentRect: clientParentRect, + displayRect: clientDisplayArea, + ); + + final Offset expectedPosition = + overlappingAbove.bottomRight - Offset(clientChildSize.width, clientChildSize.height); + + expect(childRect.topLeft, expectedPosition); + expect(childRect.size, clientChildSize); + }); + + test('Client anchors to parent given offset right of parent', () { + const double rectSize = 10.0; + final Rect midRight = Rect.fromLTWH( + clientParentRect.right - rectSize, + clientParentRect.center.dy, + rectSize, + rectSize, + ); + + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.topLeft, + offset: Offset(rectSize, 0), + constraintAdjustment: WindowPositionerConstraintAdjustment(slideY: true, resizeX: true), + ); + + final Rect childRect = positioner.placeWindow( + childSize: clientChildSize, + anchorRect: midRight, + parentRect: clientParentRect, + displayRect: clientDisplayArea, + ); + + final Offset expectedPosition = midRight.topRight; + + expect(childRect.topLeft, expectedPosition); + expect(childRect.size, clientChildSize); + }); + + test('Client anchors to parent given offset above parent', () { + const double rectSize = 10.0; + final Rect midTop = Rect.fromLTWH( + clientParentRect.center.dx, + clientParentRect.top, + rectSize, + rectSize, + ); + + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.bottomRight, + offset: Offset(0, -rectSize), + constraintAdjustment: WindowPositionerConstraintAdjustment(slideX: true), + ); + + final Rect childRect = positioner.placeWindow( + childSize: clientChildSize, + anchorRect: midTop, + parentRect: clientParentRect, + displayRect: clientDisplayArea, + ); + + final Offset expectedPosition = + clientParentPosition + + Offset(clientParentSize.width / 2 + rectSize, 0) - + Offset(clientChildSize.width, clientChildSize.height); + + expect(childRect.topLeft, expectedPosition); + expect(childRect.size, clientChildSize); + }); + + test('Client anchors to parent given anchor rectangle and offset below left parent', () { + const double rectSize = 10.0; + final Rect belowLeft = Rect.fromLTWH( + clientParentRect.left - rectSize, + clientParentRect.bottom, + rectSize, + rectSize, + ); + + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomLeft, + childAnchor: WindowPositionerAnchor.topRight, + offset: Offset(-rectSize, rectSize), + constraintAdjustment: WindowPositionerConstraintAdjustment(resizeX: true, resizeY: true), + ); + + final Rect childRect = positioner.placeWindow( + childSize: clientChildSize, + anchorRect: belowLeft, + parentRect: clientParentRect, + displayRect: clientDisplayArea, + ); + + final Offset expectedPosition = + clientParentRect.bottomLeft - Offset(clientChildSize.width, 0); + + expect(childRect.topLeft, expectedPosition); + expect(childRect.size, clientChildSize); + }); + + group('Can attach by every anchor given no constraint adjustment', () { + Offset anchorPositionFor(WindowPositionerAnchor anchor, Rect rect) { + return switch (anchor) { + WindowPositionerAnchor.center => rect.center, + WindowPositionerAnchor.top => rect.topCenter, + WindowPositionerAnchor.bottom => rect.bottomCenter, + WindowPositionerAnchor.left => rect.centerLeft, + WindowPositionerAnchor.right => rect.centerRight, + WindowPositionerAnchor.topLeft => rect.topLeft, + WindowPositionerAnchor.bottomLeft => rect.bottomLeft, + WindowPositionerAnchor.topRight => rect.topRight, + WindowPositionerAnchor.bottomRight => rect.bottomRight, + }; + } + + for (final WindowPositionerAnchor parentAnchor in WindowPositionerAnchor.values) { + for (final WindowPositionerAnchor childAnchor in WindowPositionerAnchor.values) { + test('parent: $parentAnchor, child: $childAnchor', () { + final Rect anchorRect = anchorRectFor(const Rect.fromLTWH(100, 50, 20, 20)); + final WindowPositioner positioner = WindowPositioner( + parentAnchor: parentAnchor, + childAnchor: childAnchor, + ); + + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect( + anchorPositionFor(childAnchor, childRect), + anchorPositionFor(parentAnchor, anchorRect), + ); + }); + } + } + }); + + test('Placement is flipped given anchor rectangle near right side and offset', () { + const double xOffset = 42.0; + const double yOffset = 13.0; + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.topLeft, + offset: Offset(xOffset, yOffset), + constraintAdjustment: WindowPositionerConstraintAdjustment(flipX: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearRhs); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + final Offset expectedPosition = + onLeftEdge(anchorRect, childSize) + const Offset(-xOffset, yOffset); + + expect(childRect.topLeft, expectedPosition); + }); + + test('Placement is flipped given anchor rectangle near bottom and offset', () { + const double xOffset = 42.0; + const double yOffset = 13.0; + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomLeft, + childAnchor: WindowPositionerAnchor.topLeft, + offset: Offset(xOffset, yOffset), + constraintAdjustment: WindowPositionerConstraintAdjustment(flipY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearBottom); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + final Offset expectedPosition = + onTopEdge(anchorRect, childSize) + const Offset(xOffset, -yOffset); + + expect(childRect.topLeft, expectedPosition); + }); + + test('Placement is flipped both ways given anchor rectangle near bottom right and offset', () { + const double xOffset = 42.0; + const double yOffset = 13.0; + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomRight, + childAnchor: WindowPositionerAnchor.topLeft, + offset: Offset(xOffset, yOffset), + constraintAdjustment: WindowPositionerConstraintAdjustment(flipX: true, flipY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearBothBottomRight); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + final Offset expectedPosition = + anchorRect.topLeft - + Offset(childSize.width, childSize.height) - + const Offset(xOffset, yOffset); + + expect(childRect.topLeft, expectedPosition); + }); + + test('Placement can slide in X given anchor rectangle near right side', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideX: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearRhs); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.topLeft.dx, displayArea.right - childSize.width); + }); + + test('Placement can slide in X given anchor rectangle near left side', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topLeft, + childAnchor: WindowPositionerAnchor.topRight, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideX: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearLeftSide); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.topLeft.dx, displayArea.left); + }); + + test('Placement can slide in Y given anchor rectangle near bottom', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomLeft, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearBottom); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.topLeft.dy, displayArea.bottom - childSize.height); + }); + + test('Placement can slide in Y given anchor rectangle near top', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topLeft, + childAnchor: WindowPositionerAnchor.bottomLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearAllSides); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.topLeft.dy, displayArea.top); + }); + + test('Placement can slide in X and Y given anchor rectangle near bottom right and offset', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomLeft, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(slideX: true, slideY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearBothBottomRight); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + final Offset expectedPosition = Offset( + displayArea.right - childSize.width, + displayArea.bottom - childSize.height, + ); + + expect(childRect.topLeft, expectedPosition); + }); + + test('Placement can resize in X given anchor rectangle near right side', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topRight, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(resizeX: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearRhs); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.width, displayArea.right - (anchorRect.left + anchorRect.width)); + }); + + test('Placement can resize in X given anchor rectangle near left side', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topLeft, + childAnchor: WindowPositionerAnchor.topRight, + constraintAdjustment: WindowPositionerConstraintAdjustment(resizeX: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearLeftSide); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.width, anchorRect.left - displayArea.left); + }); + + test('Placement can resize in Y given anchor rectangle near bottom', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomLeft, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(resizeY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearAllSides); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.height, displayArea.bottom - (anchorRect.top + anchorRect.height)); + }); + + test('Placement can resize in Y given anchor rectangle near top', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.topLeft, + childAnchor: WindowPositionerAnchor.bottomLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(resizeY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearAllSides); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + expect(childRect.height, anchorRect.top - displayArea.top); + }); + + test('Placement can resize in X and Y given anchor rectangle near bottom right and offset', () { + const WindowPositioner positioner = WindowPositioner( + parentAnchor: WindowPositionerAnchor.bottomRight, + childAnchor: WindowPositionerAnchor.topLeft, + constraintAdjustment: WindowPositionerConstraintAdjustment(resizeX: true, resizeY: true), + ); + + final Rect anchorRect = anchorRectFor(rectangleNearBothBottomRight); + final Rect childRect = positioner.placeWindow( + childSize: childSize, + anchorRect: anchorRect, + parentRect: parentRect, + displayRect: displayArea, + ); + + final Size expectedSize = Size( + displayArea.right - (anchorRect.left + anchorRect.width), + displayArea.bottom - (anchorRect.top + anchorRect.height), + ); + + expect(childRect.size, expectedSize); + }); + }); +} diff --git a/packages/flutter/test/widgets/windowing_test.dart b/packages/flutter/test/widgets/windowing_test.dart index 9cc7163c6bb..5dde5da336e 100644 --- a/packages/flutter/test/widgets/windowing_test.dart +++ b/packages/flutter/test/widgets/windowing_test.dart @@ -13,6 +13,8 @@ import 'package:flutter/src/widgets/_window.dart' RegularWindow, RegularWindowController, RegularWindowControllerDelegate, + TooltipWindow, + TooltipWindowController, WindowScope, WindowingOwner, createDefaultWindowingOwner; @@ -108,6 +110,26 @@ class _StubDialogWindowController extends DialogWindowController { void destroy() {} } +class _StubTooltipWindowController extends TooltipWindowController { + _StubTooltipWindowController({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 + void setConstraints(BoxConstraints constraints) {} + + @override + void destroy() {} +} + void main() { group('Windowing', () { group('isWindowingEnabled is false', () { @@ -146,6 +168,16 @@ void main() { ); }); + testWidgets('TooltipWindow throws UnsupportedError', (WidgetTester tester) async { + expect( + () => TooltipWindow( + controller: _StubTooltipWindowController(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)); @@ -213,6 +245,26 @@ void main() { ); }); + testWidgets('Can access WindowScope.of for tooltip windows', (WidgetTester tester) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final BaseWindowController scope = WindowScope.of(context); + expect(scope, isA()); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeOf for regular windows', ( WidgetTester tester, ) async { @@ -251,6 +303,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.maybeOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final BaseWindowController? scope = WindowScope.maybeOf(context); + expect(scope, isA()); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.contentSizeOf for regular windows', ( WidgetTester tester, ) async { @@ -291,6 +365,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.contentSizeOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final Size size = WindowScope.contentSizeOf(context); + expect(size, equals(Size.zero)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeContentSizeOf for regular windows', ( WidgetTester tester, ) async { @@ -331,6 +427,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.maybeContentSizeOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final Size? size = WindowScope.maybeContentSizeOf(context); + expect(size, equals(Size.zero)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.titleOf for regular windows', ( WidgetTester tester, ) async { @@ -369,6 +487,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.titleOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final String title = WindowScope.titleOf(context); + expect(title, equals('')); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeTitleOf for regular windows', ( WidgetTester tester, ) async { @@ -409,6 +549,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.maybeTitleOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final String? title = WindowScope.maybeTitleOf(context); + expect(title, equals('')); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.isActivatedOf for regular windows', ( WidgetTester tester, ) async { @@ -449,6 +611,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.isActivatedOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool isActivated = WindowScope.isActivatedOf(context); + expect(isActivated, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeIsActivatedOf for regular windows', ( WidgetTester tester, ) async { @@ -489,6 +673,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.maybeIsActivatedOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool? isActivated = WindowScope.maybeIsActivatedOf(context); + expect(isActivated, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.isMinimizedOf for regular windows', ( WidgetTester tester, ) async { @@ -529,6 +735,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.isMinimizedOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool isMinimized = WindowScope.isMinimizedOf(context); + expect(isMinimized, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeIsMinimizedOf for regular windows', ( WidgetTester tester, ) async { @@ -569,6 +797,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.maybeIsMinimizedOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool? isMinimized = WindowScope.maybeIsMinimizedOf(context); + expect(isMinimized, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.isMaximizedOf for regular windows', ( WidgetTester tester, ) async { @@ -609,6 +859,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.isMaximizedOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool isMaximized = WindowScope.isMaximizedOf(context); + expect(isMaximized, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeIsMaximizedOf for regular windows', ( WidgetTester tester, ) async { @@ -649,6 +921,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.maybeIsMaximizedOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool? isMaximized = WindowScope.maybeIsMaximizedOf(context); + expect(isMaximized, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.isFullscreenOf for regular windows', ( WidgetTester tester, ) async { @@ -689,6 +983,28 @@ void main() { ); }); + testWidgets('Can access WindowScope.isFullscreenOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool isFullscreen = WindowScope.isFullscreenOf(context); + expect(isFullscreen, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); + testWidgets('Can access WindowScope.maybeIsFullscreenOf for regular windows', ( WidgetTester tester, ) async { @@ -728,6 +1044,28 @@ void main() { ), ); }); + + testWidgets('Can access WindowScope.maybeIsFullscreenOf for tooltip windows', ( + WidgetTester tester, + ) async { + final _StubTooltipWindowController controller = _StubTooltipWindowController( + tester: tester, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + wrapWithView: false, + TooltipWindow( + controller: controller, + child: Builder( + builder: (BuildContext context) { + final bool? isFullscreen = WindowScope.maybeIsFullscreenOf(context); + expect(isFullscreen, equals(false)); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); }); }); }