macOS: Implement tooltip window controller (#180895)

This PR implements tooltip windows on macOS.

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

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

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Matej Knopp 2026-02-06 00:11:19 +01:00 committed by GitHub
parent 153301ae9c
commit 692dbb71e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 800 additions and 132 deletions

View File

@ -1171,12 +1171,12 @@ static void SetThreadPriority(FlutterThreadPriority priority) {
}
NSAssert([self viewControllerForIdentifier:viewController.viewIdentifier] == viewController,
@"The provided view controller is not attached to this engine.");
NSView* view = viewController.flutterView;
FlutterView* view = viewController.flutterView;
CGRect scaledBounds = [view convertRectToBacking:view.bounds];
CGSize scaledSize = scaledBounds.size;
double pixelRatio = view.bounds.size.width == 0 ? 1 : scaledSize.width / view.bounds.size.width;
double pixelRatio = view.layer.contentsScale;
auto displayId = [view.window.screen.deviceDescription[@"NSScreenNumber"] integerValue];
const FlutterWindowMetricsEvent windowMetricsEvent = {
FlutterWindowMetricsEvent windowMetricsEvent = {
.struct_size = sizeof(windowMetricsEvent),
.width = static_cast<size_t>(scaledSize.width),
.height = static_cast<size_t>(scaledSize.height),
@ -1186,6 +1186,20 @@ static void SetThreadPriority(FlutterThreadPriority priority) {
.display_id = static_cast<uint64_t>(displayId),
.view_id = viewController.viewIdentifier,
};
if (view.sizedToContents) {
CGSize maximumContentSize = [view convertSizeToBacking:view.maximumContentSize];
CGSize minimumContentSize = [view convertSizeToBacking:view.minimumContentSize];
windowMetricsEvent.has_constraints = true;
windowMetricsEvent.min_width_constraint = static_cast<size_t>(minimumContentSize.width);
windowMetricsEvent.min_height_constraint = static_cast<size_t>(minimumContentSize.height);
windowMetricsEvent.max_width_constraint = static_cast<size_t>(maximumContentSize.width);
windowMetricsEvent.max_height_constraint = static_cast<size_t>(maximumContentSize.height);
} else {
windowMetricsEvent.min_width_constraint = static_cast<size_t>(scaledSize.width);
windowMetricsEvent.min_height_constraint = static_cast<size_t>(scaledSize.height);
windowMetricsEvent.max_width_constraint = static_cast<size_t>(scaledSize.width);
windowMetricsEvent.max_height_constraint = static_cast<size_t>(scaledSize.height);
}
_embedderAPI.SendWindowMetricsEvent(_engine, &windowMetricsEvent);
}

View File

@ -13,6 +13,7 @@ import Foundation
/// (`pollFlutterMessagesOnce()`).
@objc public final class FlutterRunLoop: NSObject {
private static let flutterRunLoopMode = CFRunLoopMode("FlutterRunLoopMode" as CFString)
private static var _mainRunLoop: FlutterRunLoop?
private let runLoop: CFRunLoop = CFRunLoopGetCurrent()

View File

@ -11,6 +11,39 @@
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h"
#include <stdint.h>
#include <optional>
@class FlutterView;
/**
* Interface that facilitates process of sizing the FlutterView to its
* content. It is used to determine the content constraints and to notify
* the view container about the size change so that the parent view can
* be resized (and repositioned) accordingly.
*/
@protocol FlutterViewSizingDelegate <NSObject>
/**
* When view should be sized to content, this method should return the minimum
* logical size of the view.
* For views that are not sized to content, will return std::nullopt;
*/
- (std::optional<NSSize>)minimumViewSize:(nonnull FlutterView*)view;
/**
* When view should be sized to content, this method should return the maximum
* logical size of the view.
* For views that are not sized to content, this method should return std::nullopt.
*/
- (std::optional<NSSize>)maximumViewSize:(nonnull FlutterView*)view;
/**
* Called when the view's size changes. The container should update its
* layout to accommodate the new size.
*/
- (void)viewDidUpdateContents:(nonnull FlutterView*)view withSize:(NSSize)newSize;
@end
/**
* Delegate for FlutterView.
@ -55,6 +88,11 @@
*/
@property(readonly, nonatomic, nonnull) FlutterSurfaceManager* surfaceManager;
/**
* Optional sizing delegate. If set, the view can be sized to its content.
*/
@property(readwrite, nonatomic, weak, nullable) id<FlutterViewSizingDelegate> sizingDelegate;
/**
* By default, the `FlutterSurfaceManager` creates two layers to manage Flutter
* content, the content layer and containing layer. To set the native background
@ -76,6 +114,30 @@
*/
- (void)shutDown;
/**
* Whether this view is sized to contents. If so, resize synchronization
* will be disabled.
*/
@property(nonatomic, readonly) BOOL sizedToContents;
/**
* When sized to contents, this property returns the minimum content size.
* If not sized to contents, this property returns NSZeroSize.
*/
@property(nonatomic, readonly) CGSize minimumContentSize;
/**
* When sized to contents, this property returns the maximum content size.
* If not sized to contents, this property returns NSZeroSize.
*/
@property(nonatomic, readonly) CGSize maximumContentSize;
/**
* Informs the view that layout constraints have changed. The view should
* send reconfigure event to the engine so that new content matches the constraints.
*/
- (void)constraintsDidChange;
@end
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERVIEW_H_

View File

@ -45,7 +45,14 @@
}
- (void)onPresent:(CGSize)frameSize withBlock:(dispatch_block_t)block delay:(NSTimeInterval)delay {
[_resizeSynchronizer performCommitForSize:frameSize afterDelay:delay notify:block];
// This block will be called in main thread same run loop turn as the layer content
// update.
auto notifyBlock = ^{
NSSize scaledSize = [self convertSizeFromBacking:frameSize];
[self.sizingDelegate viewDidUpdateContents:self withSize:scaledSize];
block();
};
[_resizeSynchronizer performCommitForSize:frameSize afterDelay:delay notify:notifyBlock];
}
- (FlutterSurfaceManager*)surfaceManager {
@ -68,14 +75,16 @@
- (void)setFrameSize:(NSSize)newSize {
[super setFrameSize:newSize];
CGSize scaledSize = [self convertSizeToBacking:self.bounds.size];
[_resizeSynchronizer beginResizeForSize:scaledSize
notify:^{
[_viewDelegate viewDidReshape:self];
}
onTimeout:^{
[FlutterLogger logError:@"Resize timed out"];
}];
if (!self.sizedToContents) {
CGSize scaledSize = [self convertSizeToBacking:self.bounds.size];
[_resizeSynchronizer beginResizeForSize:scaledSize
notify:^{
[_viewDelegate viewDidReshape:self];
}
onTimeout:^{
[FlutterLogger logError:@"Resize timed out"];
}];
}
}
/**
@ -164,4 +173,32 @@
return applicationName;
}
- (BOOL)sizedToContents {
return _sizingDelegate != nil && [_sizingDelegate minimumViewSize:self] != std::nullopt;
}
- (NSSize)minimumContentSize {
if (_sizingDelegate != nil) {
std::optional<NSSize> minSize = [_sizingDelegate minimumViewSize:self];
if (minSize) {
return *minSize;
}
}
return self.bounds.size;
}
- (NSSize)maximumContentSize {
if (_sizingDelegate != nil) {
std::optional<NSSize> maxSize = [_sizingDelegate maximumViewSize:self];
if (maxSize) {
return *maxSize;
}
}
return self.bounds.size;
}
- (void)constraintsDidChange {
[_viewDelegate viewDidReshape:self];
}
@end

View File

@ -20,9 +20,34 @@
@end
struct FlutterWindowRect {
double left;
double top;
double width;
double height;
static FlutterWindowRect fromNSRect(const NSRect& rect) {
return {
rect.origin.x,
rect.origin.y,
rect.size.width,
rect.size.height,
};
}
NSRect toNSRect() const { return NSMakeRect(left, top, width, height); }
};
struct FlutterWindowSize {
double width;
double height;
static FlutterWindowSize fromNSSize(const NSSize& size) {
return {
size.width,
size.height,
};
}
};
struct FlutterWindowConstraints {
@ -41,6 +66,11 @@ struct FlutterWindowCreationRequest {
void (*on_should_close)();
void (*on_will_close)();
void (*notify_listeners)();
// For sized to content windows with positioner returns the desired window position for given
// configuration. All coordinates are in logical space.
FlutterWindowRect* (*on_get_window_position)(const FlutterWindowSize& child_size,
const FlutterWindowRect& parent_rect,
const FlutterWindowRect& output_rect);
};
extern "C" {
@ -57,6 +87,11 @@ int64_t InternalFlutter_WindowController_CreateDialogWindow(
int64_t engine_id,
const FlutterWindowCreationRequest* request);
FLUTTER_DARWIN_EXPORT
int64_t InternalFlutter_WindowController_CreateTooltipWindow(
int64_t engine_id,
const FlutterWindowCreationRequest* request);
FLUTTER_DARWIN_EXPORT
void InternalFlutter_Window_Destroy(int64_t engine_id, void* window);
@ -106,6 +141,9 @@ char* InternalFlutter_Window_GetTitle(void* window);
FLUTTER_DARWIN_EXPORT
bool InternalFlutter_Window_IsActivated(void* window);
FLUTTER_DARWIN_EXPORT
void InternalFlutter_Window_UpdatePosition(void* window);
// NOLINTEND(google-objc-function-naming)
}

View File

@ -13,17 +13,21 @@
#include "flutter/shell/platform/common/isolate_scope.h"
// A delegate for a Flutter managed window.
@interface FlutterWindowOwner : NSObject <NSWindowDelegate> {
@interface FlutterWindowOwner : NSObject <NSWindowDelegate, FlutterViewSizingDelegate> {
// Strong reference to the window. This is the only strong reference to the
// window.
NSWindow* _window;
FlutterViewController* _flutterViewController;
std::optional<flutter::Isolate> _isolate;
FlutterWindowCreationRequest _creationRequest;
// Extra size constraints coming from the window positioner.
CGSize _positionerSizeConstraints;
}
@property(readonly, nonatomic) NSWindow* window;
@property(readonly, nonatomic) FlutterViewController* flutterViewController;
@property(readwrite, nonatomic) BOOL closeWhenParentResignsKey;
- (instancetype)initWithWindow:(NSWindow*)window
flutterViewController:(FlutterViewController*)viewController
@ -63,6 +67,14 @@
@end
@interface FlutterWindowController () {
NSMutableArray<FlutterWindowOwner*>* _windows;
}
- (void)windowDidResignKey:(FlutterWindowOwner*)window;
@end
@implementation FlutterWindowOwner
@synthesize window = _window;
@ -86,6 +98,7 @@
- (void)windowDidResignKey:(NSNotification*)notification {
[_flutterViewController.engine windowDidResignKey:_flutterViewController.viewIdentifier];
[[_flutterViewController.engine windowController] windowDidResignKey:self];
}
- (BOOL)windowShouldClose:(NSWindow*)sender {
@ -127,10 +140,106 @@
_creationRequest.notify_listeners();
}
@end
- (std::optional<NSSize>)minimumViewSize:(FlutterView*)view {
if (_creationRequest.has_constraints) {
return NSMakeSize(_creationRequest.constraints.min_width,
_creationRequest.constraints.min_height);
} else {
return std::nullopt;
}
}
@interface FlutterWindowController () {
NSMutableArray<FlutterWindowOwner*>* _windows;
- (std::optional<NSSize>)maximumViewSize:(FlutterView*)view {
if (!_creationRequest.has_constraints) {
// Window is not sized to contents.
return std::nullopt;
}
NSSize screenSize = self.window.screen.visibleFrame.size;
double width = screenSize.width;
width = std::min(width, _creationRequest.constraints.max_width);
if (_positionerSizeConstraints.width > 0) {
width = std::min(width, _positionerSizeConstraints.width);
}
double height = screenSize.height;
height = std::min(height, _creationRequest.constraints.max_height);
if (_positionerSizeConstraints.height > 0) {
height = std::min(height, _positionerSizeConstraints.height);
}
return NSMakeSize(width, height);
}
// Returns the frame that includes all screen. This is used to flip coordinates
// of individual screen to match Flutter coordinate system.
static NSRect ComputeGlobalScreenFrame() {
NSRect frame = NSZeroRect;
for (NSScreen* screen in [NSScreen screens]) {
NSRect screenFrame = screen.frame;
if (NSIsEmptyRect(frame)) {
frame = screenFrame;
} else {
frame = NSUnionRect(frame, screenFrame);
}
}
return frame;
}
static void FlipRect(NSRect& rect, const NSRect& globalScreenFrame) {
// Flip the y coordinate to match Flutter coordinate system.
rect.origin.y = (globalScreenFrame.origin.y + globalScreenFrame.size.height) -
(rect.origin.y + rect.size.height);
}
- (void)updatePosition {
[self viewDidUpdateContents:self.flutterViewController.flutterView
withSize:self.flutterViewController.flutterView.bounds.size];
}
- (void)viewDidUpdateContents:(FlutterView*)view withSize:(NSSize)newSize {
if (_creationRequest.on_get_window_position == nullptr) {
// There is no positioner associated with this window.
return;
}
NSRect globalScreenFrame = ComputeGlobalScreenFrame();
NSRect parentRect =
[self.window.parentWindow contentRectForFrameRect:self.window.parentWindow.frame];
FlipRect(parentRect, globalScreenFrame);
NSRect screenRect = [self.window.screen visibleFrame];
FlipRect(screenRect, globalScreenFrame);
flutter::IsolateScope isolate_scope(*_isolate);
auto position = _creationRequest.on_get_window_position(
FlutterWindowSize::fromNSSize(newSize), FlutterWindowRect::fromNSRect(parentRect),
FlutterWindowRect::fromNSRect(screenRect));
NSRect positionRect = position->toNSRect();
FlipRect(positionRect, globalScreenFrame);
[self.window setFrame:positionRect display:NO animate:NO];
free(position);
// For windows sized to contents if the positioner size doesn't match actual size
// the requested size needs to be passed through constraints.
if (view.sizedToContents &&
(positionRect.size.width < newSize.width || positionRect.size.height < newSize.height)) {
_positionerSizeConstraints = positionRect.size;
[view constraintsDidChange];
} else {
// Only show the window initially if positioner agrees with the size.
self.window.alphaValue = 1.0;
}
}
- (void)setConstraints:(FlutterWindowConstraints)constraints {
if (_flutterViewController.flutterView.sizedToContents) {
self->_creationRequest.constraints = constraints;
[_flutterViewController.flutterView constraintsDidChange];
} else {
[self.window flutterSetConstraints:constraints];
}
}
@end
@ -146,16 +255,16 @@
}
- (FlutterViewIdentifier)createDialogWindow:(const FlutterWindowCreationRequest*)request {
FlutterViewController* c = [[FlutterViewController alloc] initWithEngine:_engine
nibName:nil
bundle:nil];
FlutterViewController* controller = [[FlutterViewController alloc] initWithEngine:_engine
nibName:nil
bundle:nil];
NSWindow* window = [[NSWindow alloc] init];
// If this is not set there will be double free on window close when
// using ARC.
[window setReleasedWhenClosed:NO];
window.contentViewController = c;
window.contentViewController = controller;
window.styleMask =
NSWindowStyleMaskResizable | NSWindowStyleMaskTitled | NSWindowStyleMaskClosable;
window.collectionBehavior = NSWindowCollectionBehaviorFullScreenAuxiliary;
@ -166,11 +275,11 @@
[window flutterSetConstraints:request->constraints];
}
FlutterWindowOwner* w = [[FlutterWindowOwner alloc] initWithWindow:window
flutterViewController:c
creationRequest:*request];
window.delegate = w;
[_windows addObject:w];
FlutterWindowOwner* owner = [[FlutterWindowOwner alloc] initWithWindow:window
flutterViewController:controller
creationRequest:*request];
window.delegate = owner;
[_windows addObject:owner];
NSWindow* parent = nil;
@ -187,28 +296,82 @@
}
if (parent != nil) {
[parent beginCriticalSheet:window
completionHandler:^(NSModalResponse response){
}];
dispatch_async(dispatch_get_main_queue(), ^{
// beginCriticalSheet blocks with nested run loop until the
// sheet animation is finished.
[parent beginCriticalSheet:window
completionHandler:^(NSModalResponse response){
}];
});
} else {
[window setIsVisible:YES];
[window makeKeyAndOrderFront:nil];
}
return c.viewIdentifier;
return controller.viewIdentifier;
}
- (FlutterViewIdentifier)createRegularWindow:(const FlutterWindowCreationRequest*)request {
FlutterViewController* c = [[FlutterViewController alloc] initWithEngine:_engine
nibName:nil
bundle:nil];
- (FlutterViewIdentifier)createTooltipWindow:(const FlutterWindowCreationRequest*)request {
FlutterViewController* controller = [[FlutterViewController alloc] initWithEngine:_engine
nibName:nil
bundle:nil];
NSWindow* window = [[NSWindow alloc] init];
// If this is not set there will be double free on window close when
// using ARC.
[window setReleasedWhenClosed:NO];
window.contentViewController = c;
window.contentViewController = controller;
window.styleMask = NSWindowStyleMaskBorderless;
window.hasShadow = NO;
window.opaque = NO;
window.backgroundColor = [NSColor clearColor];
FlutterWindowOwner* owner = [[FlutterWindowOwner alloc] initWithWindow:window
flutterViewController:controller
creationRequest:*request];
controller.flutterView.sizingDelegate = owner;
controller.flutterView.backgroundColor = [NSColor clearColor];
// Resend configure event after setting the sizing delegate.
[controller.flutterView constraintsDidChange];
owner.closeWhenParentResignsKey = YES;
window.delegate = owner;
[_windows addObject:owner];
NSWindow* parent = nil;
if (request->parent_view_id != 0) {
for (FlutterWindowOwner* owner in _windows) {
if (owner.flutterViewController.viewIdentifier == request->parent_view_id) {
parent = owner.window;
break;
}
}
}
NSAssert(parent != nil, @"Tooltip window must have a parent window.");
window.ignoresMouseEvents = YES;
window.collectionBehavior = NSWindowCollectionBehaviorAuxiliary;
[parent addChildWindow:window ordered:NSWindowAbove];
window.alphaValue = 0.0;
return controller.viewIdentifier;
}
- (FlutterViewIdentifier)createRegularWindow:(const FlutterWindowCreationRequest*)request {
FlutterViewController* controller = [[FlutterViewController alloc] initWithEngine:_engine
nibName:nil
bundle:nil];
NSWindow* window = [[NSWindow alloc] init];
// If this is not set there will be double free on window close when
// using ARC.
[window setReleasedWhenClosed:NO];
window.contentViewController = controller;
window.styleMask = NSWindowStyleMaskResizable | NSWindowStyleMaskTitled |
NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable;
if (request->has_size) {
@ -220,13 +383,13 @@
[window setIsVisible:YES];
[window makeKeyAndOrderFront:nil];
FlutterWindowOwner* w = [[FlutterWindowOwner alloc] initWithWindow:window
flutterViewController:c
creationRequest:*request];
window.delegate = w;
[_windows addObject:w];
FlutterWindowOwner* owner = [[FlutterWindowOwner alloc] initWithWindow:window
flutterViewController:controller
creationRequest:*request];
window.delegate = owner;
[_windows addObject:owner];
return c.viewIdentifier;
return controller.viewIdentifier;
}
- (void)destroyWindow:(NSWindow*)window {
@ -239,7 +402,6 @@
}
if (owner != nil) {
[_windows removeObject:owner];
for (NSWindow* win in owner.window.sheets) {
[self destroyWindow:win];
}
@ -247,7 +409,6 @@
for (NSWindow* win in owner.window.childWindows) {
[self destroyWindow:win];
}
// Make sure to unregister the controller from the engine and remove the FlutterView
// before destroying the window and Flutter NSView.
[owner.flutterViewController dispose];
@ -265,6 +426,27 @@
[_windows removeAllObjects];
}
static BOOL IsChildAncestor(NSWindow* child, NSWindow* ancestor) {
NSWindow* current = child.parentWindow;
while (current) {
if (current == ancestor) {
return YES;
}
current = current.parentWindow;
}
return NO;
}
- (void)windowDidResignKey:(FlutterWindowOwner*)parent {
for (FlutterWindowOwner* possibleChild in _windows) {
if (possibleChild.closeWhenParentResignsKey &&
IsChildAncestor(possibleChild.window, parent.window)) {
[possibleChild windowShouldClose:possibleChild.window];
}
}
}
@end
// NOLINTBEGIN(google-objc-function-naming)
@ -285,6 +467,14 @@ int64_t InternalFlutter_WindowController_CreateDialogWindow(
return [engine.windowController createDialogWindow:request];
}
int64_t InternalFlutter_WindowController_CreateTooltipWindow(
int64_t engine_id,
const FlutterWindowCreationRequest* request) {
FlutterEngine* engine = [FlutterEngine engineForIdentifier:engine_id];
[engine enableMultiView];
return [engine.windowController createTooltipWindow:request];
}
void InternalFlutter_Window_Destroy(int64_t engine_id, void* window) {
NSWindow* w = (__bridge NSWindow*)window;
FlutterEngine* engine = [FlutterEngine engineForIdentifier:engine_id];
@ -315,7 +505,8 @@ FLUTTER_DARWIN_EXPORT
void InternalFlutter_Window_SetConstraints(void* window,
const FlutterWindowConstraints* constraints) {
NSWindow* w = (__bridge NSWindow*)window;
[w flutterSetConstraints:*constraints];
FlutterWindowOwner* owner = (FlutterWindowOwner*)w.delegate;
[owner setConstraints:*constraints];
}
void InternalFlutter_Window_SetTitle(void* window, const char* title) {
@ -383,4 +574,10 @@ bool InternalFlutter_Window_IsActivated(void* window) {
return w.isKeyWindow;
}
void InternalFlutter_Window_UpdatePosition(void* window) {
NSWindow* w = (__bridge NSWindow*)window;
FlutterWindowOwner* owner = (FlutterWindowOwner*)w.delegate;
[owner updatePosition];
}
// NOLINTEND(google-objc-function-naming)

View File

@ -8,6 +8,7 @@
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject_Internal.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTestUtils.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.h"
#import "flutter/testing/testing.h"
#import "third_party/googletest/googletest/include/gtest/gtest.h"
@ -81,6 +82,50 @@ TEST_F(FlutterWindowControllerTest, CreateRegularWindow) {
}
}
TEST_F(FlutterWindowControllerTest, CreateTooltipWindow) {
IsolateScope isolate_scope(isolate());
FlutterEngine* engine = GetFlutterEngine();
int64_t engineId = reinterpret_cast<int64_t>(engine);
auto request = FlutterWindowCreationRequest{
.has_size = true,
.size = {.width = 800, .height = 600},
.on_should_close = [] {},
.on_will_close = [] {},
.notify_listeners = [] {},
};
int64_t parentViewId = InternalFlutter_WindowController_CreateRegularWindow(engineId, &request);
EXPECT_EQ(parentViewId, 1);
auto position_callback = [](const FlutterWindowSize& child_size,
const FlutterWindowRect& parent_rect,
const FlutterWindowRect& output_rect) -> FlutterWindowRect* {
FlutterWindowRect* rect = static_cast<FlutterWindowRect*>(malloc(sizeof(FlutterWindowRect)));
rect->left = parent_rect.left + 10;
rect->top = parent_rect.top + 10;
rect->width = child_size.width;
rect->height = child_size.height;
return rect;
};
request = FlutterWindowCreationRequest{
.has_constraints = true,
.constraints{
.max_width = 1000,
.max_height = 1000,
},
.parent_view_id = parentViewId,
.on_should_close = [] {},
.on_will_close = [] {},
.notify_listeners = [] {},
.on_get_window_position = position_callback,
};
const int64_t tooltipViewId =
InternalFlutter_WindowController_CreateTooltipWindow(engineId, &request);
EXPECT_NE(tooltipViewId, 0);
}
TEST_F(FlutterWindowControllerRetainTest, WindowControllerDoesNotRetainEngine) {
FlutterWindowCreationRequest request{
.has_size = true,
@ -211,7 +256,6 @@ TEST_F(FlutterWindowControllerTest, ClosesAllWindowsOnEngineRestart) {
.on_will_close = [] {},
.notify_listeners = [] {},
};
FlutterEngine* engine = GetFlutterEngine();
int64_t engine_id = reinterpret_cast<int64_t>(engine);
@ -240,5 +284,68 @@ TEST_F(FlutterWindowControllerTest, ClosesAllWindowsOnEngineRestart) {
EXPECT_EQ(viewController1, nil);
EXPECT_EQ(viewController2, nil);
EXPECT_EQ(viewController3, nil);
};
TEST_F(FlutterWindowControllerTest, ViewMetricsRespectPositionCallbackConstraints) {
IsolateScope isolate_scope(isolate());
FlutterEngine* engine = GetFlutterEngine();
int64_t engineId = reinterpret_cast<int64_t>(engine);
// Create parent window.
auto parentRequest = FlutterWindowCreationRequest{
.has_size = true,
.size = {.width = 800, .height = 600},
.on_should_close = [] {},
.on_will_close = [] {},
.notify_listeners = [] {},
};
int64_t parentViewId =
InternalFlutter_WindowController_CreateRegularWindow(engineId, &parentRequest);
EXPECT_EQ(parentViewId, 1);
auto position_callback = [](const FlutterWindowSize& child_size,
const FlutterWindowRect& parent_rect,
const FlutterWindowRect& output_rect) -> FlutterWindowRect* {
FlutterWindowRect* rect = static_cast<FlutterWindowRect*>(malloc(sizeof(FlutterWindowRect)));
rect->left = parent_rect.left;
rect->top = parent_rect.top;
rect->width = 500;
rect->height = 400;
return rect;
};
auto tooltipRequest = FlutterWindowCreationRequest{
.has_constraints = true,
.constraints{
.min_width = 0,
.min_height = 0,
.max_width = 1000,
.max_height = 1000,
},
.parent_view_id = parentViewId,
.on_should_close = [] {},
.on_will_close = [] {},
.notify_listeners = [] {},
.on_get_window_position = position_callback,
};
const int64_t tooltipViewId =
InternalFlutter_WindowController_CreateTooltipWindow(engineId, &tooltipRequest);
EXPECT_NE(tooltipViewId, 0);
FlutterViewController* viewController = [engine viewControllerForIdentifier:tooltipViewId];
FlutterView* flutterView = viewController.flutterView;
EXPECT_EQ(flutterView.sizedToContents, YES);
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, false);
[flutterView.sizingDelegate viewDidUpdateContents:flutterView withSize:NSMakeSize(1000, 1000)];
// The constraints from request are 1000x1000, but additional constraints came from the positioner
// and must be respected.
CGSize maxSize = flutterView.maximumContentSize;
EXPECT_LE(maxSize.width, 500);
EXPECT_LE(maxSize.height, 400);
}
} // namespace flutter::testing

View File

@ -120,7 +120,16 @@ class WindowingOwnerMacOS extends WindowingOwner {
required WindowPositioner positioner,
required BaseWindowController parent,
}) {
throw UnimplementedError('Tooltip windows are not yet implemented on MacOS.');
final res = TooltipWindowControllerMacOS(
owner: this,
delegate: delegate,
contentSizeConstraints: preferredConstraints,
anchorRect: anchorRect,
positioner: positioner,
parent: parent,
);
_activeControllers.add(res);
return res;
}
@internal
@ -135,7 +144,7 @@ class WindowingOwnerMacOS extends WindowingOwner {
throw UnimplementedError('Popup windows are not yet implemented on MacOS.');
}
final List<BaseWindowController> _activeControllers = <BaseWindowController>[];
final List<_WindowControllerMixin> _activeControllers = <_WindowControllerMixin>[];
/// Returns the window handle for the given [view], or null is the window
/// handle is not available.
@ -143,12 +152,200 @@ class WindowingOwnerMacOS extends WindowingOwner {
/// The window handle is a pointer to the NSWindow instance.
static Pointer<Void> getWindowHandle(FlutterView view) {
return _MacOSPlatformInterface.getWindowHandle(
WidgetsBinding.instance.platformDispatcher.engineId!,
PlatformDispatcher.instance.engineId!,
view.viewId,
);
}
}
mixin _WindowControllerMixin {
void _initController(WindowingOwnerMacOS owner) {
if (!isWindowingEnabled) {
throw UnsupportedError(_kWindowingDisabledErrorMessage);
}
_onShouldClose = NativeCallable<Void Function()>.isolateLocal(_handleOnShouldClose);
_onWillClose = NativeCallable<Void Function()>.isolateLocal(_handleOnWillClose);
_onResize = NativeCallable<Void Function()>.isolateLocal(_handleOnResize);
_onGetWindowPosition =
NativeCallable<
Pointer<_Rect> Function(
Pointer<_Size> childSize,
Pointer<_Rect> parentRect,
Pointer<_Rect> outputRect,
)
>.isolateLocal(_handleOnGetWindowPosition);
_owner = owner;
_owner._activeControllers.add(this);
}
void _handleOnShouldClose();
void _handleOnResize();
@mustCallSuper
void _handleOnWillClose() {
_onWillClose.close();
_onShouldClose.close();
_onResize.close();
_onGetWindowPosition.close();
_destroyed = true;
_owner._activeControllers.remove(this);
}
@mustCallSuper
Pointer<_Rect> _handleOnGetWindowPosition(
Pointer<_Size> childSize,
Pointer<_Rect> parentRect,
Pointer<_Rect> outputRect,
) {
return Pointer<_Rect>.fromAddress(0);
}
void _ensureNotDestroyed() {
if (_destroyed) {
throw StateError('Window has been destroyed.');
}
}
FlutterView get rootView;
/// Returns window handle for the current window.
/// The handle is a pointer to NSWindow instance.
Pointer<Void> getWindowHandle() {
_ensureNotDestroyed();
return WindowingOwnerMacOS.getWindowHandle(rootView);
}
Size get contentSize {
_ensureNotDestroyed();
return _MacOSPlatformInterface.getWindowContentSize(getWindowHandle());
}
void destroy() {
if (_destroyed) {
return;
}
final Pointer<Void> handle = getWindowHandle();
_MacOSPlatformInterface.destroyWindow(handle);
}
bool get destroyed => _destroyed;
bool _destroyed = false;
late final NativeCallable<Void Function()> _onShouldClose;
late final NativeCallable<Void Function()> _onWillClose;
late final NativeCallable<Void Function()> _onResize;
late final NativeCallable<
Pointer<_Rect> Function(
Pointer<_Size> childSize,
Pointer<_Rect> parentRect,
Pointer<_Rect> outputRect,
)
>
_onGetWindowPosition;
late final WindowingOwnerMacOS _owner;
}
/// MacOS specific implementation of [TooltipWindowController].
///
/// /// {@macro flutter.widgets.windowing.experimental}
class TooltipWindowControllerMacOS extends TooltipWindowController with _WindowControllerMixin {
/// Creates a new tooltip window controller for macOS.
TooltipWindowControllerMacOS({
required WindowingOwnerMacOS owner,
required TooltipWindowControllerDelegate delegate,
required BoxConstraints contentSizeConstraints,
required BaseWindowController parent,
required Rect anchorRect,
required WindowPositioner positioner,
}) : _anchorRect = anchorRect,
_positioner = positioner,
_delegate = delegate,
_parent = parent,
super.empty() {
_initController(owner);
final int viewId = _MacOSPlatformInterface.createTooltipWindow(
preferredConstraints: contentSizeConstraints,
onShouldClose: _onShouldClose.nativeFunction,
onWillClose: _onWillClose.nativeFunction,
onNotifyListeners: _onResize.nativeFunction,
onGetWindowPosition: _onGetWindowPosition.nativeFunction,
parentViewId: parent.rootView.viewId,
);
final FlutterView flutterView = WidgetsBinding.instance.platformDispatcher.views.firstWhere(
(FlutterView view) => view.viewId == viewId,
);
rootView = flutterView;
}
@override
void updatePosition({Rect? anchorRect, WindowPositioner? positioner}) {
if (anchorRect != null) {
_anchorRect = anchorRect;
}
if (positioner != null) {
_positioner = positioner;
}
_MacOSPlatformInterface.updateWindowPosition(getWindowHandle());
}
@override
void _handleOnShouldClose() {
destroy();
}
@override
void _handleOnWillClose() {
super._handleOnWillClose();
_delegate.onWindowDestroyed();
}
@override
void _handleOnResize() {
notifyListeners();
}
@override
Pointer<_Rect> _handleOnGetWindowPosition(
Pointer<_Size> childSize,
Pointer<_Rect> parentRect,
Pointer<_Rect> outputRect,
) {
super._handleOnGetWindowPosition(childSize, parentRect, outputRect);
final Pointer<_Rect> result = _allocator<_Rect>();
final Rect targetRect = _positioner.placeWindow(
childSize: childSize.ref.toSize(),
anchorRect: _anchorRect.translate(parentRect.ref.left, parentRect.ref.top),
parentRect: parentRect.ref.toRect(),
displayRect: outputRect.ref.toRect(),
);
result.ref.left = targetRect.left;
result.ref.top = targetRect.top;
result.ref.width = childSize.ref.width;
result.ref.height = childSize.ref.height;
return result;
}
@override
BaseWindowController get parent => _parent;
@override
void setConstraints(BoxConstraints constraints) {
_ensureNotDestroyed();
_MacOSPlatformInterface.setWindowConstraints(getWindowHandle(), constraints);
}
final TooltipWindowControllerDelegate _delegate;
final BaseWindowController _parent;
WindowPositioner _positioner;
Rect _anchorRect;
}
/// Implementation of [RegularWindowController] for the macOS platform.
///
/// {@macro flutter.widgets.windowing.experimental}
@ -156,7 +353,7 @@ class WindowingOwnerMacOS extends WindowingOwner {
/// See also:
///
/// * [RegularWindowController], the base class for regular windows.
class RegularWindowControllerMacOS extends RegularWindowController {
class RegularWindowControllerMacOS extends RegularWindowController with _WindowControllerMixin {
/// Creates a new regular window controller for macOS. When this constructor
/// completes the FlutterView is created and framework is aware of it.
RegularWindowControllerMacOS({
@ -165,16 +362,10 @@ class RegularWindowControllerMacOS extends RegularWindowController {
required Size? preferredSize,
BoxConstraints? preferredConstraints,
String? title,
}) : _owner = owner,
_delegate = delegate,
}) : _delegate = delegate,
super.empty() {
if (!isWindowingEnabled) {
throw UnsupportedError(_kWindowingDisabledErrorMessage);
}
_initController(owner);
_onShouldClose = NativeCallable<Void Function()>.isolateLocal(_handleOnShouldClose);
_onWillClose = NativeCallable<Void Function()>.isolateLocal(_handleOnWillClose);
_onResize = NativeCallable<Void Function()>.isolateLocal(_handleOnResize);
final int viewId = _MacOSPlatformInterface.createRegularWindow(
preferredSize: preferredSize,
preferredConstraints: preferredConstraints,
@ -191,38 +382,18 @@ class RegularWindowControllerMacOS extends RegularWindowController {
}
}
/// Returns window handle for the current window.
///
/// The handle is a pointer to an NSWindow instance.
Pointer<Void> getWindowHandle() {
_ensureNotDestroyed();
return WindowingOwnerMacOS.getWindowHandle(rootView);
}
bool _destroyed = false;
@override
void destroy() {
if (_destroyed) {
return;
}
final Pointer<Void> handle = getWindowHandle();
_MacOSPlatformInterface.destroyWindow(handle);
}
void _handleOnShouldClose() {
_delegate.onWindowCloseRequested(this);
}
@override
void _handleOnWillClose() {
_destroyed = true;
_owner._activeControllers.remove(this);
super._handleOnWillClose();
_delegate.onWindowDestroyed();
_onShouldClose.close();
_onWillClose.close();
_onResize.close();
}
@override
void _handleOnResize() {
notifyListeners();
}
@ -248,12 +419,6 @@ class RegularWindowControllerMacOS extends RegularWindowController {
notifyListeners();
}
final WindowingOwnerMacOS _owner;
final RegularWindowControllerDelegate _delegate;
late final NativeCallable<Void Function()> _onShouldClose;
late final NativeCallable<Void Function()> _onWillClose;
late final NativeCallable<Void Function()> _onResize;
@override
Size get contentSize {
_ensureNotDestroyed();
@ -306,11 +471,7 @@ class RegularWindowControllerMacOS extends RegularWindowController {
return _MacOSPlatformInterface.isFullscreen(getWindowHandle());
}
void _ensureNotDestroyed() {
if (_destroyed) {
throw StateError('Window has been destroyed.');
}
}
final RegularWindowControllerDelegate _delegate;
@override
bool get isActivated => _MacOSPlatformInterface.isActivated(getWindowHandle());
@ -326,7 +487,7 @@ class RegularWindowControllerMacOS extends RegularWindowController {
/// See also:
///
/// * [DialogWindowController], the base class for dialog windows.
class DialogWindowControllerMacOS extends DialogWindowController {
class DialogWindowControllerMacOS extends DialogWindowController with _WindowControllerMixin {
/// Creates a new regular window controller for macOS. When this constructor
/// completes the FlutterView is created and framework is aware of it.
DialogWindowControllerMacOS({
@ -336,16 +497,10 @@ class DialogWindowControllerMacOS extends DialogWindowController {
this.parent,
BoxConstraints? preferredConstraints,
String? title,
}) : _owner = owner,
_delegate = delegate,
}) : _delegate = delegate,
super.empty() {
if (!isWindowingEnabled) {
throw UnsupportedError(_kWindowingDisabledErrorMessage);
}
_initController(owner);
_onShouldClose = NativeCallable<Void Function()>.isolateLocal(_handleOnShouldClose);
_onWillClose = NativeCallable<Void Function()>.isolateLocal(_handleOnWillClose);
_onResize = NativeCallable<Void Function()>.isolateLocal(_handleOnResize);
final int viewId = _MacOSPlatformInterface.createDialogWindow(
preferredSize: preferredSize,
preferredConstraints: preferredConstraints,
@ -363,38 +518,18 @@ class DialogWindowControllerMacOS extends DialogWindowController {
}
}
/// Returns the window handle for this window.
///
/// The handle is a pointer to an `NSWindow` instance.
Pointer<Void> getWindowHandle() {
_ensureNotDestroyed();
return WindowingOwnerMacOS.getWindowHandle(rootView);
}
bool _destroyed = false;
@override
void destroy() {
if (_destroyed) {
return;
}
final Pointer<Void> handle = getWindowHandle();
_MacOSPlatformInterface.destroyWindow(handle);
}
void _handleOnShouldClose() {
_delegate.onWindowCloseRequested(this);
}
@override
void _handleOnWillClose() {
_destroyed = true;
_owner._activeControllers.remove(this);
super._handleOnWillClose();
_delegate.onWindowDestroyed();
_onShouldClose.close();
_onWillClose.close();
_onResize.close();
}
@override
void _handleOnResize() {
notifyListeners();
}
@ -420,11 +555,7 @@ class DialogWindowControllerMacOS extends DialogWindowController {
notifyListeners();
}
final WindowingOwnerMacOS _owner;
final DialogWindowControllerDelegate _delegate;
late final NativeCallable<Void Function()> _onShouldClose;
late final NativeCallable<Void Function()> _onWillClose;
late final NativeCallable<Void Function()> _onResize;
@override
Size get contentSize {
@ -454,12 +585,6 @@ class DialogWindowControllerMacOS extends DialogWindowController {
return _MacOSPlatformInterface.isMinimized(getWindowHandle());
}
void _ensureNotDestroyed() {
if (_destroyed) {
throw StateError('Window has been destroyed.');
}
}
@override
bool get isActivated => _MacOSPlatformInterface.isActivated(getWindowHandle());
@ -485,6 +610,16 @@ final class _WindowCreationRequest extends Struct {
external Pointer<NativeFunction<Void Function()>> onShouldClose;
external Pointer<NativeFunction<Void Function()>> onWillClose;
external Pointer<NativeFunction<Void Function()>> onNotifyListeners;
external Pointer<
NativeFunction<
Pointer<_Rect> Function(
Pointer<_Size> childSize,
Pointer<_Rect> parentRect,
Pointer<_Rect> outputRect,
)
>
>
onGetWindowPosition;
}
final class _Size extends Struct {
@ -493,6 +628,38 @@ final class _Size extends Struct {
@Double()
external double height;
@override
String toString() {
return 'Size(width: $width, height: $height)';
}
Size toSize() {
return Size(width, height);
}
}
final class _Rect extends Struct {
@Double()
external double left;
@Double()
external double top;
@Double()
external double width;
@Double()
external double height;
Rect toRect() {
return Rect.fromLTWH(left, top, width, height);
}
@override
String toString() {
return 'Rect(left: $left, top: $top, width: $width, height: $height)';
}
}
final class _Constraints extends Struct {
@ -634,6 +801,48 @@ class _MacOSPlatformInterface {
}
}
@Native<Int64 Function(Int64, Pointer<_WindowCreationRequest>)>(
symbol: 'InternalFlutter_WindowController_CreateTooltipWindow',
)
external static int _createTooltipWindow(int engineId, Pointer<_WindowCreationRequest> request);
/// Creates a new window and returns the viewId of the created FlutterView.
static int createTooltipWindow({
required BoxConstraints preferredConstraints,
required int parentViewId,
required Pointer<NativeFunction<Void Function()>> onShouldClose,
required Pointer<NativeFunction<Void Function()>> onWillClose,
required Pointer<NativeFunction<Void Function()>> onNotifyListeners,
required Pointer<
NativeFunction<
Pointer<_Rect> Function(
Pointer<_Size> childSize,
Pointer<_Rect> parentRect,
Pointer<_Rect> outputRect,
)
>
>
onGetWindowPosition,
}) {
final Pointer<_WindowCreationRequest> request = _allocator<_WindowCreationRequest>()
..ref.onShouldClose = onShouldClose
..ref.onWillClose = onWillClose
..ref.onNotifyListeners = onNotifyListeners
..ref.onGetWindowPosition = onGetWindowPosition
..ref.parentViewId = parentViewId;
request.ref
..hasConstraints = true
..constraints.minWidth = preferredConstraints.minWidth
..constraints.minHeight = preferredConstraints.minHeight
..constraints.maxWidth = preferredConstraints.maxWidth
..constraints.maxHeight = preferredConstraints.maxHeight;
final int viewId = _createTooltipWindow(PlatformDispatcher.instance.engineId!, request);
_allocator.free(request);
return viewId;
}
@Native<Void Function(Int64, Pointer<Void>)>(symbol: 'InternalFlutter_Window_Destroy')
external static void _destroyWindow(int engineId, Pointer<Void> handle);
@ -694,6 +903,9 @@ class _MacOSPlatformInterface {
@Native<Bool Function(Pointer<Void>)>(symbol: 'InternalFlutter_Window_IsActivated')
external static bool isActivated(Pointer<Void> windowHandle);
@Native<Void Function(Pointer<Void>)>(symbol: 'InternalFlutter_Window_UpdatePosition')
external static void updateWindowPosition(Pointer<Void> windowHandle);
}
// FFI utilities.