From 692dbb71e79919629da172d044e775d90614ed00 Mon Sep 17 00:00:00 2001 From: Matej Knopp Date: Fri, 6 Feb 2026 00:11:19 +0100 Subject: [PATCH] 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. [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 --- .../macos/framework/Source/FlutterEngine.mm | 20 +- .../framework/Source/FlutterRunLoop.swift | 1 + .../macos/framework/Source/FlutterView.h | 62 +++ .../macos/framework/Source/FlutterView.mm | 55 ++- .../Source/FlutterWindowController.h | 38 ++ .../Source/FlutterWindowController.mm | 259 ++++++++++-- .../Source/FlutterWindowControllerTest.mm | 109 ++++- .../lib/src/widgets/_window_macos.dart | 388 ++++++++++++++---- 8 files changed, 800 insertions(+), 132 deletions(-) diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm index 817152cfcd3..9abf60a1a6b 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm @@ -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(scaledSize.width), .height = static_cast(scaledSize.height), @@ -1186,6 +1186,20 @@ static void SetThreadPriority(FlutterThreadPriority priority) { .display_id = static_cast(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(minimumContentSize.width); + windowMetricsEvent.min_height_constraint = static_cast(minimumContentSize.height); + windowMetricsEvent.max_width_constraint = static_cast(maximumContentSize.width); + windowMetricsEvent.max_height_constraint = static_cast(maximumContentSize.height); + } else { + windowMetricsEvent.min_width_constraint = static_cast(scaledSize.width); + windowMetricsEvent.min_height_constraint = static_cast(scaledSize.height); + windowMetricsEvent.max_width_constraint = static_cast(scaledSize.width); + windowMetricsEvent.max_height_constraint = static_cast(scaledSize.height); + } _embedderAPI.SendWindowMetricsEvent(_engine, &windowMetricsEvent); } diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift index e99a563934e..f5b01d48512 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift @@ -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() diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h index 73debbdb38a..116f46c5ef3 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h @@ -11,6 +11,39 @@ #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h" #include +#include + +@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 + +/** + * 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)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)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 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_ diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm index 1bdbd584412..ff99348dc9a 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm @@ -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 minSize = [_sizingDelegate minimumViewSize:self]; + if (minSize) { + return *minSize; + } + } + return self.bounds.size; +} + +- (NSSize)maximumContentSize { + if (_sizingDelegate != nil) { + std::optional maxSize = [_sizingDelegate maximumViewSize:self]; + if (maxSize) { + return *maxSize; + } + } + return self.bounds.size; +} + +- (void)constraintsDidChange { + [_viewDelegate viewDidReshape:self]; +} + @end diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.h b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.h index d52553c645f..8d91963aa4f 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.h +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.h @@ -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) } diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.mm index 412c632e70c..d50d7b49f40 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowController.mm @@ -13,17 +13,21 @@ #include "flutter/shell/platform/common/isolate_scope.h" // A delegate for a Flutter managed window. -@interface FlutterWindowOwner : NSObject { +@interface FlutterWindowOwner : NSObject { // Strong reference to the window. This is the only strong reference to the // window. NSWindow* _window; FlutterViewController* _flutterViewController; std::optional _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* _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)minimumViewSize:(FlutterView*)view { + if (_creationRequest.has_constraints) { + return NSMakeSize(_creationRequest.constraints.min_width, + _creationRequest.constraints.min_height); + } else { + return std::nullopt; + } +} -@interface FlutterWindowController () { - NSMutableArray* _windows; +- (std::optional)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) diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowControllerTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowControllerTest.mm index 4889bf2508e..fa4513ceb93 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterWindowControllerTest.mm @@ -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(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(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(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(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(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 diff --git a/packages/flutter/lib/src/widgets/_window_macos.dart b/packages/flutter/lib/src/widgets/_window_macos.dart index 4c5e01084e0..a4c71f27780 100644 --- a/packages/flutter/lib/src/widgets/_window_macos.dart +++ b/packages/flutter/lib/src/widgets/_window_macos.dart @@ -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 _activeControllers = []; + 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 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.isolateLocal(_handleOnShouldClose); + _onWillClose = NativeCallable.isolateLocal(_handleOnWillClose); + _onResize = NativeCallable.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 getWindowHandle() { + _ensureNotDestroyed(); + return WindowingOwnerMacOS.getWindowHandle(rootView); + } + + Size get contentSize { + _ensureNotDestroyed(); + return _MacOSPlatformInterface.getWindowContentSize(getWindowHandle()); + } + + void destroy() { + if (_destroyed) { + return; + } + final Pointer handle = getWindowHandle(); + _MacOSPlatformInterface.destroyWindow(handle); + } + + bool get destroyed => _destroyed; + + bool _destroyed = false; + + late final NativeCallable _onShouldClose; + late final NativeCallable _onWillClose; + late final NativeCallable _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.isolateLocal(_handleOnShouldClose); - _onWillClose = NativeCallable.isolateLocal(_handleOnWillClose); - _onResize = NativeCallable.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 getWindowHandle() { - _ensureNotDestroyed(); - return WindowingOwnerMacOS.getWindowHandle(rootView); - } - - bool _destroyed = false; - @override - void destroy() { - if (_destroyed) { - return; - } - final Pointer 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 _onShouldClose; - late final NativeCallable _onWillClose; - late final NativeCallable _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.isolateLocal(_handleOnShouldClose); - _onWillClose = NativeCallable.isolateLocal(_handleOnWillClose); - _onResize = NativeCallable.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 getWindowHandle() { - _ensureNotDestroyed(); - return WindowingOwnerMacOS.getWindowHandle(rootView); - } - - bool _destroyed = false; - @override - void destroy() { - if (_destroyed) { - return; - } - final Pointer 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 _onShouldClose; - late final NativeCallable _onWillClose; - late final NativeCallable _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> onShouldClose; external Pointer> onWillClose; external Pointer> 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)>( + 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> onShouldClose, + required Pointer> onWillClose, + required Pointer> 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)>(symbol: 'InternalFlutter_Window_Destroy') external static void _destroyWindow(int engineId, Pointer handle); @@ -694,6 +903,9 @@ class _MacOSPlatformInterface { @Native)>(symbol: 'InternalFlutter_Window_IsActivated') external static bool isActivated(Pointer windowHandle); + + @Native)>(symbol: 'InternalFlutter_Window_UpdatePosition') + external static void updateWindowPosition(Pointer windowHandle); } // FFI utilities.