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.
This commit is contained in:
Nico Weber
2024-12-04 14:22:53 -05:00
parent 50dc4b75d7
commit 2c8a52dc77
11 changed files with 147 additions and 3 deletions

View File

@ -399,6 +399,14 @@ typedef enum {
GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
} ghostty_action_fullscreen_e; } 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 // apprt.action.SecureInput
typedef enum { typedef enum {
GHOSTTY_SECURE_INPUT_ON, GHOSTTY_SECURE_INPUT_ON,
@ -579,6 +587,7 @@ typedef enum {
GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_COLOR_CHANGE,
GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CONFIG_CHANGE,
GHOSTTY_ACTION_TOGGLE_MACOS_USE_OPT_AS_ALT,
} ghostty_action_tag_e; } ghostty_action_tag_e;
typedef union { typedef union {
@ -722,6 +731,7 @@ void ghostty_surface_split_resize(ghostty_surface_t,
ghostty_action_resize_split_direction_e, ghostty_action_resize_split_direction_e,
uint16_t); uint16_t);
void ghostty_surface_split_equalize(ghostty_surface_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); bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);
void ghostty_surface_complete_clipboard_request(ghostty_surface_t, void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
const char*, const char*,

View File

@ -36,6 +36,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuUseOptionAsMetaKey: NSMenuItem?
@IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem?
@IBOutlet private var menuToggleFullScreen: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem?
@ -328,6 +329,18 @@ class AppDelegate: NSObject,
return dockMenu 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. /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
private func syncMenuShortcuts(_ config: Ghostty.Config) { private func syncMenuShortcuts(_ config: Ghostty.Config) {
guard ghostty.readiness == .ready else { return } 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: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) 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_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct"> <document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies> <dependencies>
<deployment identifier="macosx"/> <deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/> <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
</dependencies> </dependencies>
<objects> <objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication"> <customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@ -48,6 +48,7 @@
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/> <outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/> <outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
<outlet property="menuToggleVisibility" destination="DOX-wA-ilh" id="iBj-Bc-2bq"/> <outlet property="menuToggleVisibility" destination="DOX-wA-ilh" id="iBj-Bc-2bq"/>
<outlet property="menuUseOptionAsMetaKey" destination="7fW-nh-YqK" id="b7F-MJ-i0U"/>
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/> <outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
</connections> </connections>
</customObject> </customObject>
@ -192,6 +193,15 @@
</connections> </connections>
</menuItem> </menuItem>
<menuItem isSeparatorItem="YES" id="VYS-RG-uZD"/> <menuItem isSeparatorItem="YES" id="VYS-RG-uZD"/>
<menuItem title="Use Option as Meta Key" state="on" id="7fW-nh-YqK">
<string key="keyEquivalent" base64-UTF8="YES">
CQ
</string>
<connections>
<action selector="toggleUseOptionAsMetaKey:" target="-1" id="9CI-7C-odU"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="D4E-a6-qFl"/>
</items> </items>
</menu> </menu>
</menuItem> </menuItem>

View File

@ -255,6 +255,10 @@ class BaseTerminalController: NSWindowController,
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
focusedSurface = to focusedSurface = to
if let appDelegate = NSApplication.shared.delegate as? AppDelegate, let focusedSurface {
appDelegate.setToggleUseOptAsAltMenuState(value:focusedSurface.usesOptAsAlt!)
}
} }
func titleDidChange(to: String) { 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 // 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. // so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree() self.syncFocusToSurfaceTree()
if let appDelegate = NSApplication.shared.delegate as? AppDelegate, let focusedSurface {
appDelegate.setToggleUseOptAsAltMenuState(value:focusedSurface.usesOptAsAlt!)
}
} }
func windowDidResignKey(_ notification: Notification) { func windowDidResignKey(_ notification: Notification) {
@ -601,6 +609,11 @@ class BaseTerminalController: NSWindowController,
ghostty.resetTerminal(surface: surface) ghostty.resetTerminal(surface: surface)
} }
@IBAction func toggleUseOptionAsMetaKey(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleUseOptionAsMetaKey(surface: surface)
}
private struct DerivedConfig { private struct DerivedConfig {
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool let windowStepResize: Bool

View File

@ -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) { func toggleTerminalInspector(surface: ghostty_surface_t) {
let action = "inspector:toggle" let action = "inspector:toggle"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
@ -523,6 +530,9 @@ extension Ghostty {
case GHOSTTY_ACTION_RENDERER_HEALTH: case GHOSTTY_ACTION_RENDERER_HEALTH:
rendererHealth(app, target: target, v: action.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: case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL:
toggleQuickTerminal(app, target: target) 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( private static func toggleSecureInput(
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,

View File

@ -88,6 +88,11 @@ extension Ghostty {
return ghostty_surface_inspector(surface) 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 // True if the inspector should be visible
@Published var inspectorVisible: Bool = false { @Published var inspectorVisible: Bool = false {
didSet { didSet {

View File

@ -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 /// Perform a binding action. A binding is a keybinding. This function
/// must be called from the GUI thread. /// 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( .toggle_fullscreen => try self.rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
.toggle_fullscreen, .toggle_fullscreen,

View File

@ -217,6 +217,9 @@ pub const Action = union(Key) {
/// for changes. /// for changes.
config_change: ConfigChange, config_change: ConfigChange,
/// macOS only: Toggle if option always acts as alt.
toggle_macos_option_as_alt,
/// Sync with: ghostty_action_tag_e /// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) { pub const Key = enum(c_int) {
new_window, new_window,
@ -254,6 +257,7 @@ pub const Action = union(Key) {
color_change, color_change,
reload_config, reload_config,
config_change, config_change,
toggle_macos_option_as_alt,
}; };
/// Sync with: ghostty_action_u /// Sync with: ghostty_action_u
@ -375,6 +379,14 @@ pub const Fullscreen = enum(c_int) {
macos_non_native_visible_menu, 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) { pub const SecureInput = enum(c_int) {
on, on,
off, off,

View File

@ -162,7 +162,11 @@ pub const App = struct {
const translate_mods = translate_mods: { const translate_mods = translate_mods: {
var translate_mods = mods; var translate_mods = mods;
if (comptime builtin.target.isDarwin()) { 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, .false => false,
.true => mods.alt, .true => mods.alt,
.left => mods.sides.alt == .left, .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. /// Invoke an action on the surface.
export fn ghostty_surface_binding_action( export fn ghostty_surface_binding_action(
ptr: *Surface, ptr: *Surface,

View File

@ -2207,6 +2207,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
.{ .key = .{ .translated = .a }, .mods = .{ .super = true } }, .{ .key = .{ .translated = .a }, .mods = .{ .super = true } },
.{ .select_all = {} }, .{ .select_all = {} },
); );
try result.keybind.set.put(
alloc,
.{ .key = .{ .translated = .o }, .mods = .{ .super = true, .alt = true } },
.{ .toggle_macos_option_as_alt = {} },
);
// Viewport scrolling // Viewport scrolling
try result.keybind.set.put( try result.keybind.set.put(

View File

@ -358,6 +358,19 @@ pub const Action = union(enum) {
/// Toggle fullscreen mode of window. /// Toggle fullscreen mode of window.
toggle_fullscreen: void, 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 on and off. This only works on Linux.
toggle_window_decorations: void, toggle_window_decorations: void,
@ -665,6 +678,7 @@ pub const Action = union(enum) {
.last_tab, .last_tab,
.goto_tab, .goto_tab,
.move_tab, .move_tab,
.toggle_macos_option_as_alt,
.toggle_tab_overview, .toggle_tab_overview,
.new_split, .new_split,
.goto_split, .goto_split,