diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 581691ca9..060b7990b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -221,6 +221,10 @@ extension Ghostty { // This is set to non-null during keyDown to accumulate insertText contents private var keyTextAccumulator: [String]? + // True when we've consumed a left mouse-down only to move focus and + // should suppress the matching mouse-up from being reported. + private var suppressNextLeftMouseUp: Bool = false + // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? @@ -644,12 +648,18 @@ extension Ghostty { let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } - // We only want to grab focus if either our app or window was - // not focused. - guard !NSApp.isActive || !window.isKeyWindow else { return event } + // If we're already the first responder then no focus transfer is + // happening, so the click should continue as normal. + guard window.firstResponder !== self else { return event } - // If we're already focused we do nothing - guard !focused else { return event } + // If our window/app is already focused, then this click is only + // being used to transfer split focus. Consume it so it does not + // get forwarded to the terminal as a mouse click. + if NSApp.isActive && window.isKeyWindow { + window.makeFirstResponder(self) + suppressNextLeftMouseUp = true + return nil + } // Make ourselves the first responder window.makeFirstResponder(self) @@ -854,6 +864,13 @@ extension Ghostty { } override func mouseUp(with event: NSEvent) { + // If this mouse-up corresponds to a focus-only click transfer, + // suppress it so we don't emit a release without a press. + if suppressNextLeftMouseUp { + suppressNextLeftMouseUp = false + return + } + // Always reset our pressure when the mouse goes up prevPressureStage = 0 -- 2.51.2 From 0fa12f89151d5332233c97308bdda8925f6627b9 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 4 Mar 2026 12:27:19 -0600 Subject: [PATCH 2/3] gtk: suppress mouse reports on focus-transfer clicks Amp-Thread-ID: https://ampcode.com/threads/T-019cb9fe-b11b-753f-99e7-8ecc52b73ec4 Co-authored-by: Amp --- src/apprt/gtk/class/surface.zig | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2f4a13a32..b76ddba7e 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -693,6 +693,10 @@ pub const Surface = extern struct { /// Whether primary paste (middle-click paste) is enabled. gtk_enable_primary_paste: bool = true, + /// True when a left mouse down was consumed purely for a focus change, + /// and the matching left mouse release should also be suppressed. + suppress_left_mouse_release: bool = false, + /// How much pending horizontal scroll do we have? pending_horizontal_scroll: f64 = 0.0, @@ -2733,13 +2737,21 @@ pub const Surface = extern struct { // If we don't have focus, grab it. const gl_area_widget = priv.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0) { + const had_focus = gl_area_widget.hasFocus() != 0; + if (!had_focus) { _ = gl_area_widget.grabFocus(); } // Report the event const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + // If this click is only transitioning split focus, suppress it so + // it doesn't get forwarded to the terminal as a mouse event. + if (!had_focus and button == .left) { + priv.suppress_left_mouse_release = true; + return; + } + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } @@ -2795,6 +2807,11 @@ pub const Surface = extern struct { const gtk_mods = event.getModifierState(); const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + if (button == .left and priv.suppress_left_mouse_release) { + priv.suppress_left_mouse_release = false; + return; + } + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } -- 2.51.2 From d1468086efcb7ae83498c4660ecfa5c3aebd8b6a Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 4 Mar 2026 12:27:48 -0600 Subject: [PATCH 3/3] macos: defer key-window focus sync to reduce churn Amp-Thread-ID: https://ampcode.com/threads/T-019cb9fe-b11b-753f-99e7-8ecc52b73ec4 Co-authored-by: Amp --- .../Features/Terminal/BaseTerminalController.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 51d6a263d..d4b0ac080 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1239,9 +1239,11 @@ class BaseTerminalController: NSWindowController, } } - // Becoming/losing key means we have to notify our surface(s) that we have focus - // so things like cursors blink, pty events are sent, etc. - self.syncFocusToSurfaceTree() + // Becoming key can race with responder updates when activating a window. + // Sync on the next runloop so split focus has settled first. + DispatchQueue.main.async { + self.syncFocusToSurfaceTree() + } } func windowDidResignKey(_ notification: Notification) {