diff --git a/include/ghostty.h b/include/ghostty.h index 676cbd5e0..77671140f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d2b3cff83..9bff35757 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -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 = ""; }; + A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { + isa = PBXGroup; + children = ( + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, + ); + path = "Global Keybinds"; + sourceTree = ""; + }; 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 */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 41815631d..76dfdb5ec 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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. diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift new file mode 100644 index 000000000..3a768df79 --- /dev/null +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -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? { + 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 +} diff --git a/src/App.zig b/src/App.zig index 4b9c2673e..31f3e451b 100644 --- a/src/App.zig +++ b/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. diff --git a/src/Surface.zig b/src/Surface.zig index 49017883a..b5f7b9293 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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 diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index be3df896a..e0eecf8cc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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+ is not translated by AppKit. So, + // we just avoid this by never allowing AppKit to translate + // ctrl+ 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+ is not translated by AppKit. So, - // we just avoid this by never allowing AppKit to translate - // ctrl+ 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; }; } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 20fd80716..93e046d1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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;