From 4d103ca16d657ef95748fe4a141e9934217a0559 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 13:40:47 -0800 Subject: [PATCH 1/6] core: add keyEventIsBinding This API can be used to determine if the next key event, if given as-is, would result in a key binding being triggered. --- include/ghostty.h | 1 + src/Surface.zig | 27 ++++++++++++++++++- src/apprt/embedded.zig | 59 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cbb77f00c..b88fd9888 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_button(ghostty_surface_t, diff --git a/src/Surface.zig b/src/Surface.zig index 389e7f7e4..214bdae7e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1637,13 +1637,38 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { try self.queueRender(); } +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +/// +/// Note that this function does not check if the binding itself +/// is performable, only if the key event would trigger a binding. +/// If a performable binding is found and the event is not performable, +/// then Ghosty will act as though the binding does not exist. +pub fn keyEventIsBinding( + self: *Surface, + event: input.KeyEvent, +) bool { + switch (event.action) { + .release => return false, + .press, .repeat => {}, + } + + // Our keybinding set is either our current nested set (for + // sequences) or the root set. + const set = self.keyboard.bindings orelse &self.config.keybind.set; + + // If we have a keybinding for this event then we return true. + return set.getEvent(event) != null; +} + /// Called for any key events. This handles keybindings, encoding and /// sending to the terminal, etc. pub fn keyCallback( self: *Surface, event: input.KeyEvent, ) !InputEffect { - // log.debug("text keyCallback event={}", .{event}); + log.debug("text keyCallback event={}", .{event}); // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 59f81e694..3de9e4281 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -147,12 +147,12 @@ pub const App = struct { self.core_app.focusEvent(focused); } - /// See CoreApp.keyEvent. - pub fn keyEvent( + /// Convert a C key event into a Zig key event. + fn coreKeyEvent( self: *App, target: KeyTarget, event: KeyEvent, - ) !bool { + ) !?input.KeyEvent { const action = event.action; const keycode = event.keycode; const mods = event.mods; @@ -243,7 +243,7 @@ pub const App = struct { result.text, ) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } } else { @@ -251,7 +251,7 @@ pub const App = struct { .app => {}, .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } @@ -335,7 +335,7 @@ pub const App = struct { } else .invalid; // Build our final key event - const input_event: input.KeyEvent = .{ + return .{ .action = action, .key = key, .physical_key = physical_key, @@ -345,24 +345,39 @@ pub const App = struct { .utf8 = result.text, .unshifted_codepoint = unshifted_codepoint, }; + } + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + // Convert our C key event into a Zig one. + const input_event: input.KeyEvent = (try self.coreKeyEvent( + target, + event, + )) orelse return false; // 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, + )) .consumed else .ignored, - .surface => |surface| try surface.core_surface.keyCallback(input_event), + .surface => |surface| try surface.core_surface.keyCallback( + input_event, + ), }; return switch (effect) { .closed => true, .ignored => false, .consumed => consumed: { + const is_down = input_event.action == .press or + input_event.action == .repeat; + if (is_down) { // If we consume the key then we want to reset the dead // key state. @@ -1601,6 +1616,28 @@ pub const CAPI = struct { }; } + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_surface_key_is_binding( + surface: *Surface, + event: KeyEvent, + ) bool { + const core_event = surface.app.coreKeyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + } orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return surface.core_surface.keyEventIsBinding(core_event); + } + /// Send raw text to the terminal. This is treated like a paste /// so this isn't useful for sending escape sequences. For that, /// individual key input should be used. From 8b8c53fc4cbc21118ee35f16eb8cc71c22932fc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 14:36:49 -0800 Subject: [PATCH 2/6] macos: add NSEvent extension to convert to libghostty key events --- macos/Ghostty.xcodeproj/project.pbxproj | 12 +++++++---- macos/Sources/App/macOS/AppDelegate.swift | 8 +------- macos/Sources/Ghostty/NSEvent+Extension.swift | 15 ++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 20 +++++-------------- 4 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 macos/Sources/Ghostty/NSEvent+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 42479f0b3..fd4f10f24 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -10,8 +10,8 @@ 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; - 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; + 9351BE8E3D22937F003B3499 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* vim */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -87,6 +87,7 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; @@ -108,8 +109,8 @@ 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; - 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; + 9351BE8E2D22937F003B3499 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; @@ -177,6 +178,7 @@ A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = ""; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = ""; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -357,6 +359,7 @@ A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, + A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, ); path = Ghostty; sourceTree = ""; @@ -405,7 +408,7 @@ A5985CE52C33060F00C57AD3 /* man */, A5A1F8842A489D6800D1E8BC /* terminfo */, FC5218F92D10FFC7004C93E0 /* zsh */, - 9351BE8E2D22937F003B3499 /* nvim */, + 9351BE8E2D22937F003B3499 /* vim */, ); name = Resources; sourceTree = ""; @@ -582,7 +585,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, - 9351BE8E3D22937F003B3499 /* nvim in Resources */, + 9351BE8E3D22937F003B3499 /* vim in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -611,6 +614,7 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8564bbb1e..513a6872e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -438,13 +438,7 @@ class AppDelegate: NSObject, guard let ghostty = self.ghostty.app else { return event } // 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)) { + if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { // The key was used so we want to stop it from going to our Mac app Ghostty.logger.debug("local key event handled event=\(event)") return nil diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift new file mode 100644 index 000000000..4118cd94d --- /dev/null +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -0,0 +1,15 @@ +import Cocoa +import GhosttyKit + +extension NSEvent { + /// Create a Ghostty key event for a given keyboard action. + func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s { + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(modifierFlags) + key_ev.keycode = UInt32(keyCode) + key_ev.text = nil + key_ev.composing = false + return key_ev + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2cac4a0dd..8e68161b1 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -810,6 +810,8 @@ extension Ghostty { return false } + // If this event as-is would result in a key event then + // Only process keys when Control is active. All known issues we're // resolving happen only in this scenario. This probably isn't fully robust // but we can broaden the scope as we find more cases. @@ -903,23 +905,14 @@ extension Ghostty { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let surface = self.surface else { return } - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false - ghostty_surface_key(surface, key_ev) + ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) } private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { guard let surface = self.surface else { return } preedit.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) + var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr key_ev.composing = true ghostty_surface_key(surface, key_ev) @@ -930,10 +923,7 @@ extension Ghostty { guard let surface = self.surface else { return } text.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) + var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr ghostty_surface_key(surface, key_ev) } From 4031815a8db163950b97de18026003c10b41eac7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 14:39:22 -0800 Subject: [PATCH 3/6] macos: if a key event would result in an immediate binding then do it --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8e68161b1..634224c8e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -810,7 +810,14 @@ extension Ghostty { return false } - // If this event as-is would result in a key event then + // If this event as-is would result in a key binding then we send it. + if let surface, + ghostty_surface_key_is_binding( + surface, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + self.keyDown(with: event) + return true + } // Only process keys when Control is active. All known issues we're // resolving happen only in this scenario. This probably isn't fully robust From 3e89c4c2f4203009fb03bcbb04d3c55ae98be0ee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 12:45:03 -0800 Subject: [PATCH 4/6] Key events return boolean if handled --- include/ghostty.h | 2 +- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/Ghostty/Ghostty.Event.swift | 15 ++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 74 ++++++++++++------- src/apprt/embedded.zig | 6 +- 5 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 macos/Sources/Ghostty/Ghostty.Event.swift diff --git a/include/ghostty.h b/include/ghostty.h index b88fd9888..8af181051 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -713,7 +713,7 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); -void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fd4f10f24..adb27338b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; }; + A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; @@ -179,6 +180,7 @@ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = ""; }; + A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Event.swift; sourceTree = ""; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = ""; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -353,6 +355,7 @@ A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, + A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, @@ -621,6 +624,7 @@ A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, + A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, diff --git a/macos/Sources/Ghostty/Ghostty.Event.swift b/macos/Sources/Ghostty/Ghostty.Event.swift new file mode 100644 index 000000000..1cde50ee7 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Event.swift @@ -0,0 +1,15 @@ +import Cocoa +import GhosttyKit + +extension Ghostty { + /// A comparable event. + struct ComparableKeyEvent: Equatable { + let keyCode: UInt16 + let flags: NSEvent.ModifierFlags + + init(event: NSEvent) { + self.keyCode = event.keyCode + self.flags = event.modifierFlags + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 634224c8e..d5dcd12ce 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -773,7 +773,7 @@ extension Ghostty { if let list = keyTextAccumulator, list.count > 0 { handled = true for text in list { - keyAction(action, event: event, text: text) + _ = keyAction(action, event: event, text: text) } } @@ -783,29 +783,38 @@ extension Ghostty { // the preedit. if (markedText.length > 0 || markedTextBefore) { handled = true - keyAction(action, event: event, preedit: markedText.string) + _ = keyAction(action, event: event, preedit: markedText.string) } if (!handled) { // No text or anything, we want to handle this manually. - keyAction(action, event: event) + _ = keyAction(action, event: event) } } override func keyUp(with event: NSEvent) { - keyAction(GHOSTTY_ACTION_RELEASE, event: event) + _ = keyAction(GHOSTTY_ACTION_RELEASE, event: event) } /// Special case handling for some control keys override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Only process key down events - if (event.type != .keyDown) { + switch (event.type) { + case .keyDown: + // Continue, we care about key down events + break + + default: + // Any other key event we don't care about. I don't think its even + // possible to receive any other event type. return false } // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the // the first responder (I don't know why). This prevents us from handling it. + // Besides C-/, its important we don't process key equivalents if unfocused + // because there are other event listeners for that (i.e. AppDelegate's + // local event handler). if (!focused) { return false } @@ -819,13 +828,6 @@ extension Ghostty { return true } - // Only process keys when Control is active. All known issues we're - // resolving happen only in this scenario. This probably isn't fully robust - // but we can broaden the scope as we find more cases. - if (!event.modifierFlags.contains(.control)) { - return false - } - let equivalent: String switch (event.charactersIgnoringModifiers) { case "/": @@ -841,14 +843,25 @@ extension Ghostty { case "\r": // Pass C- through verbatim // (prevent the default context menu equivalent) + if (!event.modifierFlags.contains(.control)) { + return false + } + equivalent = "\r" + case ".": + if (!event.modifierFlags.contains(.command)) { + return false + } + + equivalent = "." + default: // Ignore other events return false } - let newEvent = NSEvent.keyEvent( + let finalEvent = NSEvent.keyEvent( with: .keyDown, location: event.locationInWindow, modifierFlags: event.modifierFlags, @@ -861,7 +874,7 @@ extension Ghostty { keyCode: event.keyCode ) - self.keyDown(with: newEvent!) + self.keyDown(with: finalEvent!) return true } @@ -906,33 +919,38 @@ extension Ghostty { } } - keyAction(action, event: event) + _ = keyAction(action, event: event) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { - guard let surface = self.surface else { return } - - ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool { + guard let surface = self.surface else { return false } + return ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { - guard let surface = self.surface else { return } + private func keyAction( + _ action: ghostty_input_action_e, + event: NSEvent, preedit: String + ) -> Bool { + guard let surface = self.surface else { return false } - preedit.withCString { ptr in + return preedit.withCString { ptr in var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr key_ev.composing = true - ghostty_surface_key(surface, key_ev) + return ghostty_surface_key(surface, key_ev) } } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { - guard let surface = self.surface else { return } + private func keyAction( + _ action: ghostty_input_action_e, + event: NSEvent, text: String + ) -> Bool { + guard let surface = self.surface else { return false } - text.withCString { ptr in + return text.withCString { ptr in var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr - ghostty_surface_key(surface, key_ev) + return ghostty_surface_key(surface, key_ev) } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 3de9e4281..758a3ff87 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1606,13 +1606,13 @@ pub const CAPI = struct { export fn ghostty_surface_key( surface: *Surface, event: KeyEvent, - ) void { - _ = surface.app.keyEvent( + ) bool { + return surface.app.keyEvent( .{ .surface = surface }, event.keyEvent(), ) catch |err| { log.warn("error processing key event err={}", .{err}); - return; + return false; }; } From 1bcfff3b794f28ca3b6134e64e0ca6a39f8f8be1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 13:43:36 -0800 Subject: [PATCH 5/6] macos: manual send keyUp event for command key --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 41 +++++++++++++++++++ src/Surface.zig | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d5dcd12ce..297ca8ea0 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -113,6 +113,9 @@ extension Ghostty { // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? + /// Event monitor (see individual events for why) + private var eventMonitor: Any? = nil + // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -170,6 +173,15 @@ extension Ghostty { name: NSWindow.didChangeScreenNotification, object: nil) + // Listen for local events that we need to know of outside of + // single surface handlers. + self.eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [ + // We need keyUp because command+key events don't trigger keyUp. + .keyUp + ] + ) { [weak self] event in self?.localEventHandler(event) } + // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) @@ -212,6 +224,11 @@ extension Ghostty { let center = NotificationCenter.default center.removeObserver(self) + // Remove our event monitor + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + // Whenever the surface is removed, we need to note that our restorable // state is invalid to prevent the surface from being restored. invalidateRestorableState() @@ -356,6 +373,30 @@ extension Ghostty { } } + // MARK: Local Events + + private func localEventHandler(_ event: NSEvent) -> NSEvent? { + return switch event.type { + case .keyUp: + localEventKeyUp(event) + + default: + event + } + } + + private func localEventKeyUp(_ event: NSEvent) -> NSEvent? { + // We only care about events with "command" because all others will + // trigger the normal responder chain. + if (!event.modifierFlags.contains(.command)) { return event } + + // Command keyUp events are never sent to the normal responder chain + // so we send them here. + guard focused else { return event } + self.keyUp(with: event) + return nil + } + // MARK: - Notifications @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { diff --git a/src/Surface.zig b/src/Surface.zig index 214bdae7e..1dc10fb27 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1668,7 +1668,7 @@ pub fn keyCallback( self: *Surface, event: input.KeyEvent, ) !InputEffect { - log.debug("text keyCallback event={}", .{event}); + // log.debug("text keyCallback event={}", .{event}); // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); From 40bdea73357ded7a3a753ee8f26d65a07434f087 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 14:07:33 -0800 Subject: [PATCH 6/6] macos: handle overridden system bindings with no focused window --- include/ghostty.h | 1 + macos/Sources/App/macOS/AppDelegate.swift | 9 +++++++++ src/App.zig | 19 +++++++++++++++++++ src/apprt/embedded.zig | 22 ++++++++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 8af181051..0e444a2fa 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -686,6 +686,7 @@ void ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); void ghostty_app_set_focus(ghostty_app_t, bool); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); +bool ghostty_app_key_is_binding(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_update_config(ghostty_app_t, ghostty_config_t); diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 513a6872e..70873236a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -425,6 +425,15 @@ class AppDelegate: NSObject, // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } + // If this event as-is would result in a key binding then we send it. + if let app = ghostty.app, + ghostty_app_key_is_binding( + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + ghostty_app_key(app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) + return nil + } + // If this event would be handled by our menu then we do nothing. if let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { diff --git a/src/App.zig b/src/App.zig index b0de85c95..a6b54db23 100644 --- a/src/App.zig +++ b/src/App.zig @@ -313,6 +313,25 @@ pub fn focusEvent(self: *App, focused: bool) void { self.focused = focused; } +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +pub fn keyEventIsBinding( + self: *App, + rt_app: *apprt.App, + event: input.KeyEvent, +) bool { + _ = self; + + switch (event.action) { + .release => return false, + .press, .repeat => {}, + } + + // If we have a keybinding for this event then we return true. + return rt_app.config.keybind.set.getEvent(event) != null; +} + /// 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. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 758a3ff87..10d09988d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1386,6 +1386,28 @@ pub const CAPI = struct { }; } + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_app_key_is_binding( + app: *App, + event: KeyEvent, + ) bool { + const core_event = app.coreKeyEvent( + .app, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + } orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return app.core_app.keyEventIsBinding(app, core_event); + } + /// 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 {