diff --git a/include/ghostty.h b/include/ghostty.h index c64ce0160..b0e5c3fd6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -88,12 +88,13 @@ typedef enum { GHOSTTY_MODS_CTRL = 1 << 1, GHOSTTY_MODS_ALT = 1 << 2, GHOSTTY_MODS_SUPER = 1 << 3, - GHOSTTY_MODS_CAPS = 1 << 4, - GHOSTTY_MODS_NUM = 1 << 5, - GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, - GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, - GHOSTTY_MODS_ALT_RIGHT = 1 << 8, - GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, + GHOSTTY_MODS_FN = 1 << 4, + GHOSTTY_MODS_CAPS = 1 << 5, + GHOSTTY_MODS_NUM = 1 << 6, + GHOSTTY_MODS_SHIFT_RIGHT = 1 << 7, + GHOSTTY_MODS_CTRL_RIGHT = 1 << 8, + GHOSTTY_MODS_ALT_RIGHT = 1 << 9, + GHOSTTY_MODS_SUPER_RIGHT = 1 << 10, } ghostty_input_mods_e; typedef enum { diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 9fdc8c7a2..d6af4d296 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -149,6 +149,12 @@ class AppDelegate: NSObject, // This registers the Ghostty => Services menu to exist. NSApp.servicesMenu = menuServices + // Setup a local event monitor for app-level keyboard shortcuts. See + // localEventHandler for more info why. + _ = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown], + handler: localEventHandler) + // Configure user notifications let actions = [ UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") @@ -348,6 +354,11 @@ class AppDelegate: NSObject, return } + if (equiv.modifiers.contains(.function)) { + // NSMenuItem key equivalent cannot contain function modifiers. + return + } + menu.keyEquivalent = equiv.key menu.keyEquivalentModifierMask = equiv.modifiers } @@ -356,6 +367,53 @@ class AppDelegate: NSObject, return terminalManager.focusedSurface?.surface } + // MARK: Notifications and Events + + /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get + /// events without any terminal windows open. + private func localEventHandler(_ event: NSEvent) -> NSEvent? { + return switch event.type { + case .keyDown: + localEventKeyDown(event) + + default: + event + } + } + + private func localEventKeyDown(_ event: NSEvent) -> NSEvent? { + // If we have a main window then we don't process any of the keys + // because we let it capture and propagate. + guard NSApp.mainWindow == nil else { return event } + + // If this event would be handled by our menu then we do nothing. + if let mainMenu = NSApp.mainMenu, + mainMenu.performKeyEquivalent(with: event) { + return nil + } + + // If we reach this point then we try to process the key event + // through the Ghostty key mechanism. + + // Ghostty must be loaded + 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)) { + // 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 + } + + return event + } + //MARK: - Restorable State /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9198e48b6..3ed082d87 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -87,7 +87,7 @@ extension Ghostty { // Subscribe to notifications for keyboard layout change so that we can update Ghostty. NotificationCenter.default.addObserver( self, - selector: #selector(self.keyboardSelectionDidChange(notification:)), + selector: #selector(keyboardSelectionDidChange(notification:)), name: NSTextInputContext.keyboardSelectionDidChangeNotification, object: nil) #endif diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index d7fd96f12..89013cab8 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -14,6 +14,7 @@ extension Ghostty { if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0) { flags.insert(.control) } if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0) { flags.insert(.option) } if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.insert(.command) } + if (mods.rawValue & GHOSTTY_MODS_FN.rawValue != 0) { flags.insert(.function) } return flags } @@ -25,6 +26,7 @@ extension Ghostty { if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } + if (flags.contains(.function)) { mods |= GHOSTTY_MODS_FN.rawValue } if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } // Handle sided input. We can't tell that both are pressed in the diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index ead898bdd..592611f32 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -726,6 +726,7 @@ extension Ghostty { case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue + case 0x3F: mod = GHOSTTY_MODS_FN.rawValue default: return } diff --git a/src/App.zig b/src/App.zig index 7e82bf007..df305ae45 100644 --- a/src/App.zig +++ b/src/App.zig @@ -294,9 +294,6 @@ pub fn keyEvent( .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={}", .{ diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 9e734d1ec..5cfaeef02 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -133,6 +133,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const ctrl_style: vaxis.Style = .{ .fg = .{ .index = 2 } }; const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } }; const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } }; + const fn_style: vaxis.Style = .{ .fg = .{ .index = 5 } }; var longest_col: usize = 0; @@ -142,6 +143,10 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false }; const trigger = bind.trigger; + if (trigger.mods.function) { + result = try win.printSegment(.{ .text = "fn ", .style = fn_style }, .{ .col_offset = result.col }); + result = try win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); + } if (trigger.mods.super) { result = try win.printSegment(.{ .text = "super", .style = super_style }, .{ .col_offset = result.col }); result = try win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); diff --git a/src/config/Config.zig b/src/config/Config.zig index c0b1b9daf..a4b1e0a21 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -670,9 +670,22 @@ class: ?[:0]const u8 = null, /// translated by any system keyboard layouts. Example: "ctrl+physical:a" /// /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, -/// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier -/// or the alias. When debugging keybinds, the non-aliased modifier will always -/// be used in output. +/// `option`), `super` (alias: `cmd`, `command`), and `function` (alias: `fn`, +/// `globe`). You may use the modifier or the alias. When debugging keybinds, +/// the non-aliased modifier will always be used in output. +/// +/// Some notes about the `function` modifier: +/// +/// * It is only available on macOS. +/// * It is used by many system shortcuts and Ghostty is not able to +/// override these shortcuts. If a system shortcut is triggered, the +/// system shortcut will take precedence. +/// * If you have multiple keyboard layouts active and don't press the +/// combination fast enough, macOS will switch to the next keyboard +/// layout. +/// * Menu items on macOS cannot be bound to the `function` modifier, +/// so this modifier will work with Ghostty but will not be visible +/// in the menu. /// /// You may also specify multiple triggers separated by `>` to require a /// sequence of triggers to activate the action. For example, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9f4ebd626..1d0e26b9e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -896,9 +896,13 @@ pub const Trigger = struct { // Alias modifiers const alias_mods = .{ - .{ "cmd", "super" }, .{ "command", "super" }, - .{ "opt", "alt" }, .{ "option", "alt" }, + .{ "cmd", "super" }, + .{ "command", "super" }, + .{ "opt", "alt" }, + .{ "option", "alt" }, .{ "control", "ctrl" }, + .{ "fn", "function" }, + .{ "globe", "function" }, }; inline for (alias_mods) |pair| { if (std.mem.eql(u8, part, pair[0])) { diff --git a/src/input/key.zig b/src/input/key.zig index 8fc7c6f20..3aa85bf01 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -89,10 +89,11 @@ pub const Mods = packed struct(Mods.Backing) { ctrl: bool = false, alt: bool = false, super: bool = false, + function: bool = false, caps_lock: bool = false, num_lock: bool = false, sides: side = .{}, - _padding: u6 = 0, + _padding: u5 = 0, /// Tracks the side that is active for any given modifier. Note /// that this doesn't confirm a modifier is pressed; you must check