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,