Tap events, core API to handle global keybinds

This commit is contained in:
Mitchell Hashimoto
2024-09-23 20:58:37 -07:00
parent 0f3f01483e
commit 1ad904478d
8 changed files with 516 additions and 226 deletions

View File

@ -527,6 +527,7 @@ ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
void ghostty_app_free(ghostty_app_t);
bool ghostty_app_tick(ghostty_app_t);
void* ghostty_app_userdata(ghostty_app_t);
bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
void ghostty_app_keyboard_changed(ghostty_app_t);
void ghostty_app_open_config(ghostty_app_t);
void ghostty_app_reload_config(ghostty_app_t);

View File

@ -61,6 +61,7 @@
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; };
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; };
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; };
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; };
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; };
@ -131,6 +132,7 @@
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = "<group>"; };
A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = "<group>"; };
A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = "<group>"; };
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
@ -199,6 +201,7 @@
A53426362A7DC53000EBB7A2 /* Features */ = {
isa = PBXGroup;
children = (
A5CBD0672CA2704E0017A1AE /* Global Keybinds */,
A56D58872ACDE6BE00508D2C /* Services */,
A59630982AEE1C4400D64628 /* Terminal */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
@ -370,6 +373,14 @@
name = Products;
sourceTree = "<group>";
};
A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = {
isa = PBXGroup;
children = (
A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */,
);
path = "Global Keybinds";
sourceTree = "<group>";
};
A5CEAFDA29B8005900646FDA /* SplitView */ = {
isa = PBXGroup;
children = (
@ -529,6 +540,7 @@
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,

View File

@ -418,6 +418,14 @@ class AppDelegate: NSObject,
c.showWindow(self)
}
}
// If our reload adds global keybinds and we don't have ax permissions then
// we need to request them.
global: if (ghostty_app_has_global_keybinds(ghostty.app!)) {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
GlobalEventTap.shared.enable()
}
}
}
/// Sync the appearance of our app with the theme specified in the config.

View File

@ -0,0 +1,150 @@
import Cocoa
import CoreGraphics
import Carbon
import OSLog
import GhosttyKit
// Manages the event tap to monitor global events, currently only used for
// global keybindings.
class GlobalEventTap {
static let shared = GlobalEventTap()
fileprivate static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: GlobalEventTap.self)
)
// The event tap used for global event listening. This is non-nil if it is
// created.
private var eventTap: CFMachPort? = nil
// This is the timer used to retry enabling the global event tap if we
// don't have permissions.
private var enableTimer: Timer? = nil
private init() {}
deinit {
disable()
}
// Enable the global event tap. This is safe to call if it is already enabled.
// If enabling fails due to permissions, this will start a timer to retry since
// accessibility permissions take affect immediately.
func enable() {
if (eventTap != nil) {
// Already enabled
return
}
// If we are already trying to enable, then stop the timer and restart it.
if let enableTimer {
enableTimer.invalidate()
}
// Try to enable the event tap immediately. If this succeeds then we're done!
if (tryEnable()) {
return
}
// Failed, probably due to permissions. The permissions dialog should've
// popped up. We retry on a timer since once the permisisons are granted
// then they take affect immediately.
enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
_ = self.tryEnable()
}
}
// Disable the global event tap. This is safe to call if it is already disabled.
func disable() {
// Stop our enable timer if it is on
if let enableTimer {
enableTimer.invalidate()
self.enableTimer = nil
}
// Stop our event tap
if let eventTap {
Self.logger.debug("invalidating event tap mach port")
CFMachPortInvalidate(eventTap)
self.eventTap = nil
}
}
// Try to enable the global event type, returns false if it fails.
private func tryEnable() -> Bool {
// The events we care about
let eventMask = [
CGEventType.keyDown
].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue)})
// Try to create it
guard let eventTap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: eventMask,
callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:),
userInfo: nil
) else {
// Return false if creation failed. This is usually because we don't have
// Accessibility permissions but can probably be other reasons I don't
// know about.
Self.logger.debug("creating global event tap failed, missing permissions?")
return false
}
// Store our event tap
self.eventTap = eventTap
// If we have an enable timer we always want to disable it
if let enableTimer {
enableTimer.invalidate()
self.enableTimer = nil
}
// Attach our event tap to the main run loop. Note if you don't do this then
// the event tap will block every
CFRunLoopAddSource(
CFRunLoopGetMain(),
CFMachPortCreateRunLoopSource(nil, eventTap, 0),
.commonModes
)
Self.logger.info("global event tap enabled for global keybinds")
return true
}
}
fileprivate func cgEventFlagsChangedHandler(
proxy: CGEventTapProxy,
type: CGEventType,
cgEvent: CGEvent,
userInfo: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
let result = Unmanaged.passUnretained(cgEvent)
// We only care about keydown events
guard type == .keyDown else { return result }
// We need an app delegate to get the Ghostty app instance
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return result }
guard let ghostty = appDelegate.ghostty.app else { return result }
// We need an NSEvent for our logic below
guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result }
// Build our event input and call ghostty
var key_ev = ghostty_input_key_s()
key_ev.action = GHOSTTY_ACTION_PRESS
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
key_ev.keycode = UInt32(event.keyCode)
key_ev.text = nil
key_ev.composing = false
if (ghostty_app_key(ghostty, key_ev)) {
GlobalEventTap.logger.info("global key event handled event=\(event)")
return nil
}
return result
}

View File

@ -262,6 +262,49 @@ pub fn setQuit(self: *App) !void {
self.quit = true;
}
/// Handle a key event at the app-scope. If this key event is used,
/// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false.
pub fn keyEvent(
self: *App,
rt_app: *apprt.App,
event: input.KeyEvent,
) bool {
switch (event.action) {
// We don't care about key release events.
.release => return false,
// Continue processing key press events.
.press, .repeat => {},
}
// Get the keybind entry for this event. We don't support key sequences
// so we can look directly in the top-level set.
const entry = rt_app.config.keybind.set.getEvent(event) orelse return false;
const leaf: input.Binding.Set.Leaf = switch (entry) {
// Sequences aren't supported. Our configuration parser verifies
// this for global keybinds but we may still get an entry for
// a non-global keybind.
.leader => return false,
// Leaf entries are good
.leaf => |leaf| leaf,
};
// We only care about global keybinds
if (!leaf.flags.global) return false;
// Perform the action
self.performAllAction(rt_app, leaf.action) catch |err| {
log.warn("error performing global keybind action action={s} err={}", .{
@tagName(leaf.action),
err,
});
};
return true;
}
/// Perform a binding action. This only accepts actions that are scoped
/// to the app. Callers can use performAllAction to perform any action
/// and any non-app-scoped actions will be performed on all surfaces.

View File

@ -1561,19 +1561,8 @@ fn maybeHandleBinding(
const entry: input.Binding.Set.Entry = entry: {
const set = self.keyboard.bindings orelse &self.config.keybind.set;
var trigger: input.Binding.Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (set.get(trigger)) |v| break :entry v;
trigger.key = .{ .physical = event.physical_key };
if (set.get(trigger)) |v| break :entry v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (set.get(trigger)) |v| break :entry v;
}
// Get our entry from the set for the given event.
if (set.getEvent(event)) |v| break :entry v;
// No entry found. If we're not looking at the root set of the
// bindings we need to encode everything up to this point and

View File

@ -143,17 +143,37 @@ pub const App = struct {
toggle_secure_input: ?*const fn () callconv(.C) void = null,
};
/// This is the key event sent for ghostty_surface_key and
/// ghostty_app_key.
pub const KeyEvent = struct {
/// The three below are absolutely required.
action: input.Action,
mods: input.Mods,
keycode: u32,
/// Optionally, the embedder can handle text translation and send
/// the text value here. If text is non-nil, it is assumed that the
/// embedder also handles dead key states and sets composing as necessary.
text: ?[:0]const u8,
composing: bool,
};
core_app: *CoreApp,
config: *const Config,
opts: Options,
keymap: input.Keymap,
/// The keymap state is used for global keybinds only. Each surface
/// also has its own keymap state for focused keybinds.
keymap_state: input.Keymap.State,
pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App {
return .{
.core_app = core_app,
.config = config,
.opts = opts,
.keymap = try input.Keymap.init(),
.keymap_state = .{},
};
}
@ -174,6 +194,241 @@ pub const App = struct {
return false;
}
/// The target of a key event. This is used to determine some subtly
/// different behavior between app and surface key events.
pub const KeyTarget = union(enum) {
app,
surface: *Surface,
};
/// See CoreApp.keyEvent.
pub fn keyEvent(
self: *App,
target: KeyTarget,
event: KeyEvent,
) !bool {
// NOTE: If this is updated, take a look at Surface.keyCallback as well.
// Their logic is very similar but not identical.
const action = event.action;
const keycode = event.keycode;
const mods = event.mods;
// True if this is a key down event
const is_down = action == .press or action == .repeat;
// If we're on macOS and we have macos-option-as-alt enabled,
// then we strip the alt modifier from the mods for translation.
const translate_mods = translate_mods: {
var translate_mods = mods;
if (comptime builtin.target.isDarwin()) {
const strip = switch (self.config.@"macos-option-as-alt") {
.false => false,
.true => mods.alt,
.left => mods.sides.alt == .left,
.right => mods.sides.alt == .right,
};
if (strip) translate_mods.alt = false;
}
// On macOS we strip ctrl because UCKeyTranslate
// converts to the masked values (i.e. ctrl+c becomes 3)
// and we don't want that behavior.
//
// We also strip super because its not used for translation
// on macos and it results in a bad translation.
if (comptime builtin.target.isDarwin()) {
translate_mods.ctrl = false;
translate_mods.super = false;
}
break :translate_mods translate_mods;
};
const event_text: ?[]const u8 = event_text: {
// This logic only applies to macOS.
if (comptime builtin.os.tag != .macos) break :event_text event.text;
// If the modifiers are ONLY "control" then we never process
// the event text because we want to do our own translation so
// we can handle ctrl+c, ctrl+z, etc.
//
// This is specifically because on macOS using the
// "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as
// "/" (the physical key that is z on a qwerty keyboard). But on
// other layouts, ctrl+<char> is not translated by AppKit. So,
// we just avoid this by never allowing AppKit to translate
// ctrl+<char> and instead do it ourselves.
const ctrl_only = comptime (input.Mods{ .ctrl = true }).int();
break :event_text if (mods.binding().int() == ctrl_only) null else event.text;
};
// Translate our key using the keymap for our localized keyboard layout.
// We only translate for keydown events. Otherwise, we only care about
// the raw keycode.
var buf: [128]u8 = undefined;
const result: input.Keymap.Translation = if (is_down) translate: {
// If the event provided us with text, then we use this as a result
// and do not do manual translation.
const result: input.Keymap.Translation = if (event_text) |text| .{
.text = text,
.composing = event.composing,
} else try self.keymap.translate(
&buf,
switch (target) {
.app => &self.keymap_state,
.surface => |surface| &surface.keymap_state,
},
@intCast(keycode),
translate_mods,
);
// If this is a dead key, then we're composing a character and
// we need to set our proper preedit state if we're targeting a
// surface.
if (result.composing) {
switch (target) {
.app => {},
.surface => |surface| surface.core_surface.preeditCallback(
result.text,
) catch |err| {
log.err("error in preedit callback err={}", .{err});
return false;
},
}
} else {
switch (target) {
.app => {},
.surface => |surface| surface.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
return false;
},
}
// If the text is just a single non-printable ASCII character
// then we clear the text. We handle non-printables in the
// key encoder manual (such as tab, ctrl+c, etc.)
if (result.text.len == 1 and result.text[0] < 0x20) {
break :translate .{ .composing = false, .text = "" };
}
}
break :translate result;
} else .{ .composing = false, .text = "" };
// UCKeyTranslate always consumes all mods, so if we have any output
// then we've consumed our translate mods.
const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{};
// We need to always do a translation with no modifiers at all in
// order to get the "unshifted_codepoint" for the key event.
const unshifted_codepoint: u21 = unshifted: {
var nomod_buf: [128]u8 = undefined;
var nomod_state: input.Keymap.State = .{};
const nomod = try self.keymap.translate(
&nomod_buf,
&nomod_state,
@intCast(keycode),
.{},
);
const view = std.unicode.Utf8View.init(nomod.text) catch |err| {
log.warn("cannot build utf8 view over text: {}", .{err});
break :unshifted 0;
};
var it = view.iterator();
break :unshifted it.nextCodepoint() orelse 0;
};
// log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{
// action,
// keycode,
// result.composing,
// result.text.len,
// result.text,
// result.text,
// mods,
// });
// We want to get the physical unmapped key to process keybinds.
const physical_key = keycode: for (input.keycodes.entries) |entry| {
if (entry.native == keycode) break :keycode entry.key;
} else .invalid;
// If the resulting text has length 1 then we can take its key
// and attempt to translate it to a key enum and call the key callback.
// If the length is greater than 1 then we're going to call the
// charCallback.
//
// We also only do key translation if this is not a dead key.
const key = if (!result.composing) key: {
// If our physical key is a keypad key, we use that.
if (physical_key.keypad()) break :key physical_key;
// A completed key. If the length of the key is one then we can
// attempt to translate it to a key enum and call the key
// callback. First try plain ASCII.
if (result.text.len > 0) {
if (input.Key.fromASCII(result.text[0])) |key| {
break :key key;
}
}
// If the above doesn't work, we use the unmodified value.
if (std.math.cast(u8, unshifted_codepoint)) |ascii| {
if (input.Key.fromASCII(ascii)) |key| {
break :key key;
}
}
break :key physical_key;
} else .invalid;
// Build our final key event
const input_event: input.KeyEvent = .{
.action = action,
.key = key,
.physical_key = physical_key,
.mods = mods,
.consumed_mods = consumed_mods,
.composing = result.composing,
.utf8 = result.text,
.unshifted_codepoint = unshifted_codepoint,
};
// Invoke the core Ghostty logic to handle this input.
const effect: CoreSurface.InputEffect = switch (target) {
.app => if (self.core_app.keyEvent(
self,
input_event,
))
.consumed
else
.ignored,
.surface => |surface| try surface.core_surface.keyCallback(input_event),
};
return switch (effect) {
.closed => true,
.ignored => false,
.consumed => consumed: {
if (is_down) {
// If we consume the key then we want to reset the dead
// key state.
self.keymap_state = .{};
switch (target) {
.app => {},
.surface => |surface| surface.core_surface.preeditCallback(null) catch {},
}
}
break :consumed true;
},
};
}
/// This should be called whenever the keyboard layout was changed.
pub fn reloadKeymap(self: *App) !void {
// Reload the keymap
@ -349,20 +604,6 @@ pub const Surface = struct {
command: [*:0]const u8 = "",
};
/// This is the key event sent for ghostty_surface_key.
pub const KeyEvent = struct {
/// The three below are absolutely required.
action: input.Action,
mods: input.Mods,
keycode: u32,
/// Optionally, the embedder can handle text translation and send
/// the text value here. If text is non-nil, it is assumed that the
/// embedder also handles dead key states and sets composing as necessary.
text: ?[:0]const u8,
composing: bool,
};
pub fn init(self: *Surface, app: *App, opts: Options) !void {
self.* = .{
.app = app,
@ -800,198 +1041,6 @@ pub const Surface = struct {
};
}
pub fn keyCallback(
self: *Surface,
event: KeyEvent,
) !void {
const action = event.action;
const keycode = event.keycode;
const mods = event.mods;
// True if this is a key down event
const is_down = action == .press or action == .repeat;
// If we're on macOS and we have macos-option-as-alt enabled,
// then we strip the alt modifier from the mods for translation.
const translate_mods = translate_mods: {
var translate_mods = mods;
if (comptime builtin.target.isDarwin()) {
const strip = switch (self.app.config.@"macos-option-as-alt") {
.false => false,
.true => mods.alt,
.left => mods.sides.alt == .left,
.right => mods.sides.alt == .right,
};
if (strip) translate_mods.alt = false;
}
// On macOS we strip ctrl because UCKeyTranslate
// converts to the masked values (i.e. ctrl+c becomes 3)
// and we don't want that behavior.
//
// We also strip super because its not used for translation
// on macos and it results in a bad translation.
if (comptime builtin.target.isDarwin()) {
translate_mods.ctrl = false;
translate_mods.super = false;
}
break :translate_mods translate_mods;
};
const event_text: ?[]const u8 = event_text: {
// This logic only applies to macOS.
if (comptime builtin.os.tag != .macos) break :event_text event.text;
// If the modifiers are ONLY "control" then we never process
// the event text because we want to do our own translation so
// we can handle ctrl+c, ctrl+z, etc.
//
// This is specifically because on macOS using the
// "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as
// "/" (the physical key that is z on a qwerty keyboard). But on
// other layouts, ctrl+<char> is not translated by AppKit. So,
// we just avoid this by never allowing AppKit to translate
// ctrl+<char> and instead do it ourselves.
const ctrl_only = comptime (input.Mods{ .ctrl = true }).int();
break :event_text if (mods.binding().int() == ctrl_only) null else event.text;
};
// Translate our key using the keymap for our localized keyboard layout.
// We only translate for keydown events. Otherwise, we only care about
// the raw keycode.
var buf: [128]u8 = undefined;
const result: input.Keymap.Translation = if (is_down) translate: {
// If the event provided us with text, then we use this as a result
// and do not do manual translation.
const result: input.Keymap.Translation = if (event_text) |text| .{
.text = text,
.composing = event.composing,
} else try self.app.keymap.translate(
&buf,
&self.keymap_state,
@intCast(keycode),
translate_mods,
);
// If this is a dead key, then we're composing a character and
// we need to set our proper preedit state.
if (result.composing) {
self.core_surface.preeditCallback(result.text) catch |err| {
log.err("error in preedit callback err={}", .{err});
return;
};
} else {
// If we aren't composing, then we set our preedit to
// empty no matter what.
self.core_surface.preeditCallback(null) catch {};
// If the text is just a single non-printable ASCII character
// then we clear the text. We handle non-printables in the
// key encoder manual (such as tab, ctrl+c, etc.)
if (result.text.len == 1 and result.text[0] < 0x20) {
break :translate .{ .composing = false, .text = "" };
}
}
break :translate result;
} else .{ .composing = false, .text = "" };
// UCKeyTranslate always consumes all mods, so if we have any output
// then we've consumed our translate mods.
const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{};
// We need to always do a translation with no modifiers at all in
// order to get the "unshifted_codepoint" for the key event.
const unshifted_codepoint: u21 = unshifted: {
var nomod_buf: [128]u8 = undefined;
var nomod_state: input.Keymap.State = .{};
const nomod = try self.app.keymap.translate(
&nomod_buf,
&nomod_state,
@intCast(keycode),
.{},
);
const view = std.unicode.Utf8View.init(nomod.text) catch |err| {
log.warn("cannot build utf8 view over text: {}", .{err});
break :unshifted 0;
};
var it = view.iterator();
break :unshifted it.nextCodepoint() orelse 0;
};
// log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{
// action,
// keycode,
// result.composing,
// result.text.len,
// result.text,
// result.text,
// mods,
// });
// We want to get the physical unmapped key to process keybinds.
const physical_key = keycode: for (input.keycodes.entries) |entry| {
if (entry.native == keycode) break :keycode entry.key;
} else .invalid;
// If the resulting text has length 1 then we can take its key
// and attempt to translate it to a key enum and call the key callback.
// If the length is greater than 1 then we're going to call the
// charCallback.
//
// We also only do key translation if this is not a dead key.
const key = if (!result.composing) key: {
// If our physical key is a keypad key, we use that.
if (physical_key.keypad()) break :key physical_key;
// A completed key. If the length of the key is one then we can
// attempt to translate it to a key enum and call the key
// callback. First try plain ASCII.
if (result.text.len > 0) {
if (input.Key.fromASCII(result.text[0])) |key| {
break :key key;
}
}
// If the above doesn't work, we use the unmodified value.
if (std.math.cast(u8, unshifted_codepoint)) |ascii| {
if (input.Key.fromASCII(ascii)) |key| {
break :key key;
}
}
break :key physical_key;
} else .invalid;
// Invoke the core Ghostty logic to handle this input.
const effect = self.core_surface.keyCallback(.{
.action = action,
.key = key,
.physical_key = physical_key,
.mods = mods,
.consumed_mods = consumed_mods,
.composing = result.composing,
.utf8 = result.text,
.unshifted_codepoint = unshifted_codepoint,
}) catch |err| {
log.err("error in key callback err={}", .{err});
return;
};
switch (effect) {
.closed => return,
.ignored => {},
.consumed => if (is_down) {
// If we consume the key then we want to reset the dead
// key state.
self.keymap_state = .{};
self.core_surface.preeditCallback(null) catch {};
},
}
}
pub fn textCallback(self: *Surface, text: []const u8) void {
_ = self.core_surface.textCallback(text) catch |err| {
log.err("error in key callback err={}", .{err});
@ -1411,7 +1460,7 @@ pub const CAPI = struct {
composing: bool,
/// Convert to surface key event.
fn keyEvent(self: KeyEvent) Surface.KeyEvent {
fn keyEvent(self: KeyEvent) App.KeyEvent {
return .{
.action = self.action,
.mods = @bitCast(@as(
@ -1497,6 +1546,19 @@ pub const CAPI = struct {
core_app.destroy();
}
/// Notify the app of a global keypress capture. This will return
/// true if the key was captured by the app, in which case the caller
/// should not process the key.
export fn ghostty_app_key(
app: *App,
event: KeyEvent,
) bool {
return app.keyEvent(.app, event.keyEvent()) catch |err| {
log.warn("error processing key event err={}", .{err});
return false;
};
}
/// Notify the app that the keyboard was changed. This causes the
/// keyboard layout to be reloaded from the OS.
export fn ghostty_app_keyboard_changed(v: *App) void {
@ -1690,16 +1752,15 @@ pub const CAPI = struct {
/// 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.
///
/// You do NOT need to also send "ghostty_surface_char" unless
/// you want to send a unicode character that is not associated
/// with a keypress, i.e. IME keyboard.
export fn ghostty_surface_key(
surface: *Surface,
event: KeyEvent,
) void {
surface.keyCallback(event.keyEvent()) catch |err| {
log.err("error processing key event err={}", .{err});
_ = surface.app.keyEvent(
.{ .surface = surface },
event.keyEvent(),
) catch |err| {
log.warn("error processing key event err={}", .{err});
return;
};
}

View File

@ -6,6 +6,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const key = @import("key.zig");
const KeyEvent = key.KeyEvent;
/// The trigger that needs to be performed to execute the action.
trigger: Trigger,
@ -1254,6 +1255,31 @@ pub const Set = struct {
return self.reverse.get(a);
}
/// Get an entry for the given key event. This will attempt to find
/// a binding using multiple parts of the event in the following order:
///
/// 1. Translated key (event.key)
/// 2. Physical key (event.physical_key)
/// 3. Unshifted Unicode codepoint (event.unshifted_codepoint)
///
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
var trigger: Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (self.get(trigger)) |v| return v;
trigger.key = .{ .physical = event.physical_key };
if (self.get(trigger)) |v| return v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (self.get(trigger)) |v| return v;
}
return null;
}
/// Remove a binding for a given trigger.
pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void {
const entry = self.bindings.get(t) orelse return;