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/Surface.zig b/src/Surface.zig index eda00483f..d4b8bdcc4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -122,6 +122,9 @@ const Mouse = struct { /// Pending scroll amounts for high-precision scrolls pending_scroll_x: f64 = 0, pending_scroll_y: f64 = 0, + + /// True if the mouse is hidden + hidden: bool = false, }; /// The configuration that a surface has, this is copied from the main @@ -138,6 +141,7 @@ const DerivedConfig = struct { copy_on_select: configpkg.CopyOnSelect, confirm_close_surface: bool, mouse_interval: u64, + mouse_hide_while_typing: bool, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: configpkg.OptionAsAlt, window_padding_x: u32, @@ -157,6 +161,7 @@ const DerivedConfig = struct { .copy_on_select = config.@"copy-on-select", .confirm_close_surface = config.@"confirm-close-surface", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms + .mouse_hide_while_typing = config.@"mouse-hide-while-typing", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", .macos_option_as_alt = config.@"macos-option-as-alt", .window_padding_x = config.@"window-padding-x", @@ -566,6 +571,11 @@ fn changeConfig(self: *Surface, config: *const configpkg.Config) !void { self.config.deinit(); self.config = derived; + // If our mouse is hidden but we disabled mouse hiding, then show it again. + if (!self.config.mouse_hide_while_typing and self.mouse.hidden) { + self.showMouse(); + } + // We need to store our configs in a heap-allocated pointer so that // our messages aren't huge. var renderer_config_ptr = try self.alloc.create(Renderer.DerivedConfig); @@ -961,6 +971,14 @@ pub fn keyCallback( } } + // If this input event has text, then we hide the mouse if configured. + if (self.config.mouse_hide_while_typing and + !self.mouse.hidden and + event.utf8.len > 0) + { + self.hideMouse(); + } + // No binding, so we have to perform an encoding task. This // may still result in no encoding. Under different modes and // inputs there are many keybindings that result in no encoding @@ -1049,6 +1067,9 @@ pub fn scrollCallback( // log.info("SCROLL: xoff={} yoff={} mods={}", .{ xoff, yoff, scroll_mods }); + // Always show the mouse again if it is hidden + if (self.mouse.hidden) self.showMouse(); + const ScrollAmount = struct { // Positive is up, right sign: isize = 1, @@ -1446,6 +1467,9 @@ pub fn mouseButtonCallback( self.mouse.click_state[@intCast(@intFromEnum(button))] = action; self.mouse.mods = @bitCast(mods); + // Always show the mouse again if it is hidden + if (self.mouse.hidden) self.showMouse(); + // Shift-click continues the previous mouse state if we have a selection. // cursorPosCallback will also do a mouse report so we don't need to do any // of the logic below. @@ -1594,6 +1618,9 @@ pub fn cursorPosCallback( const tracy = trace(@src()); defer tracy.end(); + // Always show the mouse again if it is hidden + if (self.mouse.hidden) self.showMouse(); + // We are reading/writing state for the remainder self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); @@ -1852,6 +1879,18 @@ fn scrollToBottom(self: *Surface) !void { try self.queueRender(); } +fn hideMouse(self: *Surface) void { + if (self.mouse.hidden) return; + self.mouse.hidden = true; + self.rt_surface.setMouseVisibility(false); +} + +fn showMouse(self: *Surface) void { + if (!self.mouse.hidden) return; + self.mouse.hidden = false; + self.rt_surface.setMouseVisibility(true); +} + /// Perform a binding action. A binding is a keybinding. This function /// must be called from the GUI thread. pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !void { 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, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 2efab9539..3a53bb9dc 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -542,6 +542,11 @@ pub const Surface = struct { self.cursor = new; } + /// Set the visibility of the mouse cursor. + pub fn setMouseVisibility(self: *Surface, visible: bool) void { + self.window.setInputModeCursor(if (visible) .normal else .hidden); + } + /// Read the clipboard. The windowing system is responsible for allocating /// a buffer as necessary. This should be a stable pointer until the next /// time getClipboardString is called. diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 7ecb83033..35791933d 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -45,6 +45,9 @@ pub const App = struct { app: *c.GtkApplication, ctx: *c.GMainContext, + /// The "none" cursor. We use one that is shared across the entire app. + cursor_none: ?*c.GdkCursor, + /// This is set to false when the main loop should exit. running: bool = true, @@ -68,6 +71,10 @@ pub const App = struct { } } + // The "none" cursor is used for hiding the cursor + const cursor_none = c.gdk_cursor_new_from_name("none", null); + errdefer if (cursor_none) |cursor| c.g_object_unref(cursor); + // Our uniqueness ID is based on whether we're in a debug mode or not. // In debug mode we want to be separate so we can develop Ghostty in // Ghostty. @@ -128,6 +135,7 @@ pub const App = struct { .app = app, .config = config, .ctx = ctx, + .cursor_none = cursor_none, // If we are NOT the primary instance, then we never want to run. // This means that another instance of the GTK app is running and @@ -144,6 +152,8 @@ pub const App = struct { c.g_main_context_release(self.ctx); c.g_object_unref(self.app); + if (self.cursor_none) |cursor| c.g_object_unref(cursor); + self.config.deinit(); glfw.terminate(); @@ -1042,6 +1052,21 @@ pub const Surface = struct { self.cursor = cursor; } + /// Set the visibility of the mouse cursor. + pub fn setMouseVisibility(self: *Surface, visible: bool) void { + // Note in there that self.cursor or cursor_none may be null. That's + // not a problem because NULL is a valid argument for set cursor + // which means to just use the parent value. + + if (visible) { + c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.cursor); + return; + } + + // Set our new cursor to the app "none" cursor + c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); + } + pub fn getClipboardString( self: *Surface, clipboard_type: apprt.Clipboard, diff --git a/src/config/Config.zig b/src/config/Config.zig index b5e4340dd..64aa0d13d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -119,6 +119,11 @@ palette: Palette = .{}, /// will be chosen. @"cursor-text": ?Color = null, +/// 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. +@"mouse-hide-while-typing": bool = false, + /// The opacity level (opposite of transparency) of the background. /// A value of 1 is fully opaque and a value of 0 is fully transparent. /// A value less than 0 or greater than 1 will be clamped to the nearest