Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions macos/Sources/Ghostty/Ghostty.Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,14 @@ extension Ghostty {
return v;
}

var mouseHideAfter: UInt {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Duration is better, like undoTimeout down below

guard let config = self.config else { return 0 }
var v: UInt = 0
let key = "mouse-hide-after"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return v;
}

var undoTimeout: Duration {
guard let config = self.config else { return .seconds(5) }
var v: UInt = 0
Expand Down
32 changes: 32 additions & 0 deletions macos/Sources/Ghostty/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ extension Ghostty {
// then the view is moved to a new window.
var initialSize: NSSize? = nil

// Timer used for `mouse-hide-after` behavior on macOS. This hides the mouse
// cursor after a period of no mouse movement.
private var mouseHideAfterTimer: Timer? = nil

// A content size received through sizeDidChange that may in some cases
// be different from the frame size.
private var contentSizeBacking: NSSize?
Expand Down Expand Up @@ -778,6 +782,30 @@ extension Ghostty {
userInfo: nil))
}

/// Reset the `mouse-hide-after` timer on mouse movement, if configured.
private func resetMouseHideAfterTimer() {
mouseHideAfterTimer?.invalidate()
mouseHideAfterTimer = nil

// Note: DerivedConfig does not expose the full Ghostty.Config, so we
// read directly from the global Ghostty config instead.
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
let ms = appDelegate.ghostty.config.mouseHideAfter
guard ms > 0 else { return }

let interval = TimeInterval(ms) / 1000.0
mouseHideAfterTimer = Timer.scheduledTimer(
withTimeInterval: interval,
repeats: false,
block: { [weak self] _ in
guard let self else { return }
// Hide the cursor. This will remain hidden until the mouse
// moves again (or other platform behavior such as new window).
NSCursor.setHiddenUntilMouseMoves(true)
}
)
}

override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()

Expand Down Expand Up @@ -901,6 +929,8 @@ extension Ghostty {
mods: .init(nsFlags: event.modifierFlags)
)
surfaceModel.sendMousePos(mouseEvent)

resetMouseHideAfterTimer()
}

override func mouseExited(with event: NSEvent) {
Expand Down Expand Up @@ -934,6 +964,8 @@ extension Ghostty {
)
surfaceModel.sendMousePos(mouseEvent)

resetMouseHideAfterTimer()

// Handle focus-follows-mouse
if let window,
let controller = window.windowController as? BaseTerminalController,
Expand Down
60 changes: 60 additions & 0 deletions src/apprt/gtk/class/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,14 @@ pub const Surface = extern struct {
// Progress bar
progress_bar_timer: ?c_uint = null,

/// Timer source id for `mouse-hide-after` idle hiding. When non-null,
/// a one-shot GLib timeout is scheduled to hide the mouse after the
/// configured idle duration.
mouse_hide_after_timer: ?c_uint = null,

/// The last time we observed mouse movement, used for `mouse-hide-after`.
last_mouse_move_time: ?std.time.Instant = null,

// True while the bell is ringing. This will be set to false (after
// true) under various scenarios, but can also manually be set to
// false by a parent widget.
Expand Down Expand Up @@ -911,6 +919,24 @@ pub const Surface = extern struct {
return @intFromBool(glib.SOURCE_REMOVE);
}

/// Timer callback for `mouse-hide-after`. Hides the mouse after a period
/// of no mouse movement.
fn mouseHideAfterTimer(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse
return @intFromBool(glib.SOURCE_REMOVE)));
const priv = self.private();

// Clear our timer handle first to avoid reusing it.
priv.mouse_hide_after_timer = null;

// Hide the mouse cursor directly.
if (!priv.mouse_hidden) {
self.setMouseHidden(true);
}

return @intFromBool(glib.SOURCE_REMOVE);
}

/// Request that this terminal come to the front and become focused.
/// It is up to the embedding widget to react to this.
pub fn present(self: *Self) void {
Expand Down Expand Up @@ -1716,6 +1742,13 @@ pub const Surface = extern struct {
priv.progress_bar_timer = null;
}

if (priv.mouse_hide_after_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove mouse hide-after timer", .{});
}
priv.mouse_hide_after_timer = null;
}

if (priv.idle_rechild) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove idle source", .{});
Expand Down Expand Up @@ -2641,6 +2674,12 @@ pub const Surface = extern struct {
@abs(priv.cursor_pos.y - pos.y) < 1;
if (is_cursor_still) return;

// If the mouse is currently hidden (for example due to `mouse-hide-after`),
// show it again on real mouse movement.
if (priv.mouse_hidden) {
self.setMouseHidden(false);
}

// If we don't have focus, and we want it, grab it.
if (priv.config) |config| {
const gl_area_widget = priv.gl_area.as(gtk.Widget);
Expand All @@ -2654,6 +2693,27 @@ pub const Surface = extern struct {
// Our pos changed, update
priv.cursor_pos = pos;

// Track mouse movement time for `mouse-hide-after`.
priv.last_mouse_move_time = std.time.Instant.now() catch null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this ever used? I don't think it is, right?


// Reset `mouse-hide-after` idle timer if configured.
if (priv.mouse_hide_after_timer) |timer| {
if (glib.Source.remove(timer) == 0) {
log.warn("unable to remove mouse hide-after timer", .{});
}
priv.mouse_hide_after_timer = null;
}
if (priv.config) |config| {
const ms = config.get().@"mouse-hide-after".asMilliseconds();
if (ms > 0) {
priv.mouse_hide_after_timer = glib.timeoutAdd(
ms,
mouseHideAfterTimer,
self,
);
}
}

// Notify the callback
if (priv.core_surface) |surface| {
const gtk_mods = event.getModifierState();
Expand Down
16 changes: 16 additions & 0 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,22 @@ palette: Palette = .{},
/// the mouse is shown again when a new window, tab, or split is created.
@"mouse-hide-while-typing": bool = false,

/// Hide the mouse after a period of mouse inactivity (no mouse movement).
/// When set, the mouse is hidden once it has been idle for at least this long
/// and becomes visible again when the mouse is used (button, movement, etc.).
/// Typing does not affect the idle timer for this configuration; once the
/// mouse has been idle long enough to hide, it will remain hidden while
/// typing until the mouse is moved again.
///
/// Set this to a duration such as `5s` to hide the mouse 5 seconds after the
/// last mouse movement. The default value of `0` disables this behavior.
///
/// This is not mutually exclusive with `mouse-hide-while-typing`; both can be
/// enabled at the same time.
///
/// Available since 1.3.0
@"mouse-hide-after": Duration = .{ .duration = 0 },

/// When to scroll the surface to the bottom. The format of this is a list of
/// options to enable separated by commas. If you prefix an option with `no-`
/// then it is disabled. If you omit an option, its default value is used.
Expand Down