diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Terminal.xib index 4078fa2c6..65b03b6eb 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Terminal.xib @@ -1,8 +1,8 @@ - + - + diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d20e1e82..309a97b09 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -82,6 +82,7 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) + .backport.pointerVisibility(surfaceView.pointerVisible ? .visible : .hidden) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c3ae03138..8d12c6193 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -38,6 +38,9 @@ extension Ghostty { // structure because I'm lazy. @Published var surfaceSize: ghostty_surface_size_s? = nil + // Whether the pointer should be visible or not + @Published var pointerVisible: Bool = true + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -97,11 +100,9 @@ extension Ghostty { private(set) var surface: ghostty_surface_t? private var markedText: NSMutableAttributedString - private var mouseEntered: Bool = false private(set) var focused: Bool = true private var prevPressureStage: Int = 0 private var cursor: NSCursor = .iBeam - private var cursorVisible: CursorVisibility = .visible private var appearanceObserver: NSKeyValueObservation? = nil // This is set to non-null during keyDown to accumulate insertText contents @@ -114,15 +115,6 @@ extension Ghostty { // so we'll use that to tell ghostty to refresh. override var wantsUpdateLayer: Bool { return true } - // State machine for mouse cursor visibility because every call to - // NSCursor.hide/unhide must be balanced. - enum CursorVisibility { - case visible - case hidden - case pendingVisible - case pendingHidden - } - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { self.markedText = NSMutableAttributedString() self.uuid = uuid ?? .init() @@ -194,13 +186,6 @@ extension Ghostty { trackingAreas.forEach { removeTrackingArea($0) } - // mouseExited is not called by AppKit one last time when the view - // closes so we do it manually to ensure our NSCursor state remains - // accurate. - if (mouseEntered) { - mouseExited(with: NSEvent()) - } - // Remove ourselves from secure input if we have to SecureInput.shared.removeScoped(ObjectIdentifier(self)) @@ -242,8 +227,6 @@ extension Ghostty { } func sizeDidChange(_ size: CGSize) { - guard let surface = self.surface else { return } - // Ghostty wants to know the actual framebuffer size... It is very important // here that we use "size" and NOT the view frame. If we're in the middle of // an animation (i.e. a fullscreen animation), the frame will not yet be updated. @@ -332,44 +315,10 @@ extension Ghostty { // We ignore unknown shapes. return } - - // Set our cursor immediately if our mouse is over our window - if (mouseEntered) { cursorUpdate(with: NSEvent()) } - if let window = self.window { - window.invalidateCursorRects(for: self) - } } func setCursorVisibility(_ visible: Bool) { - switch (cursorVisible) { - case .visible: - // If we want to be visible, do nothing. If we want to be hidden - // enter the pending state. - if (visible) { return } - cursorVisible = .pendingHidden - - case .hidden: - // If we want to be hidden, do nothing. If we want to be visible - // enter the pending state. - if (!visible) { return } - cursorVisible = .pendingVisible - - case .pendingVisible: - // If we want to be visible, do nothing because we're already pending. - // If we want to be hidden, we're already hidden so reset state. - if (visible) { return } - cursorVisible = .hidden - - case .pendingHidden: - // If we want to be hidden, do nothing because we're pending that switch. - // If we want to be visible, we're already visible so reset state. - if (!visible) { return } - cursorVisible = .visible - } - - if (mouseEntered) { - cursorUpdate(with: NSEvent()) - } + pointerVisible = visible } // MARK: - Notifications @@ -419,7 +368,6 @@ extension Ghostty { addTrackingArea(NSTrackingArea( rect: frame, options: [ - .mouseEnteredAndExited, .mouseMoved, // Only send mouse events that happen in our visible (not obscured) rect @@ -433,11 +381,6 @@ extension Ghostty { userInfo: nil)) } - override func resetCursorRects() { - discardCursorRects() - addCursorRect(frame, cursor: self.cursor) - } - override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() @@ -578,40 +521,6 @@ extension Ghostty { self.mouseMoved(with: event) } - override func mouseEntered(with event: NSEvent) { - // For reasons unknown (Cocoaaaaaaaaa), mouseEntered is called - // multiple times in an unbalanced way with mouseExited when a new - // tab is created. In this scenario, we only want to process our - // callback once since this is stateful and we expect balancing. - if (mouseEntered) { return } - - mouseEntered = true - - // Update our cursor when we enter so we fully process our - // cursorVisible state. - cursorUpdate(with: NSEvent()) - } - - override func mouseExited(with event: NSEvent) { - // See mouseEntered - if (!mouseEntered) { return } - - mouseEntered = false - - // If the mouse is currently hidden, we want to show it when we exit - // this view. We go through the cursorVisible dance so that only - // cursorUpdate manages cursor state. - if (cursorVisible == .hidden) { - cursorVisible = .pendingVisible - cursorUpdate(with: NSEvent()) - assert(cursorVisible == .visible) - - // We set the state to pending hidden again for the next time - // we enter. - cursorVisible = .pendingHidden - } - } - override func scrollWheel(with event: NSEvent) { guard let surface = self.surface else { return } @@ -675,24 +584,6 @@ extension Ghostty { quickLook(with: event) } - override func cursorUpdate(with event: NSEvent) { - switch (cursorVisible) { - case .visible, .hidden: - // Do nothing, stable state - break - - case .pendingHidden: - NSCursor.hide() - cursorVisible = .hidden - - case .pendingVisible: - NSCursor.unhide() - cursorVisible = .visible - } - - cursor.set() - } - override func keyDown(with event: NSEvent) { guard let surface = self.surface else { self.interpretKeyEvents([event]) diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 000251e49..2d4eef5ca 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -25,6 +25,14 @@ extension Backport where Content: Scene { } extension Backport where Content: View { + func pointerVisibility(_ v: BackportVisibility) -> some View { + if #available(macOS 15, *) { + return content.pointerVisibility(v.official) + } else { + return content + } + } + func pointerStyle(_ style: BackportPointerStyle) -> some View { if #available(macOS 15, *) { return content.pointerStyle(style.official) @@ -32,19 +40,52 @@ extension Backport where Content: View { return content } } +} - enum BackportPointerStyle { - case grabIdle - case grabActive - case link +enum BackportVisibility { + case automatic + case visible + case hidden - @available(macOS 15, *) - var official: PointerStyle { - switch self { - case .grabIdle: return .grabIdle - case .grabActive: return .grabActive - case .link: return .link - } + @available(macOS 15, *) + var official: Visibility { + switch self { + case .automatic: return .automatic + case .visible: return .visible + case .hidden: return .hidden + } + } +} + +enum BackportPointerStyle { + case `default` + case grabIdle + case grabActive + case horizontalText + case verticalText + case link + case resizeLeft + case resizeRight + case resizeUp + case resizeDown + case resizeUpDown + case resizeLeftRight + + @available(macOS 15, *) + var official: PointerStyle { + switch self { + case .default: return .default + case .grabIdle: return .grabIdle + case .grabActive: return .grabActive + case .horizontalText: return .horizontalText + case .verticalText: return .verticalText + case .link: return .link + case .resizeLeft: return .frameResize(position: .trailing, directions: [.inward]) + case .resizeRight: return .frameResize(position: .leading, directions: [.inward]) + case .resizeUp: return .frameResize(position: .bottom, directions: [.inward]) + case .resizeDown: return .frameResize(position: .top, directions: [.inward]) + case .resizeUpDown: return .frameResize(position: .top) + case .resizeLeftRight: return .frameResize(position: .trailing) } } }