From 17e46bf0f4eeaa785a0546d068be7e960359b239 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Aug 2023 21:39:25 -0700 Subject: [PATCH 01/10] input: move action parsing to dedicating Action.parse --- src/input/Binding.zig | 138 ++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 82315dbb0..5c89bde62 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -82,72 +82,7 @@ pub fn parse(input: []const u8) !Binding { }; // Find a matching action - const action: Action = action: { - // Split our action by colon. A colon may not exist for some - // actions so it is optional. The part preceding the colon is the - // action name. - const actionRaw = input[eqlIdx + 1 ..]; - const colonIdx = std.mem.indexOf(u8, actionRaw, ":"); - const action = actionRaw[0..(colonIdx orelse actionRaw.len)]; - - // An action name is always required - if (action.len == 0) return Error.InvalidFormat; - - const actionInfo = @typeInfo(Action).Union; - inline for (actionInfo.fields) |field| { - if (std.mem.eql(u8, action, field.name)) { - // If the field type is void we expect no value - switch (field.type) { - void => { - if (colonIdx != null) return Error.InvalidFormat; - break :action @unionInit(Action, field.name, {}); - }, - - []const u8 => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = actionRaw[idx + 1 ..]; - break :action @unionInit(Action, field.name, param); - }, - - // Cursor keys can't be set currently - Action.CursorKey => return Error.InvalidAction, - - else => switch (@typeInfo(field.type)) { - .Enum => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = actionRaw[idx + 1 ..]; - const value = std.meta.stringToEnum( - field.type, - param, - ) orelse return Error.InvalidFormat; - - break :action @unionInit(Action, field.name, value); - }, - - .Int => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = actionRaw[idx + 1 ..]; - const value = std.fmt.parseInt(field.type, param, 10) catch - return Error.InvalidFormat; - break :action @unionInit(Action, field.name, value); - }, - - .Float => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = actionRaw[idx + 1 ..]; - const value = std.fmt.parseFloat(field.type, param) catch - return Error.InvalidFormat; - break :action @unionInit(Action, field.name, value); - }, - - else => unreachable, - }, - } - } - } - - return Error.InvalidFormat; - }; + const action = try Action.parse(input[eqlIdx + 1 ..]); return Binding{ .trigger = trigger, .action = action }; } @@ -266,6 +201,75 @@ pub const Action = union(enum) { bottom, right, }; + + /// Parse an action in the format of "key=value" where key is the + /// action name and value is the action parameter. The parameter + /// is optional depending on the action. + pub fn parse(input: []const u8) !Action { + // Split our action by colon. A colon may not exist for some + // actions so it is optional. The part preceding the colon is the + // action name. + const colonIdx = std.mem.indexOf(u8, input, ":"); + const action = input[0..(colonIdx orelse input.len)]; + + // An action name is always required + if (action.len == 0) return Error.InvalidFormat; + + const actionInfo = @typeInfo(Action).Union; + inline for (actionInfo.fields) |field| { + if (std.mem.eql(u8, action, field.name)) { + // If the field type is void we expect no value + switch (field.type) { + void => { + if (colonIdx != null) return Error.InvalidFormat; + return @unionInit(Action, field.name, {}); + }, + + []const u8 => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = input[idx + 1 ..]; + return @unionInit(Action, field.name, param); + }, + + // Cursor keys can't be set currently + Action.CursorKey => return Error.InvalidAction, + + else => switch (@typeInfo(field.type)) { + .Enum => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = input[idx + 1 ..]; + const value = std.meta.stringToEnum( + field.type, + param, + ) orelse return Error.InvalidFormat; + + return @unionInit(Action, field.name, value); + }, + + .Int => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = input[idx + 1 ..]; + const value = std.fmt.parseInt(field.type, param, 10) catch + return Error.InvalidFormat; + return @unionInit(Action, field.name, value); + }, + + .Float => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = input[idx + 1 ..]; + const value = std.fmt.parseFloat(field.type, param) catch + return Error.InvalidFormat; + return @unionInit(Action, field.name, value); + }, + + else => unreachable, + }, + } + } + } + + return Error.InvalidAction; + } }; // A key for the C API to execute an action. This must be kept in sync @@ -427,7 +431,7 @@ test "parse: action invalid" { const testing = std.testing; // invalid action - try testing.expectError(Error.InvalidFormat, parse("a=nopenopenope")); + try testing.expectError(Error.InvalidAction, parse("a=nopenopenope")); } test "parse: action no parameters" { From c71979804e336d25a6d29e6e9f7462874b5c21f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Aug 2023 21:46:38 -0700 Subject: [PATCH 02/10] apprt/embedded: ghostty_surface_binding_action can now run any action --- include/ghostty.h | 9 +-------- macos/Sources/Ghostty/AppState.swift | 10 ++++++++-- macos/Sources/Ghostty/SurfaceView.swift | 15 ++++++++++++--- src/apprt/embedded.zig | 21 ++++++++++----------- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index bcaba1eac..98bd292da 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -230,13 +230,6 @@ typedef enum { GHOSTTY_KEY_RIGHT_SUPER, } ghostty_input_key_e; -typedef enum { - GHOSTTY_BINDING_COPY_TO_CLIPBOARD, - GHOSTTY_BINDING_PASTE_FROM_CLIPBOARD, - GHOSTTY_BINDING_NEW_TAB, - GHOSTTY_BINDING_NEW_WINDOW, -} ghostty_binding_action_e; - // Fully defined types. This MUST be kept in sync with equivalent Zig // structs. To find the Zig struct, grep for this type name. The documentation // for all of these types is available in the Zig source. @@ -315,7 +308,7 @@ void ghostty_surface_ime_point(ghostty_surface_t, double *, double *); void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_direction_e); -void ghostty_surface_binding_action(ghostty_surface_t, ghostty_binding_action_e, void *); +bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); // APIs I'd like to get rid of eventually but are still needed for now. // Don't use these unless you know what you're doing. diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 7643b7d0d..ff8bdf0e9 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -140,11 +140,17 @@ extension Ghostty { } func newTab(surface: ghostty_surface_t) { - ghostty_surface_binding_action(surface, GHOSTTY_BINDING_NEW_TAB, nil) + let action = "new_tab" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } } func newWindow(surface: ghostty_surface_t) { - ghostty_surface_binding_action(surface, GHOSTTY_BINDING_NEW_WINDOW, nil) + let action = "new_window" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } } func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 11f809fc9..60d653bd5 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -375,17 +375,26 @@ extension Ghostty { @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } - ghostty_surface_binding_action(surface, GHOSTTY_BINDING_COPY_TO_CLIPBOARD, nil) + let action = "copy_to_clipboard" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } } @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } - ghostty_surface_binding_action(surface, GHOSTTY_BINDING_PASTE_FROM_CLIPBOARD, nil) + let action = "paste_from_clipboard" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } } @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } - ghostty_surface_binding_action(surface, GHOSTTY_BINDING_PASTE_FROM_CLIPBOARD, nil) + let action = "paste_from_clipboard" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } } // MARK: NSTextInputClient diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index dd3443ed7..03b6409cf 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -882,22 +882,21 @@ pub const CAPI = struct { /// Invoke an action on the surface. export fn ghostty_surface_binding_action( ptr: *Surface, - key: input.Binding.Key, - unused: *anyopaque, - ) void { - // For future arguments - _ = unused; - - const action: input.Binding.Action = switch (key) { - .copy_to_clipboard => .{ .copy_to_clipboard = {} }, - .paste_from_clipboard => .{ .paste_from_clipboard = {} }, - .new_tab => .{ .new_tab = {} }, - .new_window => .{ .new_window = {} }, + action_ptr: [*]const u8, + action_len: usize, + ) bool { + const action_str = action_ptr[0..action_len]; + const action = input.Binding.Action.parse(action_str) catch |err| { + log.err("error parsing binding action action={s} err={}", .{ action_str, err }); + return false; }; ptr.core_surface.performBindingAction(action) catch |err| { log.err("error performing binding action action={} err={}", .{ action, err }); + return false; }; + + return true; } /// Sets the window background blur on macOS to the desired value. From ba883ce39ae11237acf34065cf20474f4428ca6a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Aug 2023 22:14:44 -0700 Subject: [PATCH 03/10] add ghostty_config_trigger C API to find a trigger for an action --- include/ghostty.h | 7 +++++++ src/config.zig | 19 +++++++++++++++++++ src/input/Binding.zig | 21 ++++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 98bd292da..856b260d8 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -230,6 +230,12 @@ typedef enum { GHOSTTY_KEY_RIGHT_SUPER, } ghostty_input_key_e; +typedef struct { + ghostty_input_key_e key; + ghostty_input_mods_e mods; + bool physical; +} ghostty_input_trigger_s; + // Fully defined types. This MUST be kept in sync with equivalent Zig // structs. To find the Zig struct, grep for this type name. The documentation // for all of these types is available in the Zig source. @@ -282,6 +288,7 @@ void ghostty_config_load_string(ghostty_config_t, const char *, uintptr_t); void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t); void ghostty_config_finalize(ghostty_config_t); +ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char *, uintptr_t); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s *, ghostty_config_t); void ghostty_app_free(ghostty_app_t); diff --git a/src/config.zig b/src/config.zig index 891595aaf..fea085933 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1559,6 +1559,25 @@ pub const CAPI = struct { log.err("error finalizing config err={}", .{err}); }; } + + export fn ghostty_config_trigger( + self: *Config, + str: [*]const u8, + len: usize, + ) inputpkg.Binding.Trigger { + return config_trigger_(self, str[0..len]) catch |err| err: { + log.err("error finding trigger err={}", .{err}); + break :err .{}; + }; + } + + fn config_trigger_( + self: *Config, + str: []const u8, + ) !inputpkg.Binding.Trigger { + const action = try inputpkg.Binding.Action.parse(str); + return self.keybind.set.getTrigger(action) orelse .{}; + } }; test { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 5c89bde62..6cb198626 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -282,7 +282,10 @@ pub const Key = enum(c_int) { }; /// Trigger is the associated key state that can trigger an action. -pub const Trigger = struct { +/// This is an extern struct because this is also used in the C API. +/// +/// This must be kept in sync with include/ghostty.h ghostty_input_trigger_s +pub const Trigger = extern struct { /// The key that has to be pressed for a binding to take action. key: key.Key = .invalid, @@ -342,6 +345,22 @@ pub const Set = struct { return self.bindings.get(t); } + /// Get a trigger for the given action. An action can have multiple + /// triggers so this will return the first one found. + pub fn getTrigger(self: Set, a: Action) ?Trigger { + // Note: iterating over the full set each time is not ideal but + // we don't expect to have that many bindings. If this becomes + // a problem we can add a reverse map. + var it = self.bindings.iterator(); + while (it.next()) |entry| { + if (std.meta.eql(entry.value_ptr.*, a)) { + return entry.key_ptr.*; + } + } + + return null; + } + /// Remove a binding for a given trigger. pub fn remove(self: *Set, t: Trigger) void { _ = self.bindings.remove(t); From 52396304ff30fff1376ed38add32eaf9a1b5658d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 30 Aug 2023 22:45:29 -0700 Subject: [PATCH 04/10] macos: begin syncing menuitem key equivalents --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/AppDelegate.swift | 40 ++++-- macos/Sources/Ghostty/Ghostty.Input.swift | 158 ++++++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 83 ------------ macos/Sources/MainMenu.xib | 7 +- 5 files changed, 198 insertions(+), 94 deletions(-) create mode 100644 macos/Sources/Ghostty/Ghostty.Input.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e29c09cc2..1a9d21e83 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 85102A1C2A6E32890084AB3E /* PrimaryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */; }; + A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; }; A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; }; A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; @@ -37,6 +38,7 @@ 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindowController.swift; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindow.swift; sourceTree = ""; }; + A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; }; A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindowManager.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; @@ -132,6 +134,7 @@ A55B7BB729B6F53A0055DE60 /* Package.swift */, A55B7BB529B6F47F0055DE60 /* AppState.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, + A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, ); @@ -260,6 +263,7 @@ files = ( A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */, 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */, + A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index b13bd0aa9..5473accba 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -14,6 +14,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { // confirmQuit published so other views can check whether quit needs to be confirmed. @Published var confirmQuit: Bool = false + /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. + @IBOutlet private var menuPreviousSplit: NSMenuItem? + @IBOutlet private var menuNextSplit: NSMenuItem? + /// The ghostty global state. Only one per process. private var ghostty: Ghostty.AppState = Ghostty.AppState() @@ -33,6 +37,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { "ApplePressAndHoldEnabled": false, ]) + // Sync our menu shortcuts with our Ghostty config + syncMenuShortcuts() + // Let's launch our first window. // TODO: we should detect if we restored windows and if so not launch a new window. windowManager.addInitialWindow() @@ -76,6 +83,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { return .terminateLater } + private func syncMenuShortcuts() { + guard let cfg = ghostty.config else { return } + + if let menu = self.menuPreviousSplit { + let action = "goto_split:previous" + let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) + if let equiv = Ghostty.keyEquivalent(key: trigger.key) { + menu.keyEquivalent = equiv + menu.keyEquivalentModifierMask = Ghostty.eventModifierFlags(mods: trigger.mods) + } + } + } + + private func focusedSurface() -> ghostty_surface_t? { + guard let window = NSApp.keyWindow as? PrimaryWindow else { return nil } + return window.focusedSurfaceWrapper.surface + } + + private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { + guard let surface = focusedSurface() else { return } + ghostty.splitMoveFocus(surface: surface, direction: direction) + } + @IBAction func newWindow(_ sender: Any?) { windowManager.newWindow() } @@ -98,11 +128,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { ghostty.requestClose(surface: surface) } - private func focusedSurface() -> ghostty_surface_t? { - guard let window = NSApp.keyWindow as? PrimaryWindow else { return nil } - return window.focusedSurfaceWrapper.surface - } - @IBAction func splitHorizontally(_ sender: Any) { guard let surface = focusedSurface() else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT) @@ -137,11 +162,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { splitMoveFocus(direction: .right) } - func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { - guard let surface = focusedSurface() else { return } - ghostty.splitMoveFocus(surface: surface, direction: direction) - } - @IBAction func showHelp(_ sender: Any) { guard let url = URL(string: "https://github.com/mitchellh/ghostty") else { return } NSWorkspace.shared.open(url) diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift new file mode 100644 index 000000000..858a1f7a0 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -0,0 +1,158 @@ +import Cocoa +import GhosttyKit + +extension Ghostty { + /// Returns the "keyEquivalent" string for a given input key. This doesn't always have a corresponding key. + static func keyEquivalent(key: ghostty_input_key_e) -> String? { + guard let byte = Self.keyToAscii[key] else { return nil } + return String(bytes: [byte], encoding: .utf8) + } + + /// Returns the event modifier flags set for the Ghostty mods enum. + static func eventModifierFlags(mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { + var flags: [NSEvent.ModifierFlags] = []; + if (mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0) { flags.append(.shift) } + if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0) { flags.append(.control) } + if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0) { flags.append(.option) } + if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.append(.command) } + return NSEvent.ModifierFlags(flags) + } + + static let keyToAscii: [ghostty_input_key_e : UInt8] = [ + // 0-9 + GHOSTTY_KEY_ZERO: 0x30, + GHOSTTY_KEY_ONE: 0x31, + GHOSTTY_KEY_TWO: 0x32, + GHOSTTY_KEY_THREE: 0x33, + GHOSTTY_KEY_FOUR: 0x34, + GHOSTTY_KEY_FIVE: 0x35, + GHOSTTY_KEY_SIX: 0x36, + GHOSTTY_KEY_SEVEN: 0x37, + GHOSTTY_KEY_EIGHT: 0x38, + GHOSTTY_KEY_NINE: 0x39, + + // a-z + GHOSTTY_KEY_A: 0x61, + GHOSTTY_KEY_B: 0x62, + GHOSTTY_KEY_C: 0x63, + GHOSTTY_KEY_D: 0x64, + GHOSTTY_KEY_E: 0x65, + GHOSTTY_KEY_F: 0x66, + GHOSTTY_KEY_G: 0x67, + GHOSTTY_KEY_H: 0x68, + GHOSTTY_KEY_I: 0x69, + GHOSTTY_KEY_J: 0x6A, + GHOSTTY_KEY_K: 0x6B, + GHOSTTY_KEY_L: 0x6C, + GHOSTTY_KEY_M: 0x6D, + GHOSTTY_KEY_N: 0x6E, + GHOSTTY_KEY_O: 0x6F, + GHOSTTY_KEY_P: 0x70, + GHOSTTY_KEY_Q: 0x71, + GHOSTTY_KEY_R: 0x72, + GHOSTTY_KEY_S: 0x73, + GHOSTTY_KEY_T: 0x74, + GHOSTTY_KEY_U: 0x75, + GHOSTTY_KEY_V: 0x76, + GHOSTTY_KEY_W: 0x77, + GHOSTTY_KEY_X: 0x78, + GHOSTTY_KEY_Y: 0x79, + GHOSTTY_KEY_Z: 0x7A, + + // Symbols + GHOSTTY_KEY_APOSTROPHE: 0x27, + GHOSTTY_KEY_BACKSLASH: 0x5C, + GHOSTTY_KEY_COMMA: 0x2C, + GHOSTTY_KEY_EQUAL: 0x3D, + GHOSTTY_KEY_GRAVE_ACCENT: 0x60, + GHOSTTY_KEY_LEFT_BRACKET: 0x5B, + GHOSTTY_KEY_MINUS: 0x2D, + GHOSTTY_KEY_PERIOD: 0x2E, + GHOSTTY_KEY_RIGHT_BRACKET: 0x5D, + GHOSTTY_KEY_SEMICOLON: 0x3B, + GHOSTTY_KEY_SLASH: 0x2F, + ] + + static let asciiToKey: [UInt8 : ghostty_input_key_e] = [ + // 0-9 + 0x30: GHOSTTY_KEY_ZERO, + 0x31: GHOSTTY_KEY_ONE, + 0x32: GHOSTTY_KEY_TWO, + 0x33: GHOSTTY_KEY_THREE, + 0x34: GHOSTTY_KEY_FOUR, + 0x35: GHOSTTY_KEY_FIVE, + 0x36: GHOSTTY_KEY_SIX, + 0x37: GHOSTTY_KEY_SEVEN, + 0x38: GHOSTTY_KEY_EIGHT, + 0x39: GHOSTTY_KEY_NINE, + + // A-Z + 0x41: GHOSTTY_KEY_A, + 0x42: GHOSTTY_KEY_B, + 0x43: GHOSTTY_KEY_C, + 0x44: GHOSTTY_KEY_D, + 0x45: GHOSTTY_KEY_E, + 0x46: GHOSTTY_KEY_F, + 0x47: GHOSTTY_KEY_G, + 0x48: GHOSTTY_KEY_H, + 0x49: GHOSTTY_KEY_I, + 0x4A: GHOSTTY_KEY_J, + 0x4B: GHOSTTY_KEY_K, + 0x4C: GHOSTTY_KEY_L, + 0x4D: GHOSTTY_KEY_M, + 0x4E: GHOSTTY_KEY_N, + 0x4F: GHOSTTY_KEY_O, + 0x50: GHOSTTY_KEY_P, + 0x51: GHOSTTY_KEY_Q, + 0x52: GHOSTTY_KEY_R, + 0x53: GHOSTTY_KEY_S, + 0x54: GHOSTTY_KEY_T, + 0x55: GHOSTTY_KEY_U, + 0x56: GHOSTTY_KEY_V, + 0x57: GHOSTTY_KEY_W, + 0x58: GHOSTTY_KEY_X, + 0x59: GHOSTTY_KEY_Y, + 0x5A: GHOSTTY_KEY_Z, + + // a-z + 0x61: GHOSTTY_KEY_A, + 0x62: GHOSTTY_KEY_B, + 0x63: GHOSTTY_KEY_C, + 0x64: GHOSTTY_KEY_D, + 0x65: GHOSTTY_KEY_E, + 0x66: GHOSTTY_KEY_F, + 0x67: GHOSTTY_KEY_G, + 0x68: GHOSTTY_KEY_H, + 0x69: GHOSTTY_KEY_I, + 0x6A: GHOSTTY_KEY_J, + 0x6B: GHOSTTY_KEY_K, + 0x6C: GHOSTTY_KEY_L, + 0x6D: GHOSTTY_KEY_M, + 0x6E: GHOSTTY_KEY_N, + 0x6F: GHOSTTY_KEY_O, + 0x70: GHOSTTY_KEY_P, + 0x71: GHOSTTY_KEY_Q, + 0x72: GHOSTTY_KEY_R, + 0x73: GHOSTTY_KEY_S, + 0x74: GHOSTTY_KEY_T, + 0x75: GHOSTTY_KEY_U, + 0x76: GHOSTTY_KEY_V, + 0x77: GHOSTTY_KEY_W, + 0x78: GHOSTTY_KEY_X, + 0x79: GHOSTTY_KEY_Y, + 0x7A: GHOSTTY_KEY_Z, + + // Symbols + 0x27: GHOSTTY_KEY_APOSTROPHE, + 0x5C: GHOSTTY_KEY_BACKSLASH, + 0x2C: GHOSTTY_KEY_COMMA, + 0x3D: GHOSTTY_KEY_EQUAL, + 0x60: GHOSTTY_KEY_GRAVE_ACCENT, + 0x5B: GHOSTTY_KEY_LEFT_BRACKET, + 0x2D: GHOSTTY_KEY_MINUS, + 0x2E: GHOSTTY_KEY_PERIOD, + 0x5D: GHOSTTY_KEY_RIGHT_BRACKET, + 0x3B: GHOSTTY_KEY_SEMICOLON, + 0x2F: GHOSTTY_KEY_SLASH, + ] +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 60d653bd5..91f2f5263 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -624,89 +624,6 @@ extension Ghostty { 0x43: GHOSTTY_KEY_KP_MULTIPLY, 0x4E: GHOSTTY_KEY_KP_SUBTRACT, ]; - - static let ascii: [UInt8 : ghostty_input_key_e] = [ - // 0-9 - 0x30: GHOSTTY_KEY_ZERO, - 0x31: GHOSTTY_KEY_ONE, - 0x32: GHOSTTY_KEY_TWO, - 0x33: GHOSTTY_KEY_THREE, - 0x34: GHOSTTY_KEY_FOUR, - 0x35: GHOSTTY_KEY_FIVE, - 0x36: GHOSTTY_KEY_SIX, - 0x37: GHOSTTY_KEY_SEVEN, - 0x38: GHOSTTY_KEY_EIGHT, - 0x39: GHOSTTY_KEY_NINE, - - // A-Z - 0x41: GHOSTTY_KEY_A, - 0x42: GHOSTTY_KEY_B, - 0x43: GHOSTTY_KEY_C, - 0x44: GHOSTTY_KEY_D, - 0x45: GHOSTTY_KEY_E, - 0x46: GHOSTTY_KEY_F, - 0x47: GHOSTTY_KEY_G, - 0x48: GHOSTTY_KEY_H, - 0x49: GHOSTTY_KEY_I, - 0x4A: GHOSTTY_KEY_J, - 0x4B: GHOSTTY_KEY_K, - 0x4C: GHOSTTY_KEY_L, - 0x4D: GHOSTTY_KEY_M, - 0x4E: GHOSTTY_KEY_N, - 0x4F: GHOSTTY_KEY_O, - 0x50: GHOSTTY_KEY_P, - 0x51: GHOSTTY_KEY_Q, - 0x52: GHOSTTY_KEY_R, - 0x53: GHOSTTY_KEY_S, - 0x54: GHOSTTY_KEY_T, - 0x55: GHOSTTY_KEY_U, - 0x56: GHOSTTY_KEY_V, - 0x57: GHOSTTY_KEY_W, - 0x58: GHOSTTY_KEY_X, - 0x59: GHOSTTY_KEY_Y, - 0x5A: GHOSTTY_KEY_Z, - - // a-z - 0x61: GHOSTTY_KEY_A, - 0x62: GHOSTTY_KEY_B, - 0x63: GHOSTTY_KEY_C, - 0x64: GHOSTTY_KEY_D, - 0x65: GHOSTTY_KEY_E, - 0x66: GHOSTTY_KEY_F, - 0x67: GHOSTTY_KEY_G, - 0x68: GHOSTTY_KEY_H, - 0x69: GHOSTTY_KEY_I, - 0x6A: GHOSTTY_KEY_J, - 0x6B: GHOSTTY_KEY_K, - 0x6C: GHOSTTY_KEY_L, - 0x6D: GHOSTTY_KEY_M, - 0x6E: GHOSTTY_KEY_N, - 0x6F: GHOSTTY_KEY_O, - 0x70: GHOSTTY_KEY_P, - 0x71: GHOSTTY_KEY_Q, - 0x72: GHOSTTY_KEY_R, - 0x73: GHOSTTY_KEY_S, - 0x74: GHOSTTY_KEY_T, - 0x75: GHOSTTY_KEY_U, - 0x76: GHOSTTY_KEY_V, - 0x77: GHOSTTY_KEY_W, - 0x78: GHOSTTY_KEY_X, - 0x79: GHOSTTY_KEY_Y, - 0x7A: GHOSTTY_KEY_Z, - - // Symbols - 0x27: GHOSTTY_KEY_APOSTROPHE, - 0x5C: GHOSTTY_KEY_BACKSLASH, - 0x2C: GHOSTTY_KEY_COMMA, - 0x3D: GHOSTTY_KEY_EQUAL, - 0x60: GHOSTTY_KEY_GRAVE_ACCENT, - 0x5B: GHOSTTY_KEY_LEFT_BRACKET, - 0x2D: GHOSTTY_KEY_MINUS, - 0x2E: GHOSTTY_KEY_PERIOD, - 0x5D: GHOSTTY_KEY_RIGHT_BRACKET, - 0x3B: GHOSTTY_KEY_SEMICOLON, - 0x2F: GHOSTTY_KEY_SLASH, - ] } } diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index f65bf2e24..db1a70a8a 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -12,7 +12,12 @@ - + + + + + + From c7071a1da2fb8688449682d5b59c919a7484cd0e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 31 Aug 2023 11:14:07 -0700 Subject: [PATCH 05/10] macos: complete lookup table for key equivalents --- macos/Sources/Ghostty/Ghostty.Input.swift | 157 ++++++++++++++-------- 1 file changed, 101 insertions(+), 56 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 858a1f7a0..b48f141e7 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -4,10 +4,9 @@ import GhosttyKit extension Ghostty { /// Returns the "keyEquivalent" string for a given input key. This doesn't always have a corresponding key. static func keyEquivalent(key: ghostty_input_key_e) -> String? { - guard let byte = Self.keyToAscii[key] else { return nil } - return String(bytes: [byte], encoding: .utf8) + return Self.keyToEquivalent[key] } - + /// Returns the event modifier flags set for the Ghostty mods enum. static func eventModifierFlags(mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { var flags: [NSEvent.ModifierFlags] = []; @@ -17,62 +16,107 @@ extension Ghostty { if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.append(.command) } return NSEvent.ModifierFlags(flags) } - - static let keyToAscii: [ghostty_input_key_e : UInt8] = [ + + /// A map from the Ghostty key enum to the keyEquivalent string for shortcuts. + static let keyToEquivalent: [ghostty_input_key_e : String] = [ // 0-9 - GHOSTTY_KEY_ZERO: 0x30, - GHOSTTY_KEY_ONE: 0x31, - GHOSTTY_KEY_TWO: 0x32, - GHOSTTY_KEY_THREE: 0x33, - GHOSTTY_KEY_FOUR: 0x34, - GHOSTTY_KEY_FIVE: 0x35, - GHOSTTY_KEY_SIX: 0x36, - GHOSTTY_KEY_SEVEN: 0x37, - GHOSTTY_KEY_EIGHT: 0x38, - GHOSTTY_KEY_NINE: 0x39, + GHOSTTY_KEY_ZERO: "0", + GHOSTTY_KEY_ONE: "1", + GHOSTTY_KEY_TWO: "2", + GHOSTTY_KEY_THREE: "3", + GHOSTTY_KEY_FOUR: "4", + GHOSTTY_KEY_FIVE: "5", + GHOSTTY_KEY_SIX: "6", + GHOSTTY_KEY_SEVEN: "7", + GHOSTTY_KEY_EIGHT: "8", + GHOSTTY_KEY_NINE: "9", // a-z - GHOSTTY_KEY_A: 0x61, - GHOSTTY_KEY_B: 0x62, - GHOSTTY_KEY_C: 0x63, - GHOSTTY_KEY_D: 0x64, - GHOSTTY_KEY_E: 0x65, - GHOSTTY_KEY_F: 0x66, - GHOSTTY_KEY_G: 0x67, - GHOSTTY_KEY_H: 0x68, - GHOSTTY_KEY_I: 0x69, - GHOSTTY_KEY_J: 0x6A, - GHOSTTY_KEY_K: 0x6B, - GHOSTTY_KEY_L: 0x6C, - GHOSTTY_KEY_M: 0x6D, - GHOSTTY_KEY_N: 0x6E, - GHOSTTY_KEY_O: 0x6F, - GHOSTTY_KEY_P: 0x70, - GHOSTTY_KEY_Q: 0x71, - GHOSTTY_KEY_R: 0x72, - GHOSTTY_KEY_S: 0x73, - GHOSTTY_KEY_T: 0x74, - GHOSTTY_KEY_U: 0x75, - GHOSTTY_KEY_V: 0x76, - GHOSTTY_KEY_W: 0x77, - GHOSTTY_KEY_X: 0x78, - GHOSTTY_KEY_Y: 0x79, - GHOSTTY_KEY_Z: 0x7A, + GHOSTTY_KEY_A: "A", + GHOSTTY_KEY_B: "B", + GHOSTTY_KEY_C: "C", + GHOSTTY_KEY_D: "D", + GHOSTTY_KEY_E: "E", + GHOSTTY_KEY_F: "F", + GHOSTTY_KEY_G: "G", + GHOSTTY_KEY_H: "H", + GHOSTTY_KEY_I: "I", + GHOSTTY_KEY_J: "J", + GHOSTTY_KEY_K: "K", + GHOSTTY_KEY_L: "L", + GHOSTTY_KEY_M: "M", + GHOSTTY_KEY_N: "N", + GHOSTTY_KEY_O: "O", + GHOSTTY_KEY_P: "P", + GHOSTTY_KEY_Q: "Q", + GHOSTTY_KEY_R: "R", + GHOSTTY_KEY_S: "S", + GHOSTTY_KEY_T: "T", + GHOSTTY_KEY_U: "U", + GHOSTTY_KEY_V: "V", + GHOSTTY_KEY_W: "W", + GHOSTTY_KEY_X: "X", + GHOSTTY_KEY_Y: "Y", + GHOSTTY_KEY_Z: "Z", // Symbols - GHOSTTY_KEY_APOSTROPHE: 0x27, - GHOSTTY_KEY_BACKSLASH: 0x5C, - GHOSTTY_KEY_COMMA: 0x2C, - GHOSTTY_KEY_EQUAL: 0x3D, - GHOSTTY_KEY_GRAVE_ACCENT: 0x60, - GHOSTTY_KEY_LEFT_BRACKET: 0x5B, - GHOSTTY_KEY_MINUS: 0x2D, - GHOSTTY_KEY_PERIOD: 0x2E, - GHOSTTY_KEY_RIGHT_BRACKET: 0x5D, - GHOSTTY_KEY_SEMICOLON: 0x3B, - GHOSTTY_KEY_SLASH: 0x2F, + GHOSTTY_KEY_APOSTROPHE: "'", + GHOSTTY_KEY_BACKSLASH: "\\", + GHOSTTY_KEY_COMMA: ",", + GHOSTTY_KEY_EQUAL: "=", + GHOSTTY_KEY_GRAVE_ACCENT: "`", + GHOSTTY_KEY_LEFT_BRACKET: "[", + GHOSTTY_KEY_MINUS: "-", + GHOSTTY_KEY_PERIOD: ".", + GHOSTTY_KEY_RIGHT_BRACKET: "]", + GHOSTTY_KEY_SEMICOLON: ";", + GHOSTTY_KEY_SLASH: "/", + + // Function keys + GHOSTTY_KEY_UP: "\u{F700}", + GHOSTTY_KEY_DOWN: "\u{F701}", + GHOSTTY_KEY_LEFT: "\u{F702}", + GHOSTTY_KEY_RIGHT: "\u{F703}", + GHOSTTY_KEY_HOME: "\u{F729}", + GHOSTTY_KEY_END: "\u{F72B}", + GHOSTTY_KEY_INSERT: "\u{F727}", + GHOSTTY_KEY_DELETE: "\u{F728}", + GHOSTTY_KEY_PAGE_UP: "\u{F72C}", + GHOSTTY_KEY_PAGE_DOWN: "\u{F72D}", + GHOSTTY_KEY_ESCAPE: "\u{1B}", + GHOSTTY_KEY_ENTER: "\r", + GHOSTTY_KEY_TAB: "\t", + GHOSTTY_KEY_BACKSPACE: "\u{7F}", + GHOSTTY_KEY_PRINT_SCREEN: "\u{F72E}", + GHOSTTY_KEY_PAUSE: "\u{F72F}", + + GHOSTTY_KEY_F1: "\u{F704}", + GHOSTTY_KEY_F2: "\u{F705}", + GHOSTTY_KEY_F3: "\u{F706}", + GHOSTTY_KEY_F4: "\u{F707}", + GHOSTTY_KEY_F5: "\u{F708}", + GHOSTTY_KEY_F6: "\u{F709}", + GHOSTTY_KEY_F7: "\u{F70A}", + GHOSTTY_KEY_F8: "\u{F70B}", + GHOSTTY_KEY_F9: "\u{F70C}", + GHOSTTY_KEY_F10: "\u{F70D}", + GHOSTTY_KEY_F11: "\u{F70E}", + GHOSTTY_KEY_F12: "\u{F70F}", + GHOSTTY_KEY_F13: "\u{F710}", + GHOSTTY_KEY_F14: "\u{F711}", + GHOSTTY_KEY_F15: "\u{F712}", + GHOSTTY_KEY_F16: "\u{F713}", + GHOSTTY_KEY_F17: "\u{F714}", + GHOSTTY_KEY_F18: "\u{F715}", + GHOSTTY_KEY_F19: "\u{F716}", + GHOSTTY_KEY_F20: "\u{F717}", + GHOSTTY_KEY_F21: "\u{F718}", + GHOSTTY_KEY_F22: "\u{F719}", + GHOSTTY_KEY_F23: "\u{F71A}", + GHOSTTY_KEY_F24: "\u{F71B}", + GHOSTTY_KEY_F25: "\u{F71C}", ] - + static let asciiToKey: [UInt8 : ghostty_input_key_e] = [ // 0-9 0x30: GHOSTTY_KEY_ZERO, @@ -85,7 +129,7 @@ extension Ghostty { 0x37: GHOSTTY_KEY_SEVEN, 0x38: GHOSTTY_KEY_EIGHT, 0x39: GHOSTTY_KEY_NINE, - + // A-Z 0x41: GHOSTTY_KEY_A, 0x42: GHOSTTY_KEY_B, @@ -113,7 +157,7 @@ extension Ghostty { 0x58: GHOSTTY_KEY_X, 0x59: GHOSTTY_KEY_Y, 0x5A: GHOSTTY_KEY_Z, - + // a-z 0x61: GHOSTTY_KEY_A, 0x62: GHOSTTY_KEY_B, @@ -141,7 +185,7 @@ extension Ghostty { 0x78: GHOSTTY_KEY_X, 0x79: GHOSTTY_KEY_Y, 0x7A: GHOSTTY_KEY_Z, - + // Symbols 0x27: GHOSTTY_KEY_APOSTROPHE, 0x5C: GHOSTTY_KEY_BACKSLASH, @@ -156,3 +200,4 @@ extension Ghostty { 0x2F: GHOSTTY_KEY_SLASH, ] } + From 22b925223aeda8d2c2aab9c34b8fbeb1376355ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 31 Aug 2023 11:33:58 -0700 Subject: [PATCH 06/10] macos: sync many more menu items --- macos/Sources/AppDelegate.swift | 35 +++++++++++++++++++++++++-------- macos/Sources/MainMenu.xib | 26 ++++++++++++++---------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 5473accba..e91196834 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -17,6 +17,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. @IBOutlet private var menuPreviousSplit: NSMenuItem? @IBOutlet private var menuNextSplit: NSMenuItem? + @IBOutlet private var menuSelectSplitAbove: NSMenuItem? + @IBOutlet private var menuSelectSplitBelow: NSMenuItem? + @IBOutlet private var menuSelectSplitLeft: NSMenuItem? + @IBOutlet private var menuSelectSplitRight: NSMenuItem? /// The ghostty global state. Only one per process. private var ghostty: Ghostty.AppState = Ghostty.AppState() @@ -83,17 +87,32 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { return .terminateLater } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. private func syncMenuShortcuts() { - guard let cfg = ghostty.config else { return } + guard ghostty.config != nil else { return } - if let menu = self.menuPreviousSplit { - let action = "goto_split:previous" - let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) - if let equiv = Ghostty.keyEquivalent(key: trigger.key) { - menu.keyEquivalent = equiv - menu.keyEquivalentModifierMask = Ghostty.eventModifierFlags(mods: trigger.mods) - } + syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) + syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) + syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft) + syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight) + } + + /// Syncs a single menu shortcut for the given action. The action string is the same + /// action string used for the Ghostty configuration. + private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) { + guard let cfg = ghostty.config else { return } + guard let menu = menuItem else { return } + + let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) + guard let equiv = Ghostty.keyEquivalent(key: trigger.key) else { + Self.logger.debug("no keyboard shorcut set for action=\(action)") + return } + + menu.keyEquivalent = equiv + menu.keyEquivalentModifierMask = Ghostty.eventModifierFlags(mods: trigger.mods) } private func focusedSurface() -> ghostty_surface_t? { diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index db1a70a8a..f91858bef 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -16,6 +16,10 @@ + + + + @@ -146,12 +150,14 @@ - + + - + + @@ -160,26 +166,26 @@ - - + + - - + + - - + + - - + + From 76053460d58700cc1903f685db3817dd34de5367 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 31 Aug 2023 11:40:21 -0700 Subject: [PATCH 07/10] macos: sync File menu --- macos/Sources/AppDelegate.swift | 13 ++++++ macos/Sources/Ghostty/Ghostty.Input.swift | 52 +++++++++++------------ macos/Sources/MainMenu.xib | 24 ++++++++--- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index e91196834..c7748cfa8 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -15,6 +15,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @Published var confirmQuit: Bool = false /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. + @IBOutlet private var menuNewWindow: NSMenuItem? + @IBOutlet private var menuNewTab: NSMenuItem? + @IBOutlet private var menuSplitHorizontal: NSMenuItem? + @IBOutlet private var menuSplitVertical: NSMenuItem? + @IBOutlet private var menuClose: NSMenuItem? + @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuPreviousSplit: NSMenuItem? @IBOutlet private var menuNextSplit: NSMenuItem? @IBOutlet private var menuSelectSplitAbove: NSMenuItem? @@ -91,6 +97,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private func syncMenuShortcuts() { guard ghostty.config != nil else { return } + syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) + syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow) + syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitHorizontal) + syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitVertical) + syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index b48f141e7..331fa087f 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -32,32 +32,32 @@ extension Ghostty { GHOSTTY_KEY_NINE: "9", // a-z - GHOSTTY_KEY_A: "A", - GHOSTTY_KEY_B: "B", - GHOSTTY_KEY_C: "C", - GHOSTTY_KEY_D: "D", - GHOSTTY_KEY_E: "E", - GHOSTTY_KEY_F: "F", - GHOSTTY_KEY_G: "G", - GHOSTTY_KEY_H: "H", - GHOSTTY_KEY_I: "I", - GHOSTTY_KEY_J: "J", - GHOSTTY_KEY_K: "K", - GHOSTTY_KEY_L: "L", - GHOSTTY_KEY_M: "M", - GHOSTTY_KEY_N: "N", - GHOSTTY_KEY_O: "O", - GHOSTTY_KEY_P: "P", - GHOSTTY_KEY_Q: "Q", - GHOSTTY_KEY_R: "R", - GHOSTTY_KEY_S: "S", - GHOSTTY_KEY_T: "T", - GHOSTTY_KEY_U: "U", - GHOSTTY_KEY_V: "V", - GHOSTTY_KEY_W: "W", - GHOSTTY_KEY_X: "X", - GHOSTTY_KEY_Y: "Y", - GHOSTTY_KEY_Z: "Z", + GHOSTTY_KEY_A: "a", + GHOSTTY_KEY_B: "b", + GHOSTTY_KEY_C: "c", + GHOSTTY_KEY_D: "d", + GHOSTTY_KEY_E: "e", + GHOSTTY_KEY_F: "f", + GHOSTTY_KEY_G: "g", + GHOSTTY_KEY_H: "h", + GHOSTTY_KEY_I: "i", + GHOSTTY_KEY_J: "j", + GHOSTTY_KEY_K: "k", + GHOSTTY_KEY_L: "l", + GHOSTTY_KEY_M: "m", + GHOSTTY_KEY_N: "n", + GHOSTTY_KEY_O: "o", + GHOSTTY_KEY_P: "p", + GHOSTTY_KEY_Q: "q", + GHOSTTY_KEY_R: "r", + GHOSTTY_KEY_S: "s", + GHOSTTY_KEY_T: "t", + GHOSTTY_KEY_U: "u", + GHOSTTY_KEY_V: "v", + GHOSTTY_KEY_W: "w", + GHOSTTY_KEY_X: "x", + GHOSTTY_KEY_Y: "y", + GHOSTTY_KEY_Z: "z", // Symbols GHOSTTY_KEY_APOSTROPHE: "'", diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index f91858bef..3aadb6200 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -14,12 +14,18 @@ + + + + + + @@ -68,34 +74,40 @@ - + + - + + - + + - + + - + + - + + From 10aaf8bd35ea02a1688c5c4680142cb3caffa5af Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 31 Aug 2023 11:44:43 -0700 Subject: [PATCH 08/10] macos: sync all remaining menu items --- macos/Sources/AppDelegate.swift | 11 +++++++++++ macos/Sources/MainMenu.xib | 18 +++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index c7748cfa8..4f7a16c4e 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -15,12 +15,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @Published var confirmQuit: Bool = false /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. + @IBOutlet private var menuQuit: NSMenuItem? + @IBOutlet private var menuNewWindow: NSMenuItem? @IBOutlet private var menuNewTab: NSMenuItem? @IBOutlet private var menuSplitHorizontal: NSMenuItem? @IBOutlet private var menuSplitVertical: NSMenuItem? @IBOutlet private var menuClose: NSMenuItem? @IBOutlet private var menuCloseWindow: NSMenuItem? + + @IBOutlet private var menuCopy: NSMenuItem? + @IBOutlet private var menuPaste: NSMenuItem? + @IBOutlet private var menuPreviousSplit: NSMenuItem? @IBOutlet private var menuNextSplit: NSMenuItem? @IBOutlet private var menuSelectSplitAbove: NSMenuItem? @@ -97,6 +103,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private func syncMenuShortcuts() { guard ghostty.config != nil else { return } + syncMenuShortcut(action: "quit", menuItem: self.menuQuit) + syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab) syncMenuShortcut(action: "close_surface", menuItem: self.menuClose) @@ -104,6 +112,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitHorizontal) syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitVertical) + syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy) + syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index 3aadb6200..1de314192 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -16,10 +16,13 @@ + + + @@ -62,7 +65,8 @@ - + + @@ -119,22 +123,18 @@ - + + - + + - - - - - - From b7508cdc66b34185348a3b4bd6bc587e951eba5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 31 Aug 2023 11:56:15 -0700 Subject: [PATCH 09/10] macos: setup delegate for app state, config reload callback --- macos/Sources/AppDelegate.swift | 11 ++++++++++- macos/Sources/Ghostty/AppState.swift | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 4f7a16c4e..8d307209c 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -3,7 +3,7 @@ import OSLog import GhosttyKit @NSApplicationMain -class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { +class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyAppStateDelegate { // The application logger. We should probably move this at some point to a dedicated // class/struct but for now it lives here! 🤷‍♂️ static let logger = Logger( @@ -43,6 +43,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { override init() { super.init() + ghostty.delegate = self windowManager = PrimaryWindowManager(ghostty: self.ghostty) } @@ -149,6 +150,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { ghostty.splitMoveFocus(surface: surface, direction: direction) } + //MARK: - GhosttyAppStateDelegate + + func configDidReload(_ state: Ghostty.AppState) { + syncMenuShortcuts() + } + + //MARK: - IB Actions + @IBAction func newWindow(_ sender: Any?) { windowManager.newWindow() } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index ff8bdf0e9..6367e6ad4 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -1,6 +1,11 @@ import SwiftUI import GhosttyKit +protocol GhosttyAppStateDelegate: AnyObject { + /// Called when the configuration did finish reloading. + func configDidReload(_ state: Ghostty.AppState) +} + extension Ghostty { enum AppReadiness { case loading, error, ready @@ -12,6 +17,9 @@ extension Ghostty { /// The readiness value of the state. @Published var readiness: AppReadiness = .loading + /// Optional delegate + weak var delegate: GhosttyAppStateDelegate? + /// The ghostty global configuration. This should only be changed when it is definitely /// safe to change. It is definite safe to change only when the embedded app runtime /// in Ghostty says so (usually, only in a reload configuration callback). @@ -242,6 +250,11 @@ extension Ghostty { let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() state.config = newConfig + // If we have a delegate, notify. + if let delegate = state.delegate { + delegate.configDidReload(state) + } + return newConfig } From fe5da86bb05d0f79d2f31908f2ba27ceee325d76 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 31 Aug 2023 12:41:19 -0700 Subject: [PATCH 10/10] input: maintain a reverse mapping to quickly look up trigger by action --- src/input/Binding.zig | 135 +++++++++++++++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 21 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6cb198626..5de868d1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -270,6 +270,49 @@ pub const Action = union(enum) { return Error.InvalidAction; } + + /// Returns a hash code that can be used to uniquely identify this + /// action. + pub fn hash(self: Action) u64 { + var hasher = std.hash.Wyhash.init(0); + + // Always has the active tag. + const Tag = @typeInfo(Action).Union.tag_type.?; + std.hash.autoHash(&hasher, @as(Tag, self)); + + // Hash the value of the field. + switch (self) { + inline else => |field| { + const FieldType = @TypeOf(field); + switch (FieldType) { + // Do nothing for void + void => {}, + + // Floats are hashed by their bits. This is totally not + // portable and there are edge cases such as NaNs and + // signed zeros but these are not cases we expect for + // our bindings. + f32 => std.hash.autoHash( + &hasher, + @as(u32, @bitCast(field)), + ), + f64 => std.hash.autoHash( + &hasher, + @as(u64, @bitCast(field)), + ), + + // Everything else automatically handle. + else => std.hash.autoHashStrat( + &hasher, + field, + .DeepRecursive, + ), + } + }, + } + + return hasher.final(); + } }; // A key for the C API to execute an action. This must be kept in sync @@ -315,15 +358,28 @@ pub const Set = struct { const HashMap = std.HashMapUnmanaged( Trigger, Action, - Context, + Context(Trigger), + std.hash_map.default_max_load_percentage, + ); + + const ReverseMap = std.HashMapUnmanaged( + Action, + Trigger, + Context(Action), std.hash_map.default_max_load_percentage, ); /// The set of bindings. bindings: HashMap = .{}, + /// The reverse mapping of action to binding. Note that multiple + /// bindings can map to the same action and this map will only have + /// the most recently added binding for an action. + reverse: ReverseMap = .{}, + pub fn deinit(self: *Set, alloc: Allocator) void { self.bindings.deinit(alloc); + self.reverse.deinit(alloc); self.* = undefined; } @@ -338,6 +394,9 @@ pub const Set = struct { // unbind should never go into the set, it should be handled prior assert(action != .unbind); try self.bindings.put(alloc, t, action); + errdefer _ = self.bindings.remove(t); + try self.reverse.put(alloc, action, t); + errdefer _ = self.reverse.remove(action); } /// Get a binding for a given trigger. @@ -348,36 +407,42 @@ pub const Set = struct { /// Get a trigger for the given action. An action can have multiple /// triggers so this will return the first one found. pub fn getTrigger(self: Set, a: Action) ?Trigger { - // Note: iterating over the full set each time is not ideal but - // we don't expect to have that many bindings. If this becomes - // a problem we can add a reverse map. - var it = self.bindings.iterator(); - while (it.next()) |entry| { - if (std.meta.eql(entry.value_ptr.*, a)) { - return entry.key_ptr.*; - } - } - - return null; + return self.reverse.get(a); } /// Remove a binding for a given trigger. pub fn remove(self: *Set, t: Trigger) void { + const action = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); + + // Look for a matching action in bindings and use that. + // 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. + const action_hash = action.hash(); + var it = self.bindings.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.hash() == action_hash) { + self.reverse.putAssumeCapacity(action, entry.key_ptr.*); + break; + } + } } /// The hash map context for the set. This defines how the hash map /// gets the hash key and checks for equality. - const Context = struct { - pub fn hash(ctx: Context, k: Trigger) u64 { - _ = ctx; - return k.hash(); - } + fn Context(comptime KeyType: type) type { + return struct { + pub fn hash(ctx: @This(), k: KeyType) u64 { + _ = ctx; + return k.hash(); + } - pub fn eql(ctx: Context, a: Trigger, b: Trigger) bool { - return ctx.hash(a) == ctx.hash(b); - } - }; + pub fn eql(ctx: @This(), a: KeyType, b: KeyType) bool { + return ctx.hash(a) == ctx.hash(b); + } + }; + } }; test "parse: triggers" { @@ -517,3 +582,31 @@ test "parse: action with float" { try testing.expectEqual(@as(f32, 0.5), binding.action.scroll_page_fractional); } } + +test "set: maintains reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .a }, .{ .new_window = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key == .a); + } + + // should be most recent + try s.put(alloc, .{ .key = .b }, .{ .new_window = {} }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key == .b); + } + + // removal should replace + s.remove(.{ .key = .b }); + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key == .a); + } +}