diff --git a/include/ghostty.h b/include/ghostty.h index bcaba1eac..856b260d8 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -230,12 +230,11 @@ 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; +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 @@ -289,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); @@ -315,7 +315,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/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..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( @@ -14,6 +14,26 @@ 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 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? + @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() @@ -23,6 +43,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { override init() { super.init() + ghostty.delegate = self windowManager = PrimaryWindowManager(ghostty: self.ghostty) } @@ -33,6 +54,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 +100,64 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { return .terminateLater } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. + 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) + 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: "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) + 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? { + 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) + } + + //MARK: - GhosttyAppStateDelegate + + func configDidReload(_ state: Ghostty.AppState) { + syncMenuShortcuts() + } + + //MARK: - IB Actions + @IBAction func newWindow(_ sender: Any?) { windowManager.newWindow() } @@ -98,11 +180,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 +214,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/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 7643b7d0d..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). @@ -140,11 +148,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) { @@ -236,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 } diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift new file mode 100644 index 000000000..331fa087f --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -0,0 +1,203 @@ +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? { + 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] = []; + 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) + } + + /// 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: "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: "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: "'", + 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, + 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 11f809fc9..91f2f5263 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 @@ -615,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..1de314192 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -12,7 +12,25 @@ - + + + + + + + + + + + + + + + + + + + @@ -47,7 +65,8 @@ - + + @@ -59,34 +78,40 @@ - + + - + + - + + - + + - + + - + + @@ -98,22 +123,18 @@ - + + - + + - - - - - - @@ -141,12 +162,14 @@ - + + - + + @@ -155,26 +178,26 @@ - - + + - - + + - - + + - - + + 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. 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 82315dbb0..5de868d1a 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,118 @@ 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; + } + + /// 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 @@ -278,7 +325,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, @@ -308,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; } @@ -331,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. @@ -338,23 +404,45 @@ 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 { + 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" { @@ -427,7 +515,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" { @@ -494,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); + } +}