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.