fix(macOS): filter phantom mouse events that defeat mouse-hide-while-typing (#11066)

## Summary

Ports the phantom mouse-motion position-equality check from the GTK
runtime to the embedded runtime (used by macOS).

On macOS, TUI apps like Zellij that frequently update the window title
cause phantom `mouseMoved` events at the same coordinates. These flow
through `embedded.zig` → `Surface.zig` `cursorPosCallback` →
`showMouse()`, which explicitly calls
`NSCursor.setHiddenUntilMouseMoves(false)` and unhides the cursor,
defeating `mouse-hide-while-typing`.

The GTK runtime already filters these in PR #4973 (for #3345):

```zig
const is_cursor_still = @abs(priv.cursor_pos.x - pos.x) < 1 and
    @abs(priv.cursor_pos.y - pos.y) < 1;
if (is_cursor_still) return;
```

This PR adds the same check to `embedded.zig`'s `cursorPosCallback`,
using the already-stored `self.cursor_pos` field.

## Test plan

- [x] Enable `mouse-hide-while-typing = true` in Ghostty config
- [ ] Run a TUI app that updates the window title frequently (e.g.
Zellij)
- [ ] Type — cursor should hide and stay hidden despite title updates
- [ ] Move the mouse — cursor should reappear normally
- [ ] Verify no regressions with normal mouse movement,
focus-follows-mouse, or link hovering
This commit is contained in:
Mitchell Hashimoto
2026-02-27 11:25:18 -08:00
committed by GitHub

View File

@@ -848,7 +848,7 @@ pub const Surface = struct {
mods: input.Mods,
) void {
// Convert our unscaled x/y to scaled.
self.cursor_pos = self.cursorPosToPixels(.{
const pos = self.cursorPosToPixels(.{
.x = @floatCast(x),
.y = @floatCast(y),
}) catch |err| {
@@ -859,6 +859,19 @@ pub const Surface = struct {
return;
};
// There are cases where the platform reports a mouse motion event
// without the cursor actually moving. For example, on macOS, updating
// the window title can trigger a phantom mouse-move event at the same
// coordinates. This can cause the mouse to incorrectly unhide when
// mouse-hide-while-typing is enabled (commonly seen with TUI apps
// like Zellij that frequently update the title). To prevent incorrect
// behavior, we only continue with callback logic if the cursor has
// actually moved.
if (@abs(self.cursor_pos.x - pos.x) < 1 and
@abs(self.cursor_pos.y - pos.y) < 1) return;
self.cursor_pos = pos;
self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;