| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterViewController.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h" |
| |
| #include <Carbon/Carbon.h> |
| #import <objc/message.h> |
| |
| #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" |
| #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterCodecs.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterEngine.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterKeyPrimaryResponder.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputSemanticsObject.h" |
| #import "flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h" |
| #import "flutter/shell/platform/embedder/embedder.h" |
| |
| namespace { |
| using flutter::KeyboardLayoutNotifier; |
| using flutter::LayoutClue; |
| |
| // Use different device ID for mouse and pan/zoom events, since we can't differentiate the actual |
| // device (mouse v.s. trackpad). |
| static constexpr int32_t kMousePointerDeviceId = 0; |
| static constexpr int32_t kPointerPanZoomDeviceId = 1; |
| |
| // A trackpad touch following inertial scrolling should cause an inertia cancel |
| // event to be issued. Use a window of 50 milliseconds after the scroll to account |
| // for delays in event propagation observed in macOS Ventura. |
| static constexpr double kTrackpadTouchInertiaCancelWindowMs = 0.050; |
| |
| /** |
| * State tracking for mouse events, to adapt between the events coming from the system and the |
| * events that the embedding API expects. |
| */ |
| struct MouseState { |
| /** |
| * The currently pressed buttons, as represented in FlutterPointerEvent. |
| */ |
| int64_t buttons = 0; |
| |
| /** |
| * The accumulated gesture pan. |
| */ |
| CGFloat delta_x = 0; |
| CGFloat delta_y = 0; |
| |
| /** |
| * The accumulated gesture zoom scale. |
| */ |
| CGFloat scale = 0; |
| |
| /** |
| * The accumulated gesture rotation. |
| */ |
| CGFloat rotation = 0; |
| |
| /** |
| * Whether or not a kAdd event has been sent (or sent again since the last kRemove if tracking is |
| * enabled). Used to determine whether to send a kAdd event before sending an incoming mouse |
| * event, since Flutter expects pointers to be added before events are sent for them. |
| */ |
| bool flutter_state_is_added = false; |
| |
| /** |
| * Whether or not a kDown has been sent since the last kAdd/kUp. |
| */ |
| bool flutter_state_is_down = false; |
| |
| /** |
| * Whether or not mouseExited: was received while a button was down. Cocoa's behavior when |
| * dragging out of a tracked area is to send an exit, then keep sending drag events until the last |
| * button is released. Flutter doesn't expect to receive events after a kRemove, so the kRemove |
| * for the exit needs to be delayed until after the last mouse button is released. If cursor |
| * returns back to the window while still dragging, the flag is cleared in mouseEntered:. |
| */ |
| bool has_pending_exit = false; |
| |
| /** |
| * Pan gesture is currently sending us events. |
| */ |
| bool pan_gesture_active = false; |
| |
| /** |
| * Scale gesture is currently sending us events. |
| */ |
| bool scale_gesture_active = false; |
| |
| /** |
| * Rotate gesture is currently sending use events. |
| */ |
| bool rotate_gesture_active = false; |
| |
| /** |
| * Time of last scroll momentum event. |
| */ |
| NSTimeInterval last_scroll_momentum_changed_time = 0; |
| |
| /** |
| * Resets all gesture state to default values. |
| */ |
| void GestureReset() { |
| delta_x = 0; |
| delta_y = 0; |
| scale = 0; |
| rotation = 0; |
| } |
| |
| /** |
| * Resets all state to default values. |
| */ |
| void Reset() { |
| flutter_state_is_added = false; |
| flutter_state_is_down = false; |
| has_pending_exit = false; |
| buttons = 0; |
| GestureReset(); |
| } |
| }; |
| |
| /** |
| * Returns the current Unicode layout data (kTISPropertyUnicodeKeyLayoutData). |
| * |
| * To use the returned data, convert it to CFDataRef first, finds its bytes |
| * with CFDataGetBytePtr, then reinterpret it into const UCKeyboardLayout*. |
| * It's returned in NSData* to enable auto reference count. |
| */ |
| NSData* currentKeyboardLayoutData() { |
| TISInputSourceRef source = TISCopyCurrentKeyboardInputSource(); |
| CFTypeRef layout_data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); |
| if (layout_data == nil) { |
| CFRelease(source); |
| // TISGetInputSourceProperty returns null with Japanese keyboard layout. |
| // Using TISCopyCurrentKeyboardLayoutInputSource to fix NULL return. |
| // https://github.com/microsoft/node-native-keymap/blob/5f0699ded00179410a14c0e1b0e089fe4df8e130/src/keyboard_mac.mm#L91 |
| source = TISCopyCurrentKeyboardLayoutInputSource(); |
| layout_data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); |
| } |
| return (__bridge_transfer NSData*)CFRetain(layout_data); |
| } |
| |
| } // namespace |
| |
| #pragma mark - Private interface declaration. |
| |
| /** |
| * FlutterViewWrapper is a convenience class that wraps a FlutterView and provides |
| * a mechanism to attach AppKit views such as FlutterTextField without affecting |
| * the accessibility subtree of the wrapped FlutterView itself. |
| * |
| * The FlutterViewController uses this class to create its content view. When |
| * any of the accessibility services (e.g. VoiceOver) is turned on, the accessibility |
| * bridge creates FlutterTextFields that interact with the service. The bridge has to |
| * attach the FlutterTextField somewhere in the view hierarchy in order for the |
| * FlutterTextField to interact correctly with VoiceOver. Those FlutterTextFields |
| * will be attached to this view so that they won't affect the accessibility subtree |
| * of FlutterView. |
| */ |
| @interface FlutterViewWrapper : NSView |
| |
| - (void)setBackgroundColor:(NSColor*)color; |
| |
| - (BOOL)performKeyEquivalent:(NSEvent*)event; |
| |
| @end |
| |
| /** |
| * Private interface declaration for FlutterViewController. |
| */ |
| @interface FlutterViewController () <FlutterViewReshapeListener> |
| |
| /** |
| * The tracking area used to generate hover events, if enabled. |
| */ |
| @property(nonatomic) NSTrackingArea* trackingArea; |
| |
| /** |
| * The current state of the mouse and the sent mouse events. |
| */ |
| @property(nonatomic) MouseState mouseState; |
| |
| /** |
| * Event monitor for keyUp events. |
| */ |
| @property(nonatomic) id keyUpMonitor; |
| |
| /** |
| * Pointer to a keyboard manager, a hub that manages how key events are |
| * dispatched to various Flutter key responders, and whether the event is |
| * propagated to the next NSResponder. |
| */ |
| @property(nonatomic, readonly, nonnull) FlutterKeyboardManager* keyboardManager; |
| |
| @property(nonatomic) KeyboardLayoutNotifier keyboardLayoutNotifier; |
| |
| @property(nonatomic) NSData* keyboardLayoutData; |
| |
| /** |
| * Starts running |engine|, including any initial setup. |
| */ |
| - (BOOL)launchEngine; |
| |
| /** |
| * Updates |trackingArea| for the current tracking settings, creating it with |
| * the correct mode if tracking is enabled, or removing it if not. |
| */ |
| - (void)configureTrackingArea; |
| |
| /** |
| * Creates and registers keyboard related components. |
| */ |
| - (void)initializeKeyboard; |
| |
| /** |
| * Calls dispatchMouseEvent:phase: with a phase determined by self.mouseState. |
| * |
| * mouseState.buttons should be updated before calling this method. |
| */ |
| - (void)dispatchMouseEvent:(nonnull NSEvent*)event; |
| |
| /** |
| * Calls dispatchMouseEvent:phase: with a phase determined by event.phase. |
| */ |
| - (void)dispatchGestureEvent:(nonnull NSEvent*)event; |
| |
| /** |
| * Converts |event| to a FlutterPointerEvent with the given phase, and sends it to the engine. |
| */ |
| - (void)dispatchMouseEvent:(nonnull NSEvent*)event phase:(FlutterPointerPhase)phase; |
| |
| /** |
| * Called when the active keyboard input source changes. |
| * |
| * Input sources may be simple keyboard layouts, or more complex input methods involving an IME, |
| * such as Chinese, Japanese, and Korean. |
| */ |
| - (void)onKeyboardLayoutChanged; |
| |
| @end |
| |
| #pragma mark - NSEvent (KeyEquivalentMarker) protocol |
| |
| @interface NSEvent (KeyEquivalentMarker) |
| |
| // Internally marks that the event was received through performKeyEquivalent:. |
| // When text editing is active, keyboard events that have modifier keys pressed |
| // are received through performKeyEquivalent: instead of keyDown:. If such event |
| // is passed to TextInputContext but doesn't result in a text editing action it |
| // needs to be forwarded by FlutterKeyboardManager to the next responder. |
| - (void)markAsKeyEquivalent; |
| |
| // Returns YES if the event is marked as a key equivalent. |
| - (BOOL)isKeyEquivalent; |
| |
| @end |
| |
| @implementation NSEvent (KeyEquivalentMarker) |
| |
| // This field doesn't need a value because only its address is used as a unique identifier. |
| static char markerKey; |
| |
| - (void)markAsKeyEquivalent { |
| objc_setAssociatedObject(self, &markerKey, @true, OBJC_ASSOCIATION_RETAIN); |
| } |
| |
| - (BOOL)isKeyEquivalent { |
| return [objc_getAssociatedObject(self, &markerKey) boolValue] == YES; |
| } |
| |
| @end |
| |
| #pragma mark - Private dependant functions |
| |
| namespace { |
| void OnKeyboardLayoutChanged(CFNotificationCenterRef center, |
| void* observer, |
| CFStringRef name, |
| const void* object, |
| CFDictionaryRef userInfo) { |
| FlutterViewController* controller = (__bridge FlutterViewController*)observer; |
| if (controller != nil) { |
| [controller onKeyboardLayoutChanged]; |
| } |
| } |
| } // namespace |
| |
| #pragma mark - FlutterViewWrapper implementation. |
| |
| @implementation FlutterViewWrapper { |
| FlutterView* _flutterView; |
| FlutterViewController* _controller; |
| } |
| |
| - (instancetype)initWithFlutterView:(FlutterView*)view |
| controller:(FlutterViewController*)controller { |
| self = [super initWithFrame:NSZeroRect]; |
| if (self) { |
| _flutterView = view; |
| _controller = controller; |
| view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; |
| [self addSubview:view]; |
| } |
| return self; |
| } |
| |
| - (void)setBackgroundColor:(NSColor*)color { |
| [_flutterView setBackgroundColor:color]; |
| } |
| |
| - (BOOL)performKeyEquivalent:(NSEvent*)event { |
| if ([_controller isDispatchingKeyEvent:event]) { |
| // When NSWindow is nextResponder, keyboard manager will send to it |
| // unhandled events (through [NSWindow keyDown:]). If event has both |
| // control and cmd modifiers set (i.e. cmd+control+space - emoji picker) |
| // NSWindow will then send this event as performKeyEquivalent: to first |
| // responder, which might be FlutterTextInputPlugin. If that's the case, the |
| // plugin must not handle the event, otherwise the emoji picker would not |
| // work (due to first responder returning YES from performKeyEquivalent:) |
| // and there would be an infinite loop, because FlutterViewController will |
| // send the event back to [keyboardManager handleEvent:]. |
| return NO; |
| } |
| [event markAsKeyEquivalent]; |
| [_flutterView keyDown:event]; |
| return YES; |
| } |
| |
| - (NSArray*)accessibilityChildren { |
| return @[ _flutterView ]; |
| } |
| |
| - (void)mouseDown:(NSEvent*)event { |
| // Work around an AppKit bug where mouseDown/mouseUp are not called on the view controller if the |
| // view is the content view of an NSPopover AND macOS's Reduced Transparency accessibility setting |
| // is enabled. |
| // |
| // This simply calls mouseDown on the next responder in the responder chain as the default |
| // implementation on NSResponder is documented to do. |
| // |
| // See: https://github.com/flutter/flutter/issues/115015 |
| // See: http://www.openradar.me/FB12050037 |
| // See: https://developer.apple.com/documentation/appkit/nsresponder/1524634-mousedown |
| [self.nextResponder mouseDown:event]; |
| } |
| |
| - (void)mouseUp:(NSEvent*)event { |
| // Work around an AppKit bug where mouseDown/mouseUp are not called on the view controller if the |
| // view is the content view of an NSPopover AND macOS's Reduced Transparency accessibility setting |
| // is enabled. |
| // |
| // This simply calls mouseUp on the next responder in the responder chain as the default |
| // implementation on NSResponder is documented to do. |
| // |
| // See: https://github.com/flutter/flutter/issues/115015 |
| // See: http://www.openradar.me/FB12050037 |
| // See: https://developer.apple.com/documentation/appkit/nsresponder/1535349-mouseup |
| [self.nextResponder mouseUp:event]; |
| } |
| |
| @end |
| |
| #pragma mark - FlutterViewController implementation. |
| |
| @implementation FlutterViewController { |
| // The project to run in this controller's engine. |
| FlutterDartProject* _project; |
| |
| std::shared_ptr<flutter::AccessibilityBridgeMac> _bridge; |
| |
| FlutterViewId _id; |
| } |
| |
| @synthesize viewId = _viewId; |
| @dynamic accessibilityBridge; |
| |
| /** |
| * Performs initialization that's common between the different init paths. |
| */ |
| static void CommonInit(FlutterViewController* controller, FlutterEngine* engine) { |
| if (!engine) { |
| engine = [[FlutterEngine alloc] initWithName:@"io.flutter" |
| project:controller->_project |
| allowHeadlessExecution:NO]; |
| } |
| NSCAssert(controller.engine == nil, |
| @"The FlutterViewController is unexpectedly attached to " |
| @"engine %@ before initialization.", |
| controller.engine); |
| [engine addViewController:controller]; |
| NSCAssert(controller.engine != nil, |
| @"The FlutterViewController unexpectedly stays unattached after initialization. " |
| @"In unit tests, this is likely because either the FlutterViewController or " |
| @"the FlutterEngine is mocked. Please subclass these classes instead.", |
| controller.engine, controller.viewId); |
| controller->_mouseTrackingMode = FlutterMouseTrackingModeInKeyWindow; |
| controller->_textInputPlugin = [[FlutterTextInputPlugin alloc] initWithViewController:controller]; |
| [controller initializeKeyboard]; |
| [controller notifySemanticsEnabledChanged]; |
| // macOS fires this message when changing IMEs. |
| CFNotificationCenterRef cfCenter = CFNotificationCenterGetDistributedCenter(); |
| __weak FlutterViewController* weakSelf = controller; |
| CFNotificationCenterAddObserver(cfCenter, (__bridge void*)weakSelf, OnKeyboardLayoutChanged, |
| kTISNotifySelectedKeyboardInputSourceChanged, NULL, |
| CFNotificationSuspensionBehaviorDeliverImmediately); |
| } |
| |
| - (instancetype)initWithCoder:(NSCoder*)coder { |
| self = [super initWithCoder:coder]; |
| NSAssert(self, @"Super init cannot be nil"); |
| |
| CommonInit(self, nil); |
| return self; |
| } |
| |
| - (instancetype)initWithNibName:(NSString*)nibNameOrNil bundle:(NSBundle*)nibBundleOrNil { |
| self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; |
| NSAssert(self, @"Super init cannot be nil"); |
| |
| CommonInit(self, nil); |
| return self; |
| } |
| |
| - (instancetype)initWithProject:(nullable FlutterDartProject*)project { |
| self = [super initWithNibName:nil bundle:nil]; |
| NSAssert(self, @"Super init cannot be nil"); |
| |
| _project = project; |
| CommonInit(self, nil); |
| return self; |
| } |
| |
| - (instancetype)initWithEngine:(nonnull FlutterEngine*)engine |
| nibName:(nullable NSString*)nibName |
| bundle:(nullable NSBundle*)nibBundle { |
| NSAssert(engine != nil, @"Engine is required"); |
| |
| self = [super initWithNibName:nibName bundle:nibBundle]; |
| if (self) { |
| CommonInit(self, engine); |
| } |
| |
| return self; |
| } |
| |
| - (BOOL)isDispatchingKeyEvent:(NSEvent*)event { |
| return [_keyboardManager isDispatchingKeyEvent:event]; |
| } |
| |
| - (void)loadView { |
| FlutterView* flutterView; |
| id<MTLDevice> device = _engine.renderer.device; |
| id<MTLCommandQueue> commandQueue = _engine.renderer.commandQueue; |
| if (!device || !commandQueue) { |
| NSLog(@"Unable to create FlutterView; no MTLDevice or MTLCommandQueue available."); |
| return; |
| } |
| flutterView = [self createFlutterViewWithMTLDevice:device commandQueue:commandQueue]; |
| if (_backgroundColor != nil) { |
| [flutterView setBackgroundColor:_backgroundColor]; |
| } |
| FlutterViewWrapper* wrapperView = [[FlutterViewWrapper alloc] initWithFlutterView:flutterView |
| controller:self]; |
| self.view = wrapperView; |
| _flutterView = flutterView; |
| } |
| |
| - (void)viewDidLoad { |
| [self configureTrackingArea]; |
| [self.view setAllowedTouchTypes:NSTouchTypeMaskIndirect]; |
| [self.view setWantsRestingTouches:YES]; |
| } |
| |
| - (void)viewWillAppear { |
| [super viewWillAppear]; |
| if (!_engine.running) { |
| [self launchEngine]; |
| } |
| [self listenForMetaModifiedKeyUpEvents]; |
| } |
| |
| - (void)viewWillDisappear { |
| // Per Apple's documentation, it is discouraged to call removeMonitor: in dealloc, and it's |
| // recommended to be called earlier in the lifecycle. |
| [NSEvent removeMonitor:_keyUpMonitor]; |
| _keyUpMonitor = nil; |
| } |
| |
| - (void)dealloc { |
| if ([self attached]) { |
| [_engine removeViewController:self]; |
| } |
| CFNotificationCenterRef cfCenter = CFNotificationCenterGetDistributedCenter(); |
| CFNotificationCenterRemoveEveryObserver(cfCenter, (__bridge void*)self); |
| } |
| |
| #pragma mark - Public methods |
| |
| - (void)setMouseTrackingMode:(FlutterMouseTrackingMode)mode { |
| if (_mouseTrackingMode == mode) { |
| return; |
| } |
| _mouseTrackingMode = mode; |
| [self configureTrackingArea]; |
| } |
| |
| - (void)setBackgroundColor:(NSColor*)color { |
| _backgroundColor = color; |
| [_flutterView setBackgroundColor:_backgroundColor]; |
| } |
| |
| - (FlutterViewId)viewId { |
| NSAssert([self attached], @"This view controller is not attched."); |
| return _viewId; |
| } |
| |
| - (void)onPreEngineRestart { |
| [self initializeKeyboard]; |
| } |
| |
| - (void)notifySemanticsEnabledChanged { |
| BOOL mySemanticsEnabled = !!_bridge; |
| BOOL newSemanticsEnabled = _engine.semanticsEnabled; |
| if (newSemanticsEnabled == mySemanticsEnabled) { |
| return; |
| } |
| if (newSemanticsEnabled) { |
| _bridge = [self createAccessibilityBridgeWithEngine:_engine]; |
| } else { |
| // Remove the accessibility children from flutter view before resetting the bridge. |
| _flutterView.accessibilityChildren = nil; |
| _bridge.reset(); |
| } |
| NSAssert(newSemanticsEnabled == !!_bridge, @"Failed to update semantics for the view."); |
| } |
| |
| - (std::weak_ptr<flutter::AccessibilityBridgeMac>)accessibilityBridge { |
| return _bridge; |
| } |
| |
| - (void)attachToEngine:(nonnull FlutterEngine*)engine withId:(FlutterViewId)viewId { |
| NSAssert(_engine == nil, @"Already attached to an engine %@.", _engine); |
| _engine = engine; |
| _viewId = viewId; |
| } |
| |
| - (void)detachFromEngine { |
| NSAssert(_engine != nil, @"Not attached to any engine."); |
| _engine = nil; |
| } |
| |
| - (BOOL)attached { |
| return _engine != nil; |
| } |
| |
| - (void)updateSemantics:(const FlutterSemanticsUpdate2*)update { |
| NSAssert(_engine.semanticsEnabled, @"Semantics must be enabled."); |
| if (!_engine.semanticsEnabled) { |
| return; |
| } |
| for (size_t i = 0; i < update->node_count; i++) { |
| const FlutterSemanticsNode2* node = update->nodes[i]; |
| _bridge->AddFlutterSemanticsNodeUpdate(*node); |
| } |
| |
| for (size_t i = 0; i < update->custom_action_count; i++) { |
| const FlutterSemanticsCustomAction2* action = update->custom_actions[i]; |
| _bridge->AddFlutterSemanticsCustomActionUpdate(*action); |
| } |
| |
| _bridge->CommitUpdates(); |
| |
| // Accessibility tree can only be used when the view is loaded. |
| if (!self.viewLoaded) { |
| return; |
| } |
| // Attaches the accessibility root to the flutter view. |
| auto root = _bridge->GetFlutterPlatformNodeDelegateFromID(0).lock(); |
| if (root) { |
| if ([self.flutterView.accessibilityChildren count] == 0) { |
| NSAccessibilityElement* native_root = root->GetNativeViewAccessible(); |
| self.flutterView.accessibilityChildren = @[ native_root ]; |
| } |
| } else { |
| self.flutterView.accessibilityChildren = nil; |
| } |
| } |
| |
| #pragma mark - Private methods |
| |
| - (BOOL)launchEngine { |
| if (![_engine runWithEntrypoint:nil]) { |
| return NO; |
| } |
| return YES; |
| } |
| |
| // macOS does not call keyUp: on a key while the command key is pressed. This results in a loss |
| // of a key event once the modified key is released. This method registers the |
| // ViewController as a listener for a keyUp event before it's handled by NSApplication, and should |
| // NOT modify the event to avoid any unexpected behavior. |
| - (void)listenForMetaModifiedKeyUpEvents { |
| if (_keyUpMonitor != nil) { |
| // It is possible for [NSViewController viewWillAppear] to be invoked multiple times |
| // in a row. https://github.com/flutter/flutter/issues/105963 |
| return; |
| } |
| FlutterViewController* __weak weakSelf = self; |
| _keyUpMonitor = [NSEvent |
| addLocalMonitorForEventsMatchingMask:NSEventMaskKeyUp |
| handler:^NSEvent*(NSEvent* event) { |
| // Intercept keyUp only for events triggered on the current |
| // view or textInputPlugin. |
| NSResponder* firstResponder = [[event window] firstResponder]; |
| if (weakSelf.viewLoaded && weakSelf.flutterView && |
| (firstResponder == weakSelf.flutterView || |
| firstResponder == weakSelf.textInputPlugin) && |
| ([event modifierFlags] & NSEventModifierFlagCommand) && |
| ([event type] == NSEventTypeKeyUp)) { |
| [weakSelf keyUp:event]; |
| } |
| return event; |
| }]; |
| } |
| |
| - (void)configureTrackingArea { |
| if (!self.viewLoaded) { |
| // The viewDidLoad will call configureTrackingArea again when |
| // the view is actually loaded. |
| return; |
| } |
| if (_mouseTrackingMode != FlutterMouseTrackingModeNone && self.flutterView) { |
| NSTrackingAreaOptions options = NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | |
| NSTrackingInVisibleRect | NSTrackingEnabledDuringMouseDrag; |
| switch (_mouseTrackingMode) { |
| case FlutterMouseTrackingModeInKeyWindow: |
| options |= NSTrackingActiveInKeyWindow; |
| break; |
| case FlutterMouseTrackingModeInActiveApp: |
| options |= NSTrackingActiveInActiveApp; |
| break; |
| case FlutterMouseTrackingModeAlways: |
| options |= NSTrackingActiveAlways; |
| break; |
| default: |
| NSLog(@"Error: Unrecognized mouse tracking mode: %ld", _mouseTrackingMode); |
| return; |
| } |
| _trackingArea = [[NSTrackingArea alloc] initWithRect:NSZeroRect |
| options:options |
| owner:self |
| userInfo:nil]; |
| [self.flutterView addTrackingArea:_trackingArea]; |
| } else if (_trackingArea) { |
| [self.flutterView removeTrackingArea:_trackingArea]; |
| _trackingArea = nil; |
| } |
| } |
| |
| - (void)initializeKeyboard { |
| // TODO(goderbauer): Seperate keyboard/textinput stuff into ViewController specific and Engine |
| // global parts. Move the global parts to FlutterEngine. |
| _keyboardManager = [[FlutterKeyboardManager alloc] initWithViewDelegate:self]; |
| } |
| |
| - (void)dispatchMouseEvent:(nonnull NSEvent*)event { |
| FlutterPointerPhase phase = _mouseState.buttons == 0 |
| ? (_mouseState.flutter_state_is_down ? kUp : kHover) |
| : (_mouseState.flutter_state_is_down ? kMove : kDown); |
| [self dispatchMouseEvent:event phase:phase]; |
| } |
| |
| - (void)dispatchGestureEvent:(nonnull NSEvent*)event { |
| if (event.phase == NSEventPhaseBegan || event.phase == NSEventPhaseMayBegin) { |
| [self dispatchMouseEvent:event phase:kPanZoomStart]; |
| } else if (event.phase == NSEventPhaseChanged) { |
| [self dispatchMouseEvent:event phase:kPanZoomUpdate]; |
| } else if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) { |
| [self dispatchMouseEvent:event phase:kPanZoomEnd]; |
| } else if (event.phase == NSEventPhaseNone && event.momentumPhase == NSEventPhaseNone) { |
| [self dispatchMouseEvent:event phase:kHover]; |
| } else { |
| // Waiting until the first momentum change event is a workaround for an issue where |
| // touchesBegan: is called unexpectedly while in low power mode within the interval between |
| // momentum start and the first momentum change. |
| if (event.momentumPhase == NSEventPhaseChanged) { |
| _mouseState.last_scroll_momentum_changed_time = event.timestamp; |
| } |
| // Skip momentum update events, the framework will generate scroll momentum. |
| NSAssert(event.momentumPhase != NSEventPhaseNone, |
| @"Received gesture event with unexpected phase"); |
| } |
| } |
| |
| - (void)dispatchMouseEvent:(NSEvent*)event phase:(FlutterPointerPhase)phase { |
| NSAssert(self.viewLoaded, @"View must be loaded before it handles the mouse event"); |
| // There are edge cases where the system will deliver enter out of order relative to other |
| // events (e.g., drag out and back in, release, then click; mouseDown: will be called before |
| // mouseEntered:). Discard those events, since the add will already have been synthesized. |
| if (_mouseState.flutter_state_is_added && phase == kAdd) { |
| return; |
| } |
| |
| // Multiple gesture recognizers could be active at once, we can't send multiple kPanZoomStart. |
| // For example: rotation and magnification. |
| if (phase == kPanZoomStart) { |
| bool gestureAlreadyDown = _mouseState.pan_gesture_active || _mouseState.scale_gesture_active || |
| _mouseState.rotate_gesture_active; |
| if (event.type == NSEventTypeScrollWheel) { |
| _mouseState.pan_gesture_active = true; |
| // Ensure scroll inertia cancel event is not sent afterwards. |
| _mouseState.last_scroll_momentum_changed_time = 0; |
| } else if (event.type == NSEventTypeMagnify) { |
| _mouseState.scale_gesture_active = true; |
| } else if (event.type == NSEventTypeRotate) { |
| _mouseState.rotate_gesture_active = true; |
| } |
| if (gestureAlreadyDown) { |
| return; |
| } |
| } |
| if (phase == kPanZoomEnd) { |
| if (event.type == NSEventTypeScrollWheel) { |
| _mouseState.pan_gesture_active = false; |
| } else if (event.type == NSEventTypeMagnify) { |
| _mouseState.scale_gesture_active = false; |
| } else if (event.type == NSEventTypeRotate) { |
| _mouseState.rotate_gesture_active = false; |
| } |
| if (_mouseState.pan_gesture_active || _mouseState.scale_gesture_active || |
| _mouseState.rotate_gesture_active) { |
| return; |
| } |
| } |
| |
| // If a pointer added event hasn't been sent, synthesize one using this event for the basic |
| // information. |
| if (!_mouseState.flutter_state_is_added && phase != kAdd) { |
| // Only the values extracted for use in flutterEvent below matter, the rest are dummy values. |
| NSEvent* addEvent = [NSEvent enterExitEventWithType:NSEventTypeMouseEntered |
| location:event.locationInWindow |
| modifierFlags:0 |
| timestamp:event.timestamp |
| windowNumber:event.windowNumber |
| context:nil |
| eventNumber:0 |
| trackingNumber:0 |
| userData:NULL]; |
| [self dispatchMouseEvent:addEvent phase:kAdd]; |
| } |
| |
| NSPoint locationInView = [self.flutterView convertPoint:event.locationInWindow fromView:nil]; |
| NSPoint locationInBackingCoordinates = [self.flutterView convertPointToBacking:locationInView]; |
| int32_t device = kMousePointerDeviceId; |
| FlutterPointerDeviceKind deviceKind = kFlutterPointerDeviceKindMouse; |
| if (phase == kPanZoomStart || phase == kPanZoomUpdate || phase == kPanZoomEnd) { |
| device = kPointerPanZoomDeviceId; |
| deviceKind = kFlutterPointerDeviceKindTrackpad; |
| } |
| FlutterPointerEvent flutterEvent = { |
| .struct_size = sizeof(flutterEvent), |
| .phase = phase, |
| .timestamp = static_cast<size_t>(event.timestamp * USEC_PER_SEC), |
| .x = locationInBackingCoordinates.x, |
| .y = -locationInBackingCoordinates.y, // convertPointToBacking makes this negative. |
| .device = device, |
| .device_kind = deviceKind, |
| // If a click triggered a synthesized kAdd, don't pass the buttons in that event. |
| .buttons = phase == kAdd ? 0 : _mouseState.buttons, |
| }; |
| |
| if (phase == kPanZoomUpdate) { |
| if (event.type == NSEventTypeScrollWheel) { |
| _mouseState.delta_x += event.scrollingDeltaX * self.flutterView.layer.contentsScale; |
| _mouseState.delta_y += event.scrollingDeltaY * self.flutterView.layer.contentsScale; |
| } else if (event.type == NSEventTypeMagnify) { |
| _mouseState.scale += event.magnification; |
| } else if (event.type == NSEventTypeRotate) { |
| _mouseState.rotation += event.rotation * (-M_PI / 180.0); |
| } |
| flutterEvent.pan_x = _mouseState.delta_x; |
| flutterEvent.pan_y = _mouseState.delta_y; |
| // Scale value needs to be normalized to range 0->infinity. |
| flutterEvent.scale = pow(2.0, _mouseState.scale); |
| flutterEvent.rotation = _mouseState.rotation; |
| } else if (phase == kPanZoomEnd) { |
| _mouseState.GestureReset(); |
| } else if (phase != kPanZoomStart && event.type == NSEventTypeScrollWheel) { |
| flutterEvent.signal_kind = kFlutterPointerSignalKindScroll; |
| |
| double pixelsPerLine = 1.0; |
| if (!event.hasPreciseScrollingDeltas) { |
| // The scrollingDelta needs to be multiplied by the line height. |
| // CGEventSourceGetPixelsPerLine() will return 10, which will result in |
| // scrolling that is noticeably slower than in other applications. |
| // Using 40.0 as the multiplier to match Chromium. |
| // See https://source.chromium.org/chromium/chromium/src/+/main:ui/events/cocoa/events_mac.mm |
| pixelsPerLine = 40.0; |
| } |
| double scaleFactor = self.flutterView.layer.contentsScale; |
| // When mouse input is received while shift is pressed (regardless of |
| // any other pressed keys), Mac automatically flips the axis. Other |
| // platforms do not do this, so we flip it back to normalize the input |
| // received by the framework. The keyboard+mouse-scroll mechanism is exposed |
| // in the ScrollBehavior of the framework so developers can customize the |
| // behavior. |
| // At time of change, Apple does not expose any other type of API or signal |
| // that the X/Y axes have been flipped. |
| double scaledDeltaX = -event.scrollingDeltaX * pixelsPerLine * scaleFactor; |
| double scaledDeltaY = -event.scrollingDeltaY * pixelsPerLine * scaleFactor; |
| if (event.modifierFlags & NSShiftKeyMask) { |
| flutterEvent.scroll_delta_x = scaledDeltaY; |
| flutterEvent.scroll_delta_y = scaledDeltaX; |
| } else { |
| flutterEvent.scroll_delta_x = scaledDeltaX; |
| flutterEvent.scroll_delta_y = scaledDeltaY; |
| } |
| } |
| |
| [_keyboardManager syncModifiersIfNeeded:event.modifierFlags timestamp:event.timestamp]; |
| [_engine sendPointerEvent:flutterEvent]; |
| |
| // Update tracking of state as reported to Flutter. |
| if (phase == kDown) { |
| _mouseState.flutter_state_is_down = true; |
| } else if (phase == kUp) { |
| _mouseState.flutter_state_is_down = false; |
| if (_mouseState.has_pending_exit) { |
| [self dispatchMouseEvent:event phase:kRemove]; |
| _mouseState.has_pending_exit = false; |
| } |
| } else if (phase == kAdd) { |
| _mouseState.flutter_state_is_added = true; |
| } else if (phase == kRemove) { |
| _mouseState.Reset(); |
| } |
| } |
| |
| - (void)onAccessibilityStatusChanged:(BOOL)enabled { |
| if (!enabled && self.viewLoaded && [_textInputPlugin isFirstResponder]) { |
| // Normally TextInputPlugin, when editing, is child of FlutterViewWrapper. |
| // When accessiblity is enabled the TextInputPlugin gets added as an indirect |
| // child to FlutterTextField. When disabling the plugin needs to be reparented |
| // back. |
| [self.view addSubview:_textInputPlugin]; |
| } |
| } |
| |
| - (std::shared_ptr<flutter::AccessibilityBridgeMac>)createAccessibilityBridgeWithEngine: |
| (nonnull FlutterEngine*)engine { |
| return std::make_shared<flutter::AccessibilityBridgeMac>(engine, self); |
| } |
| |
| - (nonnull FlutterView*)createFlutterViewWithMTLDevice:(id<MTLDevice>)device |
| commandQueue:(id<MTLCommandQueue>)commandQueue { |
| return [[FlutterView alloc] initWithMTLDevice:device |
| commandQueue:commandQueue |
| reshapeListener:self]; |
| } |
| |
| - (void)onKeyboardLayoutChanged { |
| _keyboardLayoutData = nil; |
| if (_keyboardLayoutNotifier != nil) { |
| _keyboardLayoutNotifier(); |
| } |
| } |
| |
| - (NSString*)lookupKeyForAsset:(NSString*)asset { |
| return [FlutterDartProject lookupKeyForAsset:asset]; |
| } |
| |
| - (NSString*)lookupKeyForAsset:(NSString*)asset fromPackage:(NSString*)package { |
| return [FlutterDartProject lookupKeyForAsset:asset fromPackage:package]; |
| } |
| |
| #pragma mark - FlutterViewReshapeListener |
| |
| /** |
| * Responds to view reshape by notifying the engine of the change in dimensions. |
| */ |
| - (void)viewDidReshape:(NSView*)view { |
| [_engine updateWindowMetricsForViewController:self]; |
| } |
| |
| #pragma mark - FlutterPluginRegistry |
| |
| - (id<FlutterPluginRegistrar>)registrarForPlugin:(NSString*)pluginName { |
| return [_engine registrarForPlugin:pluginName]; |
| } |
| |
| #pragma mark - FlutterKeyboardViewDelegate |
| |
| - (void)sendKeyEvent:(const FlutterKeyEvent&)event |
| callback:(nullable FlutterKeyEventCallback)callback |
| userData:(nullable void*)userData { |
| [_engine sendKeyEvent:event callback:callback userData:userData]; |
| } |
| |
| - (id<FlutterBinaryMessenger>)getBinaryMessenger { |
| return _engine.binaryMessenger; |
| } |
| |
| - (BOOL)onTextInputKeyEvent:(nonnull NSEvent*)event { |
| return [_textInputPlugin handleKeyEvent:event]; |
| } |
| |
| - (void)subscribeToKeyboardLayoutChange:(nullable KeyboardLayoutNotifier)callback { |
| _keyboardLayoutNotifier = callback; |
| } |
| |
| - (LayoutClue)lookUpLayoutForKeyCode:(uint16_t)keyCode shift:(BOOL)shift { |
| if (_keyboardLayoutData == nil) { |
| _keyboardLayoutData = currentKeyboardLayoutData(); |
| } |
| const UCKeyboardLayout* layout = reinterpret_cast<const UCKeyboardLayout*>( |
| CFDataGetBytePtr((__bridge CFDataRef)_keyboardLayoutData)); |
| |
| UInt32 deadKeyState = 0; |
| UniCharCount stringLength = 0; |
| UniChar resultChar; |
| |
| UInt32 modifierState = ((shift ? shiftKey : 0) >> 8) & 0xFF; |
| UInt32 keyboardType = LMGetKbdLast(); |
| |
| bool isDeadKey = false; |
| OSStatus status = |
| UCKeyTranslate(layout, keyCode, kUCKeyActionDown, modifierState, keyboardType, |
| kUCKeyTranslateNoDeadKeysBit, &deadKeyState, 1, &stringLength, &resultChar); |
| // For dead keys, press the same key again to get the printable representation of the key. |
| if (status == noErr && stringLength == 0 && deadKeyState != 0) { |
| isDeadKey = true; |
| status = |
| UCKeyTranslate(layout, keyCode, kUCKeyActionDown, modifierState, keyboardType, |
| kUCKeyTranslateNoDeadKeysBit, &deadKeyState, 1, &stringLength, &resultChar); |
| } |
| |
| if (status == noErr && stringLength == 1 && !std::iscntrl(resultChar)) { |
| return LayoutClue{resultChar, isDeadKey}; |
| } |
| return LayoutClue{0, false}; |
| } |
| |
| #pragma mark - NSResponder |
| |
| - (BOOL)acceptsFirstResponder { |
| return YES; |
| } |
| |
| - (void)keyDown:(NSEvent*)event { |
| [_keyboardManager handleEvent:event]; |
| } |
| |
| - (void)keyUp:(NSEvent*)event { |
| [_keyboardManager handleEvent:event]; |
| } |
| |
| - (void)flagsChanged:(NSEvent*)event { |
| [_keyboardManager handleEvent:event]; |
| } |
| |
| - (void)mouseEntered:(NSEvent*)event { |
| if (_mouseState.has_pending_exit) { |
| _mouseState.has_pending_exit = false; |
| } else { |
| [self dispatchMouseEvent:event phase:kAdd]; |
| } |
| } |
| |
| - (void)mouseExited:(NSEvent*)event { |
| if (_mouseState.buttons != 0) { |
| _mouseState.has_pending_exit = true; |
| return; |
| } |
| [self dispatchMouseEvent:event phase:kRemove]; |
| } |
| |
| - (void)mouseDown:(NSEvent*)event { |
| _mouseState.buttons |= kFlutterPointerButtonMousePrimary; |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)mouseUp:(NSEvent*)event { |
| _mouseState.buttons &= ~static_cast<uint64_t>(kFlutterPointerButtonMousePrimary); |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)mouseDragged:(NSEvent*)event { |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)rightMouseDown:(NSEvent*)event { |
| _mouseState.buttons |= kFlutterPointerButtonMouseSecondary; |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)rightMouseUp:(NSEvent*)event { |
| _mouseState.buttons &= ~static_cast<uint64_t>(kFlutterPointerButtonMouseSecondary); |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)rightMouseDragged:(NSEvent*)event { |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)otherMouseDown:(NSEvent*)event { |
| _mouseState.buttons |= (1 << event.buttonNumber); |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)otherMouseUp:(NSEvent*)event { |
| _mouseState.buttons &= ~static_cast<uint64_t>(1 << event.buttonNumber); |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)otherMouseDragged:(NSEvent*)event { |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)mouseMoved:(NSEvent*)event { |
| [self dispatchMouseEvent:event]; |
| } |
| |
| - (void)scrollWheel:(NSEvent*)event { |
| [self dispatchGestureEvent:event]; |
| } |
| |
| - (void)magnifyWithEvent:(NSEvent*)event { |
| [self dispatchGestureEvent:event]; |
| } |
| |
| - (void)rotateWithEvent:(NSEvent*)event { |
| [self dispatchGestureEvent:event]; |
| } |
| |
| - (void)swipeWithEvent:(NSEvent*)event { |
| // Not needed, it's handled by scrollWheel. |
| } |
| |
| - (void)touchesBeganWithEvent:(NSEvent*)event { |
| NSTouch* touch = event.allTouches.anyObject; |
| if (touch != nil) { |
| if ((event.timestamp - _mouseState.last_scroll_momentum_changed_time) < |
| kTrackpadTouchInertiaCancelWindowMs) { |
| // The trackpad has been touched following a scroll momentum event. |
| // A scroll inertia cancel message should be sent to the framework. |
| NSPoint locationInView = [self.flutterView convertPoint:event.locationInWindow fromView:nil]; |
| NSPoint locationInBackingCoordinates = |
| [self.flutterView convertPointToBacking:locationInView]; |
| FlutterPointerEvent flutterEvent = { |
| .struct_size = sizeof(flutterEvent), |
| .timestamp = static_cast<size_t>(event.timestamp * USEC_PER_SEC), |
| .x = locationInBackingCoordinates.x, |
| .y = -locationInBackingCoordinates.y, // convertPointToBacking makes this negative. |
| .device = kPointerPanZoomDeviceId, |
| .signal_kind = kFlutterPointerSignalKindScrollInertiaCancel, |
| .device_kind = kFlutterPointerDeviceKindTrackpad, |
| }; |
| |
| [_engine sendPointerEvent:flutterEvent]; |
| // Ensure no further scroll inertia cancel event will be sent. |
| _mouseState.last_scroll_momentum_changed_time = 0; |
| } |
| } |
| } |
| |
| @end |