Skip to content
Draft
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 @@ -1123,6 +1123,7 @@ GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t,
GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
ghostty_input_mods_e);
GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
GHOSTTY_API void ghostty_surface_record_inspector_key(ghostty_surface_t, ghostty_input_key_s);
GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t,
ghostty_input_key_s,
ghostty_binding_flags_e*);
Expand Down
71 changes: 42 additions & 29 deletions macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import SwiftUI
import GhosttyKit

extension Ghostty {
/// The manager that's responsible for updating shortcuts of Ghostty's app menu
Expand Down Expand Up @@ -34,41 +35,36 @@ extension Ghostty {
/// bindings through the menu so they flash but also lets our surface override macOS built-ins
/// like Cmd+H.
func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool {
// Convert this event into the same normalized lookup key we use when
// syncing menu shortcuts from configuration.
guard let key = MenuShortcutKey(event: event) else {
return false
// Look up and dispatch the menu item for a given shortcut key.
func dispatch(_ key: MenuShortcutKey) -> Bool {
guard let weakItem = menuItemsByShortcut[key] else { return false }
guard let item = weakItem.value else {
menuItemsByShortcut.removeValue(forKey: key)
return false
}
guard let parentMenu = item.menu else { return false }
parentMenu.update()
guard item.isEnabled else { return false }
let index = parentMenu.index(of: item)
guard index >= 0 else { return false }
parentMenu.performActionForItem(at: index)
return true
}

// If we don't have an entry for this key combo, no Ghostty-owned
// menu shortcut exists for this event.
guard let weakItem = menuItemsByShortcut[key] else {
return false
// Match the unshifted character with full modifiers,
// matching how shortcuts are stored from configuration.
if let key = MenuShortcutKey(event: event), dispatch(key) {
return true
}

// Weak references can be nil if a menu item was deallocated after sync.
guard let item = weakItem.value else {
menuItemsByShortcut.removeValue(forKey: key)
return false
// Match the produced character with consumed modifiers stripped,
// for shortcuts where the character requires translation
// modifiers to type.
if let key = MenuShortcutKey(producedFromEvent: event), dispatch(key) {
return true
}

guard let parentMenu = item.menu else {
return false
}

// Keep enablement state fresh in case menu validation hasn't run yet.
parentMenu.update()
guard item.isEnabled else {
return false
}

let index = parentMenu.index(of: item)
guard index >= 0 else {
return false
}

parentMenu.performActionForItem(at: index)
return true
return false
}
}
}
Expand Down Expand Up @@ -126,11 +122,28 @@ extension Ghostty.MenuShortcutManager {
self.modifiersRawValue = mods.rawValue
}

/// Create from an `NSEvent` using the unshifted character with full
/// modifiers, matching how shortcuts are stored from configuration.
init?(event: NSEvent) {
guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil }
self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags)
}

/// Create from an `NSEvent` using the produced character with consumed
/// modifiers stripped, for shortcuts where the character requires
/// translation modifiers to type.
init?(producedFromEvent event: NSEvent) {
guard let keyEquivalent = event.ghosttyCharacters else { return nil }

let consumed = Ghostty.Input.Mods(
cMods: event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS).consumed_mods
).nsFlags
self.init(
keyEquivalent: keyEquivalent,
modifiers: event.modifierFlags.subtracting(consumed),
)
}

/// Create from a `NSMenuItem`
///
/// - Important: This will check whether the `keyEquivalent` is uppercased by `.shift` modifier.
Expand Down
32 changes: 18 additions & 14 deletions macos/Sources/Ghostty/NSEvent+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ extension NSEvent {
(translationMods ?? modifierFlags)
.subtracting([.control, .command]))

// Our unshifted codepoint is the codepoint with no modifiers. We
// ignore multi-codepoint values. We have to use `byApplyingModifiers`
// instead of `charactersIgnoringModifiers` because the latter changes
// behavior with ctrl pressed and we don't want any of that.
// `charactersIgnoringModifiers` returns a control character when Ctrl is
// held (e.g. Ctrl+A yields U+0001, not "a"), so it does not give the true
// unmodified character. `characters(byApplyingModifiers: [])` does. Ignore
// multi-codepoint results.
key_ev.unshifted_codepoint = 0
if type == .keyDown || type == .keyUp {
if let chars = characters(byApplyingModifiers: []),
Expand All @@ -47,25 +47,29 @@ extension NSEvent {
return key_ev
}

/// Returns the text to set for a key event for Ghostty.
/// Returns the text to send to Ghostty for this key event.
///
/// This namely contains logic to avoid control characters, since we handle control character
/// mapping manually within Ghostty.
/// macOS returns "" for `characters` while Command is held and produces
/// control characters while Control is held, so under either we re-derive the
/// produced character via `characters(byApplyingModifiers:)` with the
/// translation modifiers (Shift, Option, Caps).
var ghosttyCharacters: String? {
// If we have no characters associated with this event we do nothing.
if modifierFlags.contains(.command) || modifierFlags.contains(.control) {
return self.characters(byApplyingModifiers: modifierFlags.intersection([.shift, .option, .capsLock]))
}

guard let characters else { return nil }

if characters.count == 1,
let scalar = characters.unicodeScalars.first {
// If we have a single control character, then we return the characters
// without control pressed. We do this because we handle control character
// encoding directly within Ghostty's KeyEncoder.
// C0 control characters come from keys like Enter and Tab, which
// Ghostty's KeyEncoder maps from the keycode rather than from text.
if scalar.value < 0x20 {
return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control))
return self.characters(byApplyingModifiers: modifierFlags)
}

// If we have a single value in the PUA, then it's a function key and
// we don't want to send PUA ranges down to Ghostty.
// Drop function keys that are encoded in the Private Use Area
// U+F700–U+F8FF so they don't reach Ghostty as text.
if scalar.value >= 0xF700 && scalar.value <= 0xF8FF {
return nil
}
Expand Down
93 changes: 91 additions & 2 deletions macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,29 @@ extension Ghostty {
// `interpretKeyEvents` may dispatch it.
self.lastPerformKeyEvent = nil

// AppKit routes key events with Command or Control through
// performKeyEquivalent before keyDown, but we should also let the
// main menu handle Option-modified events (e.g. option+shift+m as a
// macOS App Shortcut) before they get swallowed by interpretKeyEvents,
// unless it is a binding.
if keySequence.isEmpty,
keyTables.isEmpty,
!event.modifierFlags.contains(.command),
!event.modifierFlags.contains(.control),
event.modifierFlags.contains(.option) {
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
let isBinding = (event.ghosttyCharacters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
return surfaceModel?.keyIsBinding(ghosttyEvent)
}

if isBinding == nil,
let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) {
return
}
}

self.interpretKeyEvents([translationEvent])

// If our keyboard changed from this we just assume an input method
Expand Down Expand Up @@ -1297,7 +1320,7 @@ extension Ghostty {
// Get information about if this is a binding.
let bindingFlags = surfaceModel.flatMap { surface in
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
return (event.characters ?? "").withCString { ptr in
return (event.ghosttyCharacters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
return surface.keyIsBinding(ghosttyEvent)
}
Expand All @@ -1317,14 +1340,71 @@ extension Ghostty {
bindingFlags.contains(.consumed) {
if let appDelegate = NSApp.delegate as? AppDelegate,
appDelegate.performGhosttyBindingMenuKeyEquivalent(with: event) {

// keyDown is bypassed when the menu dispatches, so
// record the key for the inspector here.
if let surface = self.surface {
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
(event.ghosttyCharacters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
ghostty_surface_record_inspector_key(surface, ghosttyEvent)
}
}

return true
}
}

// A consumed binding should not forward text to the terminal,
// so skip interpretKeyEvents and dispatch directly to core.
// On layouts where that key is a dead key, feeding the event to
// the input client would start an unfinished composition.
if bindingFlags.contains(.consumed) {
if hasMarkedText() {
unmarkText()
}
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
_ = keyAction(action, event: event, text: event.ghosttyCharacters)
return true
}

self.keyDown(with: event)
return true
}

// AppKit strips Shift from printable key equivalents, so Cmd+Shift+X
// would match a Cmd+X menu item once this method returns false and the
// main menu runs. If this event isn't a binding but Shift-removed would
// be, the Shift is meaningful, so then route it through keyDown and
// return true so that AppKit's menu never gets to fuzzy-match it.
if bindingFlags == nil,
event.modifierFlags.contains(.shift),
event.modifierFlags.contains(.command) || event.modifierFlags.contains(.control),
let surface = self.surface {
let dropped = event.modifierFlags.subtracting(.shift)
var probe = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
probe.mods = Ghostty.ghosttyMods(dropped)
probe.consumed_mods = Ghostty.ghosttyMods(
dropped.subtracting([.control, .command])
)
var flags = ghostty_binding_flags_e(0)
let strippedText = event.characters(
byApplyingModifiers: dropped.intersection([.shift, .option, .capsLock])
)
let shiftedText = event.ghosttyCharacters
guard (strippedText ?? "").lowercased() == (shiftedText ?? "").lowercased() else {
return false
}
let strippedIsBinding = (strippedText ?? "").withCString { ptr in
probe.text = ptr
return ghostty_surface_key_is_binding(surface, probe, &flags)
}
if strippedIsBinding {
self.keyDown(with: event)
return true
}
}

let equivalent: String
switch event.charactersIgnoringModifiers {
case "\r":
Expand Down Expand Up @@ -1461,10 +1541,19 @@ extension Ghostty {
var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
key_ev.composing = composing

// Fall back to ghosttyCharacters when no explicit text was supplied,
// since `event.characters` is "" while Command is held.
let effectiveText: String? = {
if let text, !text.isEmpty {
return text
}
return event.ghosttyCharacters
}()

// For text, we only encode UTF8 if we don't have a single control
// character. Control characters are encoded by Ghostty itself.
// Without this, `ctrl+enter` does the wrong thing.
if let text, text.count > 0,
if let text = effectiveText, text.count > 0,
let codepoint = text.utf8.first, codepoint >= 0x20 {
return text.withCString { ptr in
key_ev.text = ptr
Expand Down
45 changes: 44 additions & 1 deletion src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const internal_os = @import("../os/main.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const CoreApp = @import("../App.zig");
const CoreInspector = @import("../inspector/main.zig").Inspector;
const inspectorpkg = @import("../inspector/main.zig");
const CoreInspector = inspectorpkg.Inspector;
const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig");
const Config = configpkg.Config;
Expand Down Expand Up @@ -1775,6 +1776,48 @@ pub const CAPI = struct {
return @intCast(@as(input.Mods.Backing, @bitCast(result)));
}

/// Record a key event in the terminal inspector without executing any bindings.
/// The recorded event preserves original modifiers, while binding lookup mirrors
/// keyCallback by applying any configured key remaps first.
export fn ghostty_surface_record_inspector_key(
surface: *Surface,
event: KeyEvent,
) void {
const core_event = event.keyEvent().core() orelse return;
const inspector = surface.core_surface.inspector orelse return;

var insp_ev: inspectorpkg.KeyEvent = .{ .event = core_event };
insp_ev.event.utf8 = "";
if (core_event.utf8.len > 0) {
insp_ev.event.utf8 = surface.core_surface.alloc.dupe(
u8,
core_event.utf8,
) catch &.{};
}

var lookup_event = core_event;
if (surface.core_surface.config.key_remaps.isRemapped(core_event.mods)) {
lookup_event.mods = surface.core_surface.config.key_remaps.apply(core_event.mods);
}

if (surface.core_surface.config.keybind.set.getEvent(lookup_event)) |entry| {
const actions = switch (entry.value_ptr.*) {
.leader => &.{},
inline .leaf, .leaf_chained => |leaf| leaf.generic().actionsSlice(),
};
insp_ev.binding = surface.core_surface.alloc.dupe(
input.Binding.Action,
actions,
) catch &.{};
}

if (inspector.recordKeyEvent(surface.core_surface.alloc, insp_ev)) {
surface.queueInspectorRender();
} else |_| {
insp_ev.deinit(surface.core_surface.alloc);
}
}

/// 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
2 changes: 1 addition & 1 deletion src/build/uucode_config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ pub const tables = [_]config.Table{
.extensions = &.{},
.fields = &.{
d.field("is_emoji_presentation"),
d.field("case_folding_full"),
d.field("case_folding_simple"),
},
},
.{
Expand Down
Loading