Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
136 changes: 86 additions & 50 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions src/surface_mouse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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);
}
}
Loading