diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 111c9aeef..ee4b52779 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -59,6 +59,8 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; + A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; + A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -128,6 +130,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; + A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -212,6 +215,7 @@ children = ( A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, + A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, @@ -527,6 +531,7 @@ A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, + A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, @@ -562,6 +567,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */, A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 8a4f24678..1a7272e16 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -34,6 +34,9 @@ struct ClipboardConfirmationView: View { /// Optional delegate to get results. If this is nil, then this view will never close on its own. weak var delegate: ClipboardConfirmationViewDelegate? = nil + /// Used to track if we should rehide on disappear + @State private var cursorHiddenCount: UInt = 0 + var body: some View { VStack { HStack { @@ -65,6 +68,25 @@ struct ClipboardConfirmationView: View { } .padding(.bottom) } + .onAppear { + // I can't find a better way to handle this. There is no API to detect + // if the cursor is hidden and OTHER THINGS do unhide the cursor. So we + // try to unhide it completely here and hope for the best. Issue #1516. + cursorHiddenCount = Cursor.unhideCompletely() + + // If we didn't unhide anything, we just send an unhide to be safe. + // I don't think the count can go negative on NSCursor so this handles + // scenarios cursor is hidden outside of our own NSCursor usage. + if (cursorHiddenCount == 0) { + _ = Cursor.unhide() + } + } + .onDisappear { + // Rehide if we unhid + for _ in 0.. - + - + diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d20e1e82..5eb277ba1 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -59,6 +59,23 @@ extension Ghostty { @EnvironmentObject private var ghostty: Ghostty.App + #if canImport(AppKit) + // The visibility state of the mouse pointer + private var pointerVisibility: BackportVisibility { + // If our window or surface loses focus we always bring it back + if (!windowFocus || !surfaceFocus) { + return .visible + } + + // If we have window focus then it depends on surface state + if (surfaceView.pointerVisible) { + return .visible + } else { + return .hidden + } + } + #endif + var body: some View { let center = NotificationCenter.default @@ -82,6 +99,8 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) + .backport.pointerVisibility(pointerVisibility) + .backport.pointerStyle(surfaceView.pointerStyle) .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..90d259d22 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -38,6 +38,10 @@ extension Ghostty { // structure because I'm lazy. @Published var surfaceSize: ghostty_surface_size_s? = nil + // Whether the pointer should be visible or not + @Published private(set) var pointerVisible: Bool = true + @Published private(set) var pointerStyle: BackportPointerStyle = .default + // 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 +101,8 @@ 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. @@ -284,92 +267,58 @@ extension Ghostty { func setCursorShape(_ shape: ghostty_mouse_shape_e) { switch (shape) { case GHOSTTY_MOUSE_SHAPE_DEFAULT: - cursor = .arrow - - case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: - cursor = .contextualMenu + pointerStyle = .default case GHOSTTY_MOUSE_SHAPE_TEXT: - cursor = .iBeam - - case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: - cursor = .crosshair + pointerStyle = .horizontalText case GHOSTTY_MOUSE_SHAPE_GRAB: - cursor = .openHand + pointerStyle = .grabIdle case GHOSTTY_MOUSE_SHAPE_GRABBING: - cursor = .closedHand + pointerStyle = .grabActive case GHOSTTY_MOUSE_SHAPE_POINTER: - cursor = .pointingHand + pointerStyle = .link case GHOSTTY_MOUSE_SHAPE_W_RESIZE: - cursor = .resizeLeft + pointerStyle = .resizeLeft case GHOSTTY_MOUSE_SHAPE_E_RESIZE: - cursor = .resizeRight + pointerStyle = .resizeRight case GHOSTTY_MOUSE_SHAPE_N_RESIZE: - cursor = .resizeUp + pointerStyle = .resizeUp case GHOSTTY_MOUSE_SHAPE_S_RESIZE: - cursor = .resizeDown + pointerStyle = .resizeDown case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: - cursor = .resizeUpDown + pointerStyle = .resizeUpDown case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: - cursor = .resizeLeftRight + pointerStyle = .resizeLeftRight case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: - cursor = .iBeamCursorForVerticalLayout + pointerStyle = .default + // These are not yet supported. We should support them by constructing a + // PointerStyle from an NSCursor. + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: + fallthrough + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: + fallthrough case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: - cursor = .operationNotAllowed + pointerStyle = .default default: // 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..8c3c10502 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -25,26 +25,77 @@ extension Backport where Content: Scene { } extension Backport where Content: View { - func pointerStyle(_ style: BackportPointerStyle) -> some View { + func pointerVisibility(_ v: BackportVisibility) -> some View { + #if canImport(AppKit) if #available(macOS 15, *) { - return content.pointerStyle(style.official) + return content.pointerVisibility(v.official) } else { return content } + #else + return content + #endif } - enum BackportPointerStyle { - case grabIdle - case grabActive - case link + func pointerStyle(_ style: BackportPointerStyle?) -> some View { + #if canImport(AppKit) + if #available(macOS 15, *) { + return content.pointerStyle(style?.official) + } else { + return content + } + #else + return content + #endif + } +} - @available(macOS 15, *) - var official: PointerStyle { - switch self { - case .grabIdle: return .grabIdle - case .grabActive: return .grabActive - case .link: return .link - } +enum BackportVisibility { + case automatic + case visible + case hidden + + @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 + + #if canImport(AppKit) + @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) + } + } + #endif +} diff --git a/macos/Sources/Helpers/Cursor.swift b/macos/Sources/Helpers/Cursor.swift new file mode 100644 index 000000000..fe4a148b5 --- /dev/null +++ b/macos/Sources/Helpers/Cursor.swift @@ -0,0 +1,38 @@ +import Cocoa + +/// This helps manage the stateful nature of NSCursor hiding and unhiding. +class Cursor { + private static var counter: UInt = 0 + + static var isVisible: Bool { + counter == 0 + } + + static func hide() { + counter += 1 + NSCursor.hide() + } + + /// Unhide the cursor. Returns true if the cursor was previously hidden. + static func unhide() -> Bool { + // Its always safe to call unhide when the counter is zero because it + // won't go negative. + NSCursor.unhide() + + if (counter > 0) { + counter -= 1 + return true + } + + return false + } + + static func unhideCompletely() -> UInt { + let counter = self.counter + for _ in 0.. some View { + if #available(macOS 15.0, *) { + return self.pointerStyle(.image( + Image(nsImage: cursor.image), + hotSpot: .init(x: cursor.hotSpot.x, y: cursor.hotSpot.y) + )) + } else { + return self + } + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 35ca981f8..9bc518326 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -429,6 +429,8 @@ palette: Palette = .{}, /// Hide the mouse immediately when typing. The mouse becomes visible again when /// the mouse is used. The mouse is only hidden if the mouse cursor is over the /// active terminal surface. +/// +/// macOS: This feature requires macOS 15.0 (Sequoia) or later. @"mouse-hide-while-typing": bool = false, /// Determines whether running programs can detect the shift key pressed with a