From 6c0bdd95cf9f1d818caddc1765ddec56257f0493 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sat, 16 May 2026 10:31:20 -0500 Subject: [PATCH] macos: treat unfocused split modifiers as UI state The terminal controller listens for flagsChanged events so all splits can keep modifier-sensitive UI state current. Forwarding those events through SurfaceView.flagsChanged() also routes them through the normal key input path, so an unfocused split can receive modifier press and release events as terminal input. Send modifier changes for unfocused splits through a modifier-only surface entry point instead. The core surface updates mouse modifier state, link highlighting and cursor shape from that path, but does not run key binding or terminal encoding. Apply key remaps there as well so focused and unfocused modifier state stay consistent. Factor the shared modifier and mouse-shape work into helpers so the normal key path and the modifier-only path use the same state transitions. Keep the physical-key filter in keyToMouseShape(), and expose the pure modsToMouseShape() calculation for cases where AppKit reports only a modifier state change. To reproduce the old behavior: open two splits in Ghostty and run `kitten show-key -m kitty` in each. Modifier key presses appear in both splits. AI Disclosure: This patch was prepared with the help of AI. --- include/ghostty.h | 1 + .../Terminal/BaseTerminalController.swift | 9 +- .../Surface View/SurfaceView_AppKit.swift | 5 + src/Surface.zig | 136 +++++++++++------- src/apprt/embedded.zig | 14 ++ src/surface_mouse.zig | 42 ++++++ 6 files changed, 153 insertions(+), 54 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b099741fc..977660f7c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1120,6 +1120,7 @@ GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); +GHOSTTY_API void ghostty_surface_set_mods(ghostty_surface_t, ghostty_input_mods_e); GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s, diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f5b500b70..d66ed787d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -784,15 +784,16 @@ class BaseTerminalController: NSWindowController, private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 } - // If we're the main window receiving key input, then we want to avoid - // calling this on our focused surface because that'll trigger a double - // flagsChanged call. + // If we're the main window receiving key input, then the focused + // surface gets flagsChanged through the responder chain. Other surfaces + // still need modifier updates for UI state such as link highlighting, + // but those updates must not be encoded as terminal input. if NSApp.mainWindow == window { surfaces = surfaces.filter { $0 != focusedSurface } } for surface in surfaces { - surface.flagsChanged(with: event) + surface.modifiersChanged(with: event) } return event diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index b1920f170..7c7fbb5eb 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1366,6 +1366,11 @@ extension Ghostty { return true } + func modifiersChanged(with event: NSEvent) { + guard let surface = self.surface else { return } + ghostty_surface_set_mods(surface, Ghostty.ghosttyMods(event.modifierFlags)) + } + override func flagsChanged(with event: NSEvent) { let mod: UInt32 switch event.keyCode { diff --git a/src/Surface.zig b/src/Surface.zig index 5d16f3326..e181b24d9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1522,6 +1522,24 @@ fn searchCallback_( } } +/// Call this when modifiers change without forwarding a key event to the PTY. +/// This is used by apprts to update UI state such as link highlighting for +/// surfaces that aren't receiving keyboard input. +pub fn modsCallback(self: *Surface, mods: input.Mods) !void { + // Crash metadata in case we crash in here + crash.sentry.thread_state = self.crashThreadState(); + defer crash.sentry.thread_state = null; + + var translated_mods = mods; + if (self.config.key_remaps.isRemapped(mods)) { + translated_mods = self.config.key_remaps.apply(mods); + } + + const mouse_mods = self.mouse.mods; + try self.handleModsChanged(translated_mods); + if (!mouse_mods.equal(self.mouse.mods)) try self.updateMouseShape(null); +} + /// Call this when modifiers change. This is safe to call even if modifiers /// match the previous state. /// @@ -1558,6 +1576,72 @@ fn modsChanged(self: *Surface, mods: input.Mods) void { } } +/// Handle modifier changes that may affect mouse/link UI state without +/// implying that a key input should be sent to the terminal. +fn handleModsChanged(self: *Surface, mods: input.Mods) !void { + if (self.mouse.mods.equal(mods)) return; + + // Update our modifiers, this will update mouse mods too. + self.modsChanged(mods); + + // We only refresh links if + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false))) + mouse_mods: { + // Refresh our link state + const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + self.mouseRefreshLinks( + pos, + self.posToViewport(pos.x, pos.y), + self.mouse.over_link, + ) catch |err| { + log.warn("failed to refresh links err={}", .{err}); + break :mouse_mods; + }; + } else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) { + // If we have mouse reports on and we don't have shift pressed, we reset state + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); + try self.queueRender(); + } +} + +/// Update the mouse shape for modifier-dependent cursor states. +fn updateMouseShape(self: *Surface, physical_key: ?input.Key) !void { + const state: SurfaceMouse = .{ + .physical_key = physical_key orelse .unidentified, + .mouse_event = self.io.terminal.flags.mouse_event, + .mouse_shape = self.io.terminal.mouse_shape, + .mods = self.mouse.mods, + .over_link = self.mouse.over_link, + .hidden = self.mouse.hidden, + }; + + const shape = if (physical_key != null) + state.keyToMouseShape() + else + state.modsToMouseShape(); + + if (shape) |v| _ = try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + v, + ); +} + /// Call this whenever the mouse moves or mods changed. The time /// at which this is called may matter for the correctness of other /// mouse events (see cursorPosCallback) but this is shared logic @@ -2693,59 +2777,11 @@ pub fn keyCallback( // If our mouse modifiers change we may need to change our // link highlight state. - if (!self.mouse.mods.equal(event.mods)) mouse_mods: { - // Update our modifiers, this will update mouse mods too - self.modsChanged(event.mods); - - // We only refresh links if - // 1. mouse reporting is off - // OR - // 2. mouse reporting is on and we are not reporting shift to the terminal - if (self.io.terminal.flags.mouse_event == .none or - (self.mouse.mods.shift and !self.mouseShiftCapture(false))) - { - // Refresh our link state - const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - self.mouseRefreshLinks( - pos, - self.posToViewport(pos.x, pos.y), - self.mouse.over_link, - ) catch |err| { - log.warn("failed to refresh links err={}", .{err}); - break :mouse_mods; - }; - } else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) { - // If we have mouse reports on and we don't have shift pressed, we reset state - _ = try self.rt_app.performAction( - .{ .surface = self }, - .mouse_shape, - self.io.terminal.mouse_shape, - ); - _ = try self.rt_app.performAction( - .{ .surface = self }, - .mouse_over_link, - .{ .url = "" }, - ); - try self.queueRender(); - } - } + try self.handleModsChanged(event.mods); // Process the cursor state logic. This will update the cursor shape if // needed, depending on the key state. - if ((SurfaceMouse{ - .physical_key = event.key, - .mouse_event = self.io.terminal.flags.mouse_event, - .mouse_shape = self.io.terminal.mouse_shape, - .mods = self.mouse.mods, - .over_link = self.mouse.over_link, - .hidden = self.mouse.hidden, - }).keyToMouseShape()) |shape| _ = try self.rt_app.performAction( - .{ .surface = self }, - .mouse_shape, - shape, - ); + try self.updateMouseShape(event.key); // We've processed a key event that produced some data so we want to // track the last pressed key. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 730913eba..fb83f87a7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1774,6 +1774,20 @@ pub const CAPI = struct { return @intCast(@as(input.Mods.Backing, @bitCast(result))); } + /// Update the modifier state for UI purposes without sending a key event + /// to the terminal. + export fn ghostty_surface_set_mods( + surface: *Surface, + mods_raw: c_int, + ) void { + surface.core_surface.modsCallback(@bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods_raw))), + ))) catch |err| { + log.warn("error processing mods event err={}", .{err}); + }; + } + /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index 8fa53d240..4633eb02a 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -54,6 +54,13 @@ pub fn keyToMouseShape(self: SurfaceMouse) ?MouseShape { // Filter for appropriate key events if (!eligibleMouseShapeKeyEvent(self.physical_key)) return null; + return self.modsToMouseShape(); +} + +/// Translates the current modifier state to mouse shape without requiring a +/// key event. This is used for modifier-only updates that should affect UI +/// state without sending keyboard input to the terminal. +pub fn modsToMouseShape(self: SurfaceMouse) ?MouseShape { // Exceptions: link hover or hidden state overrides any other shape // processing and does not change state. // @@ -334,3 +341,38 @@ test "keyToMouseShape" { try testing.expect(want == got); } } + +test "modsToMouseShape" { + const testing = std.testing; + + { + // Modifier-only updates do not need a specific physical key. + const m: SurfaceMouse = .{ + .physical_key = .unidentified, + .mouse_event = .none, + .mouse_shape = .text, + .mods = .{ .ctrl = true, .super = true, .alt = true }, + .over_link = false, + .hidden = false, + }; + + const want: MouseShape = .crosshair; + const got = m.modsToMouseShape(); + try testing.expect(want == got); + } + + { + // Link hover still owns the cursor shape. + const m: SurfaceMouse = .{ + .physical_key = .unidentified, + .mouse_event = .none, + .mouse_shape = .text, + .mods = .{ .ctrl = true, .super = true, .alt = true }, + .over_link = true, + .hidden = false, + }; + + const got = m.modsToMouseShape(); + try testing.expect(got == null); + } +}