diff --git a/include/ghostty.h b/include/ghostty.h index 072a8536a..77671140f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -527,10 +527,12 @@ 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); bool ghostty_app_needs_confirm_quit(ghostty_app_t); +bool ghostty_app_has_global_keybinds(ghostty_app_t); ghostty_surface_config_s ghostty_surface_config_new(); 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..44f0f0291 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -63,6 +63,10 @@ class AppDelegate: NSObject, /// This is only true before application has become active. private var applicationHasBecomeActive: Bool = false + /// This is set in applicationDidFinishLaunching with the system uptime so we can determine the + /// seconds since the process was launched. + private var applicationLaunchTime: TimeInterval = 0 + /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() @@ -73,6 +77,11 @@ class AppDelegate: NSObject, let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() + /// The elapsed time since the process was started + var timeSinceLaunch: TimeInterval { + return ProcessInfo.processInfo.systemUptime - applicationLaunchTime + } + override init() { terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( @@ -106,6 +115,9 @@ class AppDelegate: NSObject, "ApplePressAndHoldEnabled": false, ]) + // Store our start time + applicationLaunchTime = ProcessInfo.processInfo.systemUptime + // Check if secure input was enabled when we last quit. if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { toggleSecureInput(self) @@ -418,6 +430,25 @@ class AppDelegate: NSObject, c.showWindow(self) } } + + // We need to handle our global event tap depending on if there are global + // events that we care about in Ghostty. + if (ghostty_app_has_global_keybinds(ghostty.app!)) { + if (timeSinceLaunch > 5) { + // If the process has been running for awhile we enable right away + // because no windows are likely to pop up. + GlobalEventTap.shared.enable() + } else { + // If the process just started, we wait a couple seconds to allow + // the initial windows and so on to load so our permissions dialog + // doesn't get buried. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + GlobalEventTap.shared.enable() + } + } + } else { + GlobalEventTap.shared.disable() + } } /// 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..f1bb93506 --- /dev/null +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -0,0 +1,151 @@ +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 so it can't be constructed outside of our singleton + 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 permissions 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/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 8b9ed3cad..3930012df 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -78,6 +78,12 @@ class TerminalManager { window.toggleFullScreen(nil) } + // If our app isn't active, we make it active. All new_window actions + // force our app to be active. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 7b8c5688f..30efb289e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -631,7 +631,11 @@ extension Ghostty { } static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - let surface = self.surfaceUserdata(from: userdata) + let surface: SurfaceView? = if let userdata { + self.surfaceUserdata(from: userdata) + } else { + nil + } NotificationCenter.default.post( name: Notification.ghosttyNewWindow, diff --git a/src/App.zig b/src/App.zig index f933b7126..d93e00a2a 100644 --- a/src/App.zig +++ b/src/App.zig @@ -262,6 +262,102 @@ 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. +pub fn performAction( + self: *App, + rt_app: *apprt.App, + action: input.Binding.Action.Scoped(.app), +) !void { + switch (action) { + .unbind => unreachable, + .ignore => {}, + .quit => try self.setQuit(), + .new_window => try self.newWindow(rt_app, .{ .parent = null }), + .open_config => try self.openConfig(rt_app), + .reload_config => try self.reloadConfig(rt_app), + .close_all_windows => { + if (@hasDecl(apprt.App, "closeAllWindows")) { + rt_app.closeAllWindows(); + } else log.warn("runtime doesn't implement closeAllWindows", .{}); + }, + } +} + +/// Perform an app-wide binding action. If the action is surface-specific +/// then it will be performed on all surfaces. To perform only app-scoped +/// actions, use performAction. +pub fn performAllAction( + self: *App, + rt_app: *apprt.App, + action: input.Binding.Action, +) !void { + switch (action.scope()) { + // App-scoped actions are handled by the app so that they aren't + // repeated for each surface (since each surface forwards + // app-scoped actions back up). + .app => try self.performAction( + rt_app, + action.scoped(.app).?, // asserted through the scope match + ), + + // Surface-scoped actions are performed on all surfaces. Errors + // are logged but processing continues. + .surface => for (self.surfaces.items) |surface| { + _ = surface.core_surface.performBindingAction(action) catch |err| { + log.warn("error performing binding action on surface ptr={X} err={}", .{ + @intFromPtr(surface), + err, + }); + }; + }, + } +} + /// Handle a window message fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void { // We want to ensure our window is still active. Window messages diff --git a/src/Surface.zig b/src/Surface.zig index a9b2c17d6..85a5face0 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 @@ -1590,7 +1579,7 @@ fn maybeHandleBinding( }; // Determine if this entry has an action or if its a leader key. - const action: input.Binding.Action, const consumed: bool = switch (entry) { + const leaf: input.Binding.Set.Leaf = switch (entry) { .leader => |set| { // Setup the next set we'll look at. self.keyboard.bindings = set; @@ -1605,8 +1594,20 @@ fn maybeHandleBinding( return .consumed; }, - .action => |v| .{ v, true }, - .action_unconsumed => |v| .{ v, false }, + .leaf => |leaf| leaf, + }; + const action = leaf.action; + + // consumed determines if the input is consumed or if we continue + // encoding the key (if we have a key to encode). + const consumed = consumed: { + // If the consumed flag is explicitly set, then we are consumed. + if (leaf.flags.consumed) break :consumed true; + + // If the global or all flag is set, we always consume. + if (leaf.flags.global or leaf.flags.all) break :consumed true; + + break :consumed false; }; // We have an action, so at this point we're handling SOMETHING so @@ -1618,8 +1619,22 @@ fn maybeHandleBinding( self.keyboard.bindings = null; // Attempt to perform the action - log.debug("key event binding consumed={} action={}", .{ consumed, action }); - const performed = try self.performBindingAction(action); + log.debug("key event binding flags={} action={}", .{ + leaf.flags, + action, + }); + const performed = performed: { + // If this is a global or all action, then we perform it on + // the app and it applies to every surface. + if (leaf.flags.global or leaf.flags.all) { + try self.app.performAllAction(self.rt_app, action); + + // "All" actions are always performed since they are global. + break :performed true; + } + + break :performed try self.performBindingAction(action); + }; // If we performed an action and it was a closing action, // our "self" pointer is not safe to use anymore so we need to @@ -3401,14 +3416,25 @@ fn showMouse(self: *Surface) void { /// will ever return false. We can expand this in the future if it becomes /// useful. We did previous/next tab so we could implement #498. pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { - switch (action) { - .unbind => unreachable, - .ignore => {}, + // Forward app-scoped actions to the app. Some app-scoped actions are + // special-cased here because they do some special things when performed + // from the surface. + if (action.scoped(.app)) |app_action| { + switch (app_action) { + .new_window => try self.app.newWindow( + self.rt_app, + .{ .parent = self }, + ), - .open_config => try self.app.openConfig(self.rt_app), - - .reload_config => try self.app.reloadConfig(self.rt_app), + else => try self.app.performAction( + self.rt_app, + action.scoped(.app).?, + ), + } + return true; + } + switch (action.scoped(.surface).?) { .csi, .esc => |data| { // We need to send the CSI/ESC sequence as a single write request. // If you split it across two then the shell can interpret it @@ -3630,8 +3656,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool v, ), - .new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }), - .new_tab => { if (@hasDecl(apprt.Surface, "newTab")) { try self.rt_surface.newTab(); @@ -3758,14 +3782,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .close_window => try self.app.closeSurface(self), - .close_all_windows => { - if (@hasDecl(apprt.Surface, "closeAllWindows")) { - self.rt_surface.closeAllWindows(); - } else log.warn("runtime doesn't implement closeAllWindows", .{}); - }, - - .quit => try self.app.setQuit(), - .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b59ab1c9d..c540694d0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -86,7 +86,8 @@ pub const App = struct { /// New tab with options. new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - /// New window with options. + /// New window with options. The surface may be null if there is no + /// target surface. new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, /// Control the inspector visibility @@ -143,17 +144,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 = .{}, }; } @@ -161,6 +182,251 @@ pub const App = struct { self.keymap.deinit(); } + /// Returns true if there are any global keybinds in the configuration. + pub fn hasGlobalKeybinds(self: *const App) bool { + var it = self.config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.*) { + .leader => {}, + .leaf => |leaf| if (leaf.flags.global) return true, + } + } + + 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 { + 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 @@ -227,14 +493,19 @@ pub const App = struct { } pub fn newWindow(self: *App, parent: ?*CoreSurface) !void { - _ = self; - - // Right now we only support creating a new window with a parent - // through this code. - // The other case is handled by the embedding runtime. + // If we have a parent, the surface logic handles it. if (parent) |surface| { try surface.rt_surface.newWindow(); + return; } + + // No parent, call the new window callback. + const func = self.opts.new_window orelse { + log.info("runtime embedder does not support new_window", .{}); + return; + }; + + func(null, .{}); } }; @@ -336,20 +607,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, @@ -787,198 +1044,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}); @@ -1398,7 +1463,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( @@ -1484,6 +1549,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 { @@ -1514,6 +1592,11 @@ pub const CAPI = struct { return v.core_app.needsConfirmQuit(); } + /// Returns true if the app has global keybinds. + export fn ghostty_app_has_global_keybinds(v: *App) bool { + return v.hasGlobalKeybinds(); + } + /// Returns initial surface options. export fn ghostty_surface_config_new() apprt.Surface.Options { return .{}; @@ -1672,16 +1755,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/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index b12694625..9e734d1ec 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { while (iter.next()) |bind| { const action = switch (bind.value_ptr.*) { .leader => continue, // TODO: support this - .action, .action_unconsumed => |action| action, + .leaf => |leaf| leaf.action, }; const key = switch (bind.key_ptr.key) { .translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}), diff --git a/src/config/Config.zig b/src/config/Config.zig index 9bc518326..efa741307 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -651,7 +651,8 @@ class: ?[:0]const u8 = null, @"working-directory": ?[]const u8 = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will -/// overwrite previously set values. +/// overwrite previously set values. The list of actions is available in +/// the documentation or using the `ghostty +list-actions` command. /// /// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, /// `ctrl+shift+b`, `up`. Some notes: @@ -703,6 +704,9 @@ class: ?[:0]const u8 = null, /// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and /// `ctrl+a>t` will become unbound. /// +/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed +/// triggers. This is a limitation we could remove in the future. +/// /// Action is the action to take when the trigger is satisfied. It takes the /// format `action` or `action:param`. The latter form is only valid if the /// action requires a parameter. @@ -722,6 +726,9 @@ class: ?[:0]const u8 = null, /// * `text:text` - Send a string. Uses Zig string literal syntax. /// i.e. `text:\x15` sends Ctrl-U. /// +/// * All other actions can be found in the documentation or by using the +/// `ghostty +list-actions` command. +/// /// Some notes for the action: /// /// * The parameter is taken as-is after the `:`. Double quotes or @@ -736,11 +743,48 @@ class: ?[:0]const u8 = null, /// removes ALL keybindings up to this point, including the default /// keybindings. /// -/// A keybind by default causes the input to be consumed. This means that the -/// associated encoding (if any) will not be sent to the running program -/// in the terminal. If you wish to send the encoded value to the program, -/// specify the "unconsumed:" prefix before the entire keybind. For example: -/// "unconsumed:ctrl+a=reload_config" +/// The keybind trigger can be prefixed with some special values to change +/// the behavior of the keybind. These are: +/// +/// * `all:` - Make the keybind apply to all terminal surfaces. By default, +/// keybinds only apply to the focused terminal surface. If this is true, +/// then the keybind will be sent to all terminal surfaces. This only +/// applies to actions that are surface-specific. For actions that +/// are already global (i.e. `quit`), this prefix has no effect. +/// +/// * `global:` - Make the keybind global. By default, keybinds only work +/// within Ghostty and under the right conditions (application focused, +/// sometimes terminal focused, etc.). If you want a keybind to work +/// globally across your system (i.e. even when Ghostty is not focused), +/// specify this prefix. This prefix implies `all:`. Note: this does not +/// work in all environments; see the additional notes below for more +/// information. +/// +/// * `unconsumed:` - Do not consume the input. By default, a keybind +/// will consume the input, meaning that the associated encoding (if +/// any) will not be sent to the running program in the terminal. If +/// you wish to send the encoded value to the program, specify the +/// `unconsumed:` prefix before the entire keybind. For example: +/// `unconsumed:ctrl+a=reload_config`. `global:` and `all:`-prefixed +/// keybinds will always consume the input regardless of this setting. +/// Since they are not associated with a specific terminal surface, +/// they're never encoded. +/// +/// Keybind trigger are not unique per prefix combination. For example, +/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind +/// set later will overwrite the keybind set earlier. In this case, the +/// `global:` keybind will be used. +/// +/// Multiple prefixes can be specified. For example, +/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global +/// and not consume the input to reload the config. +/// +/// A note on `global:`: this feature is only supported on macOS. On macOS, +/// this feature requires accessibility permissions to be granted to Ghostty. +/// When a `global:` keybind is specified and Ghostty is launched or reloaded, +/// Ghostty will attempt to request these permissions. If the permissions are +/// not granted, the keybind will not work. On macOS, you can find these +/// permissions in System Preferences -> Privacy & Security -> Accessibility. keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells @@ -3704,11 +3748,16 @@ pub const Keybinds = struct { )) return false, // Actions are compared by field directly - inline .action, .action_unconsumed => |_, tag| if (!equalField( - inputpkg.Binding.Action, - @field(self_entry.value_ptr.*, @tagName(tag)), - @field(other_entry.value_ptr.*, @tagName(tag)), - )) return false, + .leaf => { + const self_leaf = self_entry.value_ptr.*.leaf; + const other_leaf = other_entry.value_ptr.*.leaf; + + if (!equalField( + inputpkg.Binding.Set.Leaf, + self_leaf, + other_leaf, + )) return false; + }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b347d263b..8f129065d 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, @@ -13,21 +14,36 @@ trigger: Trigger, /// The action to take if this binding matches action: Action, -/// True if this binding should consume the input when the -/// action is triggered. -consumed: bool = true, +/// Boolean flags that can be set per binding. +flags: Flags = .{}, pub const Error = error{ InvalidFormat, InvalidAction, }; +/// Flags the full binding-scoped flags that can be set per binding. +pub const Flags = packed struct { + /// True if this binding should consume the input when the + /// action is triggered. + consumed: bool = true, + + /// True if this binding should be forwarded to all active surfaces + /// in the application. + all: bool = false, + + /// True if this binding is global. Global bindings should work system-wide + /// and not just while Ghostty is focused. This may not work on all platforms. + /// See the keybind config documentation for more information. + global: bool = false, +}; + /// Full binding parser. The binding parser is implemented as an iterator /// which yields elements to support multi-key sequences without allocation. pub const Parser = struct { - unconsumed: bool = false, trigger_it: SequenceIterator, action: Action, + flags: Flags = .{}, pub const Elem = union(enum) { /// A leader trigger in a sequence. @@ -38,11 +54,7 @@ pub const Parser = struct { }; pub fn init(raw_input: []const u8) Error!Parser { - // If our entire input is prefixed with "unconsumed:" then we are - // not consuming this keybind when the action is triggered. - const unconsumed_prefix = "unconsumed:"; - const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix); - const start_idx = if (unconsumed) unconsumed_prefix.len else 0; + const flags, const start_idx = try parseFlags(raw_input); const input = raw_input[start_idx..]; // Find the first = which splits are mapping into the trigger @@ -52,24 +64,63 @@ pub const Parser = struct { // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ - .unconsumed = unconsumed, .trigger_it = .{ .input = input[0..eql_idx] }, .action = try Action.parse(input[eql_idx + 1 ..]), + .flags = flags, }; } + fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } { + var flags: Flags = .{}; + + var start_idx: usize = 0; + var input: []const u8 = raw_input; + while (true) { + // Find the next prefix + const idx = std.mem.indexOf(u8, input, ":") orelse break; + const prefix = input[0..idx]; + + // If the prefix is one of our flags then set it. + if (std.mem.eql(u8, prefix, "all")) { + if (flags.all) return Error.InvalidFormat; + flags.all = true; + } else if (std.mem.eql(u8, prefix, "global")) { + if (flags.global) return Error.InvalidFormat; + flags.global = true; + } else if (std.mem.eql(u8, prefix, "unconsumed")) { + if (!flags.consumed) return Error.InvalidFormat; + flags.consumed = false; + } else { + // If we don't recognize the prefix then we're done. + // There are trigger-specific prefixes like "physical:" so + // this lets us fall into that. + break; + } + + // Move past the prefix + start_idx += idx + 1; + input = input[idx + 1 ..]; + } + + return .{ flags, start_idx }; + } + pub fn next(self: *Parser) Error!?Elem { // Get our trigger. If we're out of triggers then we're done. const trigger = (try self.trigger_it.next()) orelse return null; // If this is our last trigger then it is our final binding. - if (!self.trigger_it.done()) return .{ .leader = trigger }; + if (!self.trigger_it.done()) { + // Global/all bindings can't be sequences + if (self.flags.global or self.flags.all) return error.InvalidFormat; + return .{ .leader = trigger }; + } // Out of triggers, yield the final action. return .{ .binding = .{ .trigger = trigger, .action = self.action, - .consumed = !self.unconsumed, + .flags = self.flags, } }; } @@ -228,7 +279,8 @@ pub const Action = union(enum) { /// available values. write_selection_file: WriteScreenAction, - /// Open a new window. + /// Open a new window. If the application isn't currently focused, + /// this will bring it to the front. new_window: void, /// Open a new tab. @@ -489,6 +541,142 @@ pub const Action = union(enum) { return Error.InvalidAction; } + /// The scope of an action. The scope is the context in which an action + /// must be executed. + pub const Scope = enum { + app, + surface, + }; + + /// Returns the scope of an action. + pub fn scope(self: Action) Scope { + return switch (self) { + // Doesn't really matter, so we'll see app. + .ignore, + .unbind, + => .app, + + // Obviously app actions. + .open_config, + .reload_config, + .close_all_windows, + .quit, + => .app, + + // These are app but can be special-cased in a surface context. + .new_window, + => .app, + + // Obviously surface actions. + .csi, + .esc, + .text, + .cursor_key, + .reset, + .copy_to_clipboard, + .paste_from_clipboard, + .paste_from_selection, + .increase_font_size, + .decrease_font_size, + .reset_font_size, + .clear_screen, + .select_all, + .scroll_to_top, + .scroll_to_bottom, + .scroll_page_up, + .scroll_page_down, + .scroll_page_fractional, + .scroll_page_lines, + .adjust_selection, + .jump_to_prompt, + .write_scrollback_file, + .write_screen_file, + .write_selection_file, + .close_surface, + .close_window, + .toggle_fullscreen, + .toggle_window_decorations, + .toggle_secure_input, + .crash, + => .surface, + + // These are less obvious surface actions. They're surface + // actions because they are relevant to the surface they + // come from. For example `new_window` needs to be sourced to + // a surface so inheritance can be done correctly. + .new_tab, + .previous_tab, + .next_tab, + .last_tab, + .goto_tab, + .new_split, + .goto_split, + .toggle_split_zoom, + .resize_split, + .equalize_splits, + .inspector, + => .surface, + }; + } + + /// Returns a union type that only contains actions that are scoped to + /// the given scope. + pub fn Scoped(comptime s: Scope) type { + const all_fields = @typeInfo(Action).Union.fields; + + // Find all fields that are app-scoped + var i: usize = 0; + var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined; + var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined; + for (all_fields) |field| { + const action = @unionInit(Action, field.name, undefined); + if (action.scope() == s) { + union_fields[i] = field; + enum_fields[i] = .{ .name = field.name, .value = i }; + i += 1; + } + } + + // Build our union + return @Type(.{ .Union = .{ + .layout = .auto, + .tag_type = @Type(.{ .Enum = .{ + .tag_type = std.math.IntFittingRange(0, i), + .fields = enum_fields[0..i], + .decls = &.{}, + .is_exhaustive = true, + } }), + .fields = union_fields[0..i], + .decls = &.{}, + } }); + } + + /// Returns the scoped version of this action. If the action is not + /// scoped to the given scope then this returns null. + /// + /// The benefit of this function is that it allows us to use Zig's + /// exhaustive switch safety to ensure we always properly handle certain + /// scoped actions. + pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) { + switch (self) { + inline else => |v, tag| { + // Use comptime to prune out non-app actions + if (comptime @unionInit( + Action, + @tagName(tag), + undefined, + ).scope() != s) return null; + + // Initialize our app action + return @unionInit( + Scoped(s), + @tagName(tag), + v, + ); + }, + } + } + /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. pub fn format( @@ -544,10 +732,15 @@ pub const Action = union(enum) { /// action. pub fn hash(self: Action) u64 { var hasher = std.hash.Wyhash.init(0); + self.hashIncremental(&hasher); + return hasher.final(); + } + /// Hash the action into the given hasher. + fn hashIncremental(self: Action, hasher: anytype) void { // Always has the active tag. const Tag = @typeInfo(Action).Union.tag_type.?; - std.hash.autoHash(&hasher, @as(Tag, self)); + std.hash.autoHash(hasher, @as(Tag, self)); // Hash the value of the field. switch (self) { @@ -562,25 +755,23 @@ pub const Action = union(enum) { // signed zeros but these are not cases we expect for // our bindings. f32 => std.hash.autoHash( - &hasher, + hasher, @as(u32, @bitCast(field)), ), f64 => std.hash.autoHash( - &hasher, + hasher, @as(u64, @bitCast(field)), ), // Everything else automatically handle. else => std.hash.autoHashStrat( - &hasher, + hasher, field, .DeepRecursive, ), } }, } - - return hasher.final(); } }; @@ -737,11 +928,16 @@ pub const Trigger = struct { /// Returns a hash code that can be used to uniquely identify this trigger. pub fn hash(self: Trigger) u64 { var hasher = std.hash.Wyhash.init(0); - std.hash.autoHash(&hasher, self.key); - std.hash.autoHash(&hasher, self.mods.binding()); + self.hashIncremental(&hasher); return hasher.final(); } + /// Hash the trigger into the given hasher. + fn hashIncremental(self: Trigger, hasher: anytype) void { + std.hash.autoHash(hasher, self.key); + std.hash.autoHash(hasher, self.mods.binding()); + } + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ @@ -818,10 +1014,8 @@ pub const Set = struct { leader: *Set, /// This trigger completes a sequence and the value is the action - /// to take. The "_unconsumed" variant is used for triggers that - /// should not consume the input. - action: Action, - action_unconsumed: Action, + /// to take along with the flags that may define binding behavior. + leaf: Leaf, /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. @@ -846,14 +1040,28 @@ pub const Set = struct { } }, - .action, .action_unconsumed => |action| { + .leaf => |leaf| { // action implements the format - try writer.print("={s}", .{action}); + try writer.print("={s}", .{leaf.action}); }, } } }; + /// Leaf node of a set is an action to trigger. This is a "leaf" compared + /// to the inner nodes which are "leaders" for sequences. + pub const Leaf = struct { + action: Action, + flags: Flags, + + pub fn hash(self: Leaf) u64 { + var hasher = std.hash.Wyhash.init(0); + self.action.hash(&hasher); + std.hash.autoHash(&hasher, self.flags); + return hasher.final(); + } + }; + pub fn deinit(self: *Set, alloc: Allocator) void { // Clear any leaders if we have them var it = self.bindings.iterator(); @@ -862,7 +1070,7 @@ pub const Set = struct { s.deinit(alloc); alloc.destroy(s); }, - .action, .action_unconsumed => {}, + .leaf => {}, }; self.bindings.deinit(alloc); @@ -934,7 +1142,7 @@ pub const Set = struct { error.OutOfMemory => return error.OutOfMemory, }, - .action, .action_unconsumed => { + .leaf => { // Remove the existing action. Fallthrough as if // we don't have a leader. set.remove(alloc, t); @@ -958,11 +1166,11 @@ pub const Set = struct { set.remove(alloc, t); if (old) |entry| switch (entry) { .leader => unreachable, // Handled above - inline .action, .action_unconsumed => |action, tag| set.put_( + .leaf => |leaf| set.putFlags( alloc, t, - action, - tag == .action, + leaf.action, + leaf.flags, ) catch {}, }; }, @@ -977,11 +1185,12 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => if (b.consumed) { - try set.put(alloc, b.trigger, b.action); - } else { - try set.putUnconsumed(alloc, b.trigger, b.action); - }, + else => try set.putFlags( + alloc, + b.trigger, + b.action, + b.flags, + ), }, } } @@ -994,29 +1203,16 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, true); + try self.putFlags(alloc, t, action, .{}); } - /// Same as put but marks the trigger as unconsumed. An unconsumed - /// trigger will evaluate the action and continue to encode for the - /// terminal. - /// - /// This is a separate function because this case is rare. - pub fn putUnconsumed( + /// Add a binding to the set with explicit flags. + pub fn putFlags( self: *Set, alloc: Allocator, t: Trigger, action: Action, - ) Allocator.Error!void { - try self.put_(alloc, t, action, false); - } - - fn put_( - self: *Set, - alloc: Allocator, - t: Trigger, - action: Action, - consumed: bool, + flags: Flags, ) Allocator.Error!void { // unbind should never go into the set, it should be handled prior assert(action != .unbind); @@ -1032,7 +1228,7 @@ pub const Set = struct { // If we have an existing binding for this trigger, we have to // update the reverse mapping to remove the old action. - .action, .action_unconsumed => { + .leaf => { const t_hash = t.hash(); var it = self.reverse.iterator(); while (it.next()) |reverse_entry| it: { @@ -1044,11 +1240,10 @@ pub const Set = struct { }, }; - gop.value_ptr.* = if (consumed) .{ + gop.value_ptr.* = .{ .leaf = .{ .action = action, - } else .{ - .action_unconsumed = action, - }; + .flags = flags, + } }; errdefer _ = self.bindings.remove(t); try self.reverse.put(alloc, action, t); errdefer _ = self.reverse.remove(action); @@ -1065,6 +1260,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; @@ -1083,15 +1303,16 @@ pub const Set = struct { // Note: we'd LIKE to replace this with the most recent binding but // our hash map obviously has no concept of ordering so we have to // choose whatever. Maybe a switch to an array hash map here. - .action, .action_unconsumed => |action| { - const action_hash = action.hash(); + .leaf => |leaf| { + const action_hash = leaf.action.hash(); + var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { .leader => {}, - .action, .action_unconsumed => |action_search| { - if (action_search.hash() == action_hash) { - self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + .leaf => |leaf_search| { + if (leaf_search.action.hash() == action_hash) { + self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); break; } }, @@ -1099,7 +1320,7 @@ pub const Set = struct { } else { // No over trigger points to this action so we remove // the reverse mapping completely. - _ = self.reverse.remove(action); + _ = self.reverse.remove(leaf.action); } }, } @@ -1116,7 +1337,7 @@ pub const Set = struct { var it = result.bindings.iterator(); while (it.next()) |entry| switch (entry.value_ptr.*) { // No data to clone - .action, .action_unconsumed => {}, + .leaf => {}, // Must be deep cloned. .leader => |*s| { @@ -1218,7 +1439,7 @@ test "parse: triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, + .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:shift+a=ignore")); // unconsumed physical keys @@ -1228,7 +1449,7 @@ test "parse: triggers" { .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, + .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); // invalid key @@ -1241,6 +1462,92 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); } +test "parse: global triggers" { + const testing = std.testing; + + // global keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .global = true }, + }, try parseSingle("global:shift+a=ignore")); + + // global physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .global = true }, + }, try parseSingle("global:physical:a+shift=ignore")); + + // global unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ + .global = true, + .consumed = false, + }, + }, try parseSingle("unconsumed:global:a+shift=ignore")); + + // global sequences not allowed + { + var p = try Parser.init("global:a>b=ignore"); + try testing.expectError(Error.InvalidFormat, p.next()); + } +} + +test "parse: all triggers" { + const testing = std.testing; + + // all keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .all = true }, + }, try parseSingle("all:shift+a=ignore")); + + // all physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .all = true }, + }, try parseSingle("all:physical:a+shift=ignore")); + + // all unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ + .all = true, + .consumed = false, + }, + }, try parseSingle("unconsumed:all:a+shift=ignore")); + + // all sequences not allowed + { + var p = try Parser.init("all:a>b=ignore"); + try testing.expectError(Error.InvalidFormat, p.next()); + } +} + test "parse: modifier aliases" { const testing = std.testing; @@ -1466,8 +1773,9 @@ test "set: parseAndPut typical binding" { // Creates forward mapping { - const action = s.get(.{ .key = .{ .translated = .a } }).?.action; - try testing.expect(action == .new_window); + const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{}, action.flags); } // Creates reverse mapping @@ -1489,8 +1797,9 @@ test "set: parseAndPut unconsumed binding" { // Creates forward mapping { const trigger: Trigger = .{ .key = .{ .translated = .a } }; - const action = s.get(trigger).?.action_unconsumed; - try testing.expect(action == .new_window); + const action = s.get(trigger).?.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{ .consumed = false }, action.flags); } // Creates reverse mapping @@ -1536,8 +1845,9 @@ test "set: parseAndPut sequence" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1560,14 +1870,16 @@ test "set: parseAndPut sequence with two actions" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } { const t: Trigger = .{ .key = .{ .translated = .c } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_tab); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_tab); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1590,8 +1902,9 @@ test "set: parseAndPut overwrite sequence" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1614,8 +1927,9 @@ test "set: parseAndPut overwrite leader" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1744,11 +2058,19 @@ test "set: consumed state" { defer s.deinit(alloc); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); - try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed); + try s.putFlags( + alloc, + .{ .key = .{ .translated = .a } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); }