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 🧪 

## Pre-launch Checklist

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

View File

@ -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<MyApp> createState() {
return _MyAppState();
}
}
class _MyAppState extends State<MyApp> {
final GlobalKey _key = GlobalKey();
TooltipWindowController? _tooltipController;
@override
Widget build(BuildContext context) {
final List<Widget> children = <Widget>[
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;
}
}

View File

@ -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);
});
}

View File

@ -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',
};
}

View File

@ -39,6 +39,7 @@ class WindowContent extends StatelessWidget {
controller: dialog,
child: MaterialApp(home: DialogWindowContent(window: dialog)),
),
TooltipWindowController() => throw UnimplementedError(),
};
}
}

View File

@ -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,
},
},
);

View File

@ -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.

View File

@ -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<BaseWindowController> _activeControllers = <BaseWindowController>[];
/// Returns the window handle for the given [view], or null is the window

View File

@ -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);
}

View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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<TooltipWindowController>());
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<TooltipWindowController>());
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();
},
),
),
);
});
});
});
}