diff --git a/include/ghostty.h b/include/ghostty.h index b099741fc23..977660f7c12 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 f5b500b70b3..d66ed787d2f 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 b1920f170f2..7c7fbb5eb63 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 5d16f33267c..e181b24d9e2 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 730913eba1c..fb83f87a764 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 8fa53d240d9..4633eb02ac8 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); + } +}