diff --git a/include/ghostty.h b/include/ghostty.h index 225265633..50ef2bcb0 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -302,6 +302,7 @@ typedef void (*ghostty_runtime_wakeup_cb)(void *); typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void *); typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef void (*ghostty_runtime_set_mouse_shape_cb)(void *, ghostty_mouse_shape_e); +typedef void (*ghostty_runtime_set_mouse_visibility_cb)(void *, bool); typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty_clipboard_e); typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e, ghostty_surface_config_s); @@ -320,6 +321,7 @@ typedef struct { ghostty_runtime_reload_config_cb reload_config_cb; ghostty_runtime_set_title_cb set_title_cb; ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb; + ghostty_runtime_set_mouse_visibility_cb set_mouse_visibility_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_new_split_cb new_split_cb; diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 2743ef01f..fda9bf9b5 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -73,6 +73,7 @@ extension Ghostty { reload_config_cb: { userdata in AppState.reloadConfig(userdata) }, set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) }, + set_mouse_visibility_cb: { userdata, visible in AppState.setMouseVisibility(userdata, visible: visible) }, read_clipboard_cb: { userdata, loc in AppState.readClipboard(userdata, location: loc) }, write_clipboard_cb: { userdata, str, loc in AppState.writeClipboard(userdata, string: str, location: loc) }, new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) }, @@ -338,6 +339,11 @@ extension Ghostty { let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() surfaceView.setCursorShape(shape) } + + static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) { + let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + surfaceView.setCursorVisibility(visible) + } static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { guard let surface = self.surfaceUserdata(from: userdata) else { return } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 94c4700f4..93abf356e 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,7 +197,9 @@ extension Ghostty { private var markedText: NSMutableAttributedString private var mouseEntered: Bool = false - private var cursor: NSCursor = .arrow + private var focused: Bool = true + private var cursor: NSCursor = .iBeam + private var cursorVisible: CursorVisibility = .visible // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -206,6 +208,15 @@ 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: ghostty_surface_config_s?) { self.markedText = NSMutableAttributedString() @@ -236,7 +247,14 @@ extension Ghostty { deinit { 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()) + } + guard let surface = self.surface else { return } ghostty_surface_free(surface) } @@ -320,8 +338,41 @@ extension Ghostty { } // 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) { - cursor.set() + cursorUpdate(with: NSEvent()) } } @@ -338,13 +389,22 @@ extension Ghostty { // If we have a blur, set the blur ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) } + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if (result) { focused = true } + return result + } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() // We sometimes call this manually (see SplitView) as a way to force us to // yield our focus state. - if (result) { focusDidChange(false) } + if (result) { + focusDidChange(false) + focused = false + } return result } @@ -372,7 +432,7 @@ extension Ghostty { override func resetCursorRects() { discardCursorRects() - addCursorRect(frame, cursor: .iBeam) + addCursorRect(frame, cursor: self.cursor) } override func viewDidChangeBackingProperties() { @@ -419,7 +479,7 @@ extension Ghostty { override func mouseMoved(with event: NSEvent) { guard let surface = self.surface else { return } - + // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) @@ -432,10 +492,20 @@ extension Ghostty { override func mouseEntered(with event: NSEvent) { mouseEntered = true + + // If our cursor is hidden, we hide it on upon entry and we unhide + // it on exit (mouseExited) + if (cursorVisible == .hidden) { + NSCursor.hide() + } } override func mouseExited(with event: NSEvent) { mouseEntered = false + + if (cursorVisible == .hidden) { + NSCursor.unhide() + } } override func scrollWheel(with event: NSEvent) { @@ -482,6 +552,22 @@ extension Ghostty { } override func cursorUpdate(with event: NSEvent) { + if (focused) { + switch (cursorVisible) { + case .visible, .hidden: + // Do nothing, stable state + break + + case .pendingHidden: + NSCursor.hide() + cursorVisible = .hidden + + case .pendingVisible: + NSCursor.unhide() + cursorVisible = .visible + } + } + cursor.set() } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 54746ec50..4ee4b5770 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -52,6 +52,9 @@ pub const App = struct { /// Called to set the cursor shape. set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void, + /// Called to set the mouse visibility. + set_mouse_visibility: *const fn (SurfaceUD, bool) callconv(.C) void, + /// Read the clipboard value. The return value must be preserved /// by the host until the next call. If there is no valid clipboard /// value then this should return null. @@ -321,6 +324,14 @@ pub const Surface = struct { ); } + /// Set the visibility of the mouse cursor. + pub fn setMouseVisibility(self: *Surface, visible: bool) void { + self.app.opts.set_mouse_visibility( + self.opts.userdata, + visible, + ); + } + pub fn supportsClipboard( self: *const Surface, clipboard_type: apprt.Clipboard,