From 2c8a52dc773fb11440fa60a5c464dd4d0d1c6871 Mon Sep 17 00:00:00 2001 From: Nico Weber Date: Wed, 4 Dec 2024 14:22:53 -0500 Subject: [PATCH] macos: Add "Use Option as Meta Key" menu item Fixes #2520. Makes state per surface, to match Terminal.app Adds menu item to xib and wires it up. Clicking the menu item sends an action into libghostty, which toggles state on the surface and sends a notification back. The menu item state then updates in response to that notification. The menu item also updates when the current surface changes, either if the current split changes, or when tabs change, or when the quick terminal is opened or closed. --- include/ghostty.h | 10 +++++++ macos/Sources/App/macOS/AppDelegate.swift | 14 +++++++++ macos/Sources/App/macOS/MainMenu.xib | 14 +++++++-- .../Terminal/BaseTerminalController.swift | 13 ++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 30 +++++++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 5 ++++ src/Surface.zig | 23 ++++++++++++++ src/apprt/action.zig | 12 ++++++++ src/apprt/embedded.zig | 10 ++++++- src/config/Config.zig | 5 ++++ src/input/Binding.zig | 14 +++++++++ 11 files changed, 147 insertions(+), 3 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index d2e59b09f..dbfd86175 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -399,6 +399,14 @@ typedef enum { GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, } ghostty_action_fullscreen_e; +// apprt.action.OptionAsAlt +typedef enum { + GHOSTTY_OPTION_AS_ALT_OFF, + GHOSTTY_OPTION_AS_ALT_ON, + GHOSTTY_OPTION_AS_ALT_LEFT, + GHOSTTY_OPTION_AS_ALT_RIGHT, +} ghostty_action_option_as_alt_e; + // apprt.action.SecureInput typedef enum { GHOSTTY_SECURE_INPUT_ON, @@ -579,6 +587,7 @@ typedef enum { GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, + GHOSTTY_ACTION_TOGGLE_MACOS_USE_OPT_AS_ALT, } ghostty_action_tag_e; typedef union { @@ -722,6 +731,7 @@ void ghostty_surface_split_resize(ghostty_surface_t, ghostty_action_resize_split_direction_e, uint16_t); void ghostty_surface_split_equalize(ghostty_surface_t); +ghostty_action_option_as_alt_e ghostty_surface_uses_opt_as_alt(ghostty_surface_t); bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char*, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index ed257d9ec..b8525b150 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -36,6 +36,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? + @IBOutlet private var menuUseOptionAsMetaKey: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @@ -328,6 +329,18 @@ class AppDelegate: NSObject, return dockMenu } + func setToggleUseOptAsAltMenuState(value: ghostty_action_option_as_alt_e) { + let menu_state = switch value { + case GHOSTTY_OPTION_AS_ALT_OFF: + NSControl.StateValue.off + case GHOSTTY_OPTION_AS_ALT_ON: + NSControl.StateValue.on + default: + NSControl.StateValue.mixed + }; + menuUseOptionAsMetaKey?.state = menu_state; + } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } @@ -364,6 +377,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(config, action: "toggle_macos_option_as_alt", menuItem: self.menuUseOptionAsMetaKey) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 7a8e0d894..c0332dbc7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -48,6 +48,7 @@ + @@ -192,6 +193,15 @@ + + +CQ + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 68c243004..ecd80c826 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -255,6 +255,10 @@ class BaseTerminalController: NSWindowController, func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { focusedSurface = to + + if let appDelegate = NSApplication.shared.delegate as? AppDelegate, let focusedSurface { + appDelegate.setToggleUseOptAsAltMenuState(value:focusedSurface.usesOptAsAlt!) + } } func titleDidChange(to: String) { @@ -473,6 +477,10 @@ class BaseTerminalController: NSWindowController, // Becoming/losing key means we have to notify our surface(s) that we have focus // so things like cursors blink, pty events are sent, etc. self.syncFocusToSurfaceTree() + + if let appDelegate = NSApplication.shared.delegate as? AppDelegate, let focusedSurface { + appDelegate.setToggleUseOptAsAltMenuState(value:focusedSurface.usesOptAsAlt!) + } } func windowDidResignKey(_ notification: Notification) { @@ -601,6 +609,11 @@ class BaseTerminalController: NSWindowController, ghostty.resetTerminal(surface: surface) } + @IBAction func toggleUseOptionAsMetaKey(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.toggleUseOptionAsMetaKey(surface: surface) + } + private struct DerivedConfig { let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9056e692a..1e2929cb9 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -255,6 +255,13 @@ extension Ghostty { } } + func toggleUseOptionAsMetaKey(surface: ghostty_surface_t) { + let action = "toggle_macos_option_as_alt" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + logger.warning("action failed action=\(action)") + } + } + func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { @@ -523,6 +530,9 @@ extension Ghostty { case GHOSTTY_ACTION_RENDERER_HEALTH: rendererHealth(app, target: target, v: action.action.renderer_health) + case GHOSTTY_ACTION_TOGGLE_MACOS_USE_OPT_AS_ALT: + toggleMacOSUseOptAsAlt(app, target: target) + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: toggleQuickTerminal(app, target: target) @@ -892,6 +902,26 @@ extension Ghostty { } } + private static func toggleMacOSUseOptAsAlt( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("macos toggle use opt as alt does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.setToggleUseOptAsAltMenuState(value:surfaceView.usesOptAsAlt!) + + default: + assertionFailure() + } + } + private static func toggleSecureInput( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 7e861a229..c0da6b84d 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -88,6 +88,11 @@ extension Ghostty { return ghostty_surface_inspector(surface) } + var usesOptAsAlt: ghostty_action_option_as_alt_e? { + guard let surface = self.surface else { return nil } + return ghostty_surface_uses_opt_as_alt(surface) + } + // True if the inspector should be visible @Published var inspectorVisible: Bool = false { didSet { diff --git a/src/Surface.zig b/src/Surface.zig index 78a842673..8a390df28 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3725,6 +3725,15 @@ fn showMouse(self: *Surface) void { }; } +pub fn usesOptionAsAlt(self: *Surface) apprt.action.OptionAsAlt { + return switch (self.config.macos_option_as_alt) { + .false => .off, + .true => .on, + .left => .left, + .right => .right, + }; +} + /// Perform a binding action. A binding is a keybinding. This function /// must be called from the GUI thread. /// @@ -4056,6 +4065,20 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_macos_option_as_alt => { + self.config.macos_option_as_alt = switch (self.config.macos_option_as_alt) { + .false => .true, + .true => .false, + .left => .right, + .right => .left, + }; + try self.rt_app.performAction( + .{ .surface = self }, + .toggle_macos_option_as_alt, + {}, + ); + }, + .toggle_fullscreen => try self.rt_app.performAction( .{ .surface = self }, .toggle_fullscreen, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 527535ffa..67bc4c5b2 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -217,6 +217,9 @@ pub const Action = union(Key) { /// for changes. config_change: ConfigChange, + /// macOS only: Toggle if option always acts as alt. + toggle_macos_option_as_alt, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { new_window, @@ -254,6 +257,7 @@ pub const Action = union(Key) { color_change, reload_config, config_change, + toggle_macos_option_as_alt, }; /// Sync with: ghostty_action_u @@ -375,6 +379,14 @@ pub const Fullscreen = enum(c_int) { macos_non_native_visible_menu, }; +/// ghostty.h API wrapper for config.OptionAsAlt. +pub const OptionAsAlt = enum(c_int) { + off, + on, + left, + right, +}; + pub const SecureInput = enum(c_int) { on, off, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6a4411a85..5fbb1aa12 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -162,7 +162,11 @@ pub const App = struct { const translate_mods = translate_mods: { var translate_mods = mods; if (comptime builtin.target.isDarwin()) { - const strip = switch (self.config.@"macos-option-as-alt") { + const option_as_alt = switch (target) { + .app => self.config.@"macos-option-as-alt", + .surface => |surface| surface.core_surface.config.macos_option_as_alt, + }; + const strip = switch (option_as_alt) { .false => false, .true => mods.alt, .left => mods.sides.alt == .left, @@ -1725,6 +1729,10 @@ pub const CAPI = struct { }; } + export fn ghostty_surface_uses_opt_as_alt(ptr: *Surface) c_int { + return @intCast(@intFromEnum(ptr.core_surface.usesOptionAsAlt())); + } + /// Invoke an action on the surface. export fn ghostty_surface_binding_action( ptr: *Surface, diff --git a/src/config/Config.zig b/src/config/Config.zig index 7fda17289..42bbf2044 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2207,6 +2207,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .a }, .mods = .{ .super = true } }, .{ .select_all = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .o }, .mods = .{ .super = true, .alt = true } }, + .{ .toggle_macos_option_as_alt = {} }, + ); // Viewport scrolling try result.keybind.set.put( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a467bfc2b..189d3d13b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -358,6 +358,19 @@ pub const Action = union(enum) { /// Toggle fullscreen mode of window. toggle_fullscreen: void, + /// Toggle if option acts as alt. + /// + /// Since this is a binary toggle and macos-option-as-alt is not a binary configuration, + /// the behavior of the action is as follows: + /// + /// 1. false is mapped to true + /// 2. true is mapped to false + /// 3. left is mapped to right + /// 4. right is mapped to left + /// + // The UI shows the last two as indeterminate states. + toggle_macos_option_as_alt: void, + /// Toggle window decorations on and off. This only works on Linux. toggle_window_decorations: void, @@ -665,6 +678,7 @@ pub const Action = union(enum) { .last_tab, .goto_tab, .move_tab, + .toggle_macos_option_as_alt, .toggle_tab_overview, .new_split, .goto_split,