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.
pull/12710/head
Tim Culverhouse 2026-05-16 10:31:20 -05:00
parent cf24a4856b
commit 6c0bdd95cf
No known key found for this signature in database
6 changed files with 153 additions and 54 deletions

View File

@ -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,

View File

@ -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

View File

@ -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 {

View File

@ -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.

View File

@ -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.

View File

@ -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);
}
}