mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
Tap events, core API to handle global keybinds
This commit is contained in:
@ -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);
|
||||
|
@ -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 */,
|
||||
|
@ -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.
|
||||
|
150
macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
Normal file
150
macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
Normal 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
|
||||
}
|
43
src/App.zig
43
src/App.zig
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user