Merge pull request #2281 from ghostty-org/pointer

macOS: Use macOS 15 APIs to stabilize cursor hide while typing
This commit is contained in:
Mitchell Hashimoto
2024-09-21 14:40:55 -07:00
committed by GitHub
10 changed files with 205 additions and 148 deletions

View File

@ -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 = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = "<group>"; };
A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = "<group>"; };
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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..<cursorHiddenCount {
Cursor.hide()
}
}
}
private func onCancel() {

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22505"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>

View File

@ -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 }

View File

@ -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])

View File

@ -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
}

View File

@ -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..<counter {
assert(unhide())
}
assert(self.counter == 0)
return counter
}
}

View File

@ -44,15 +44,30 @@ extension SplitView {
}
}
private var pointerStyle: BackportPointerStyle {
return switch (direction) {
case .horizontal: .resizeLeftRight
case .vertical: .resizeUpDown
}
}
var body: some View {
ZStack {
Color.clear
.frame(width: invisibleWidth, height: invisibleHeight)
.contentShape(Rectangle()) // Makes it hit testable for pointerStyle
Rectangle()
.fill(color)
.frame(width: visibleWidth, height: visibleHeight)
}
.backport.pointerStyle(pointerStyle)
.onHover { isHovered in
// macOS 15+ we use the pointerStyle helper which is much less
// error-prone versus manual NSCursor push/pop
if #available(macOS 15, *) {
return
}
if (isHovered) {
switch (direction) {
case .horizontal:

View File

@ -16,3 +16,16 @@ extension View {
)
}
}
extension View {
func pointerStyleFromCursor(_ cursor: NSCursor) -> 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
}
}
}

View File

@ -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