Skip to content

Commit

Permalink
macOS: "option-as-alt" defaults to "true" for US keyboard layouts (#2930
Browse files Browse the repository at this point in the history
)

A common issue for US-centric users of a terminal is that the "option"
key on macOS is not treated as the "alt" key in the terminal.

## Background

macOS does not have an "alt" key, but instead has an "option" key. The
"option" key is used for a variety of purposes, but the troublesome
behavior for some (and expected/desired behavior for others) is that it
is used to input special characters.

For example, on a US standard layout, `option-b` inputs `∫`. This is not
a typically desired character when using a terminal and most users will
instead expect that `option-b` maps to `alt-b` for keybinding purposes
with whatever shell, TUI, editor, etc. they're using.

On non-US layouts, the "option" key is a critical modifier key for
inputting certain characters in the same way "shift" is a critical
modifier key for inputting certain characters on US layouts.

We previously tried to change the default for `macos-option-as-alt` to
`left` (so that the left option key behaves as alt) because I had the
wrong assumption that international users always used the right option
key with terminals or were used to this. But very quickly beta users
with different layouts (such as German, I believe) noted that this is
not the case and broke their idiomatic input behavior. This behavior was
therefore reverted.

## Solution

This confusing behavior happened frequently enough that I decided to
implement the more complex behavior in this commit. The new behavior is
that when a US layout is active, `macos-option-as-alt` defaults to true
if it is unset. When a non-US layout is active, `macos-option-as-alt`
defaults to false if it is unset. This happens live as users change
their keyboard layout.

**An important goal of Ghostty is to have zero-config defaults** that
satisfy the majority of users. Fiddling with configurations is -- for
most -- an annoying task and software that works well enough out of the
box is delightful. Based on surveying beta users, I believe this commit
will result in less configuration for the majority of users.

## Other Terminals

This behavior is unique amongst terminals as far as I know.
Terminal.app, Kitty, iTerm2, Alacritty (I stopped checking there) all
default to the default macOS behavior (option is option and special
characters are inputted).

All of the aforementioned terminals have a setting to change this
behavior, identical to Ghostty (or, Ghostty identical to them perhaps
since they all predate Ghostty).

I couldn't find any history where users requested the behavior of
defaulting this to something else for US based keyboards. That's
interesting since this has come up so frequently during the Ghostty
beta!
  • Loading branch information
mitchellh authored Dec 11, 2024
2 parents 3f21921 + df97c19 commit f6d2c4f
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 15 deletions.
18 changes: 16 additions & 2 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ const DerivedConfig = struct {
mouse_scroll_multiplier: f64,
mouse_shift_capture: configpkg.MouseShiftCapture,
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
macos_option_as_alt: configpkg.OptionAsAlt,
macos_option_as_alt: ?configpkg.OptionAsAlt,
vt_kam_allowed: bool,
window_padding_top: u32,
window_padding_bottom: u32,
Expand Down Expand Up @@ -1990,12 +1990,26 @@ fn encodeKey(
// inputs there are many keybindings that result in no encoding
// whatsoever.
const enc: input.KeyEncoder = enc: {
const option_as_alt: configpkg.OptionAsAlt = self.config.macos_option_as_alt orelse detect: {
// Non-macOS doesn't use this value so ignore.
if (comptime builtin.os.tag != .macos) break :detect .false;

// If we don't have alt pressed, it doesn't matter what this
// config is so we can just say "false" and break out and avoid
// more expensive checks below.
if (!event.mods.alt) break :detect .false;

// Alt is pressed, we're on macOS. We break some encapsulation
// here and assume libghostty for ease...
break :detect self.rt_app.keyboardLayout().detectOptionAsAlt();
};

self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const t = &self.io.terminal;
break :enc .{
.event = event,
.macos_option_as_alt = self.config.macos_option_as_alt,
.macos_option_as_alt = option_as_alt,
.alt_esc_prefix = t.modes.get(.alt_esc_prefix),
.cursor_key_application = t.modes.get(.cursor_keys),
.keypad_key_application = t.modes.get(.keypad_keys),
Expand Down
38 changes: 34 additions & 4 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ pub const App = struct {
var config_clone = try config.clone(alloc);
errdefer config_clone.deinit();

var keymap = try input.Keymap.init();
errdefer keymap.deinit();

return .{
.core_app = core_app,
.config = config_clone,
.opts = opts,
.keymap = try input.Keymap.init(),
.keymap = keymap,
.keymap_state = .{},
};
}
Expand Down Expand Up @@ -161,8 +164,15 @@ pub const App = struct {
// then we strip the alt modifier from the mods for translation.
const translate_mods = translate_mods: {
var translate_mods = mods;
if (comptime builtin.target.isDarwin()) {
const strip = switch (self.config.@"macos-option-as-alt") {
if ((comptime builtin.target.isDarwin()) and translate_mods.alt) {
// Note: the keyboardLayout() function is not super cheap
// so we only want to run it if alt is already pressed hence
// the above condition.
const option_as_alt: configpkg.OptionAsAlt =
self.config.@"macos-option-as-alt" orelse
self.keyboardLayout().detectOptionAsAlt();

const strip = switch (option_as_alt) {
.false => false,
.true => mods.alt,
.left => mods.sides.alt == .left,
Expand Down Expand Up @@ -382,6 +392,25 @@ pub const App = struct {
}
}

/// Loads the keyboard layout.
///
/// Kind of expensive so this should be avoided if possible. When I say
/// "kind of expensive" I mean that its not something you probably want
/// to run on every keypress.
pub fn keyboardLayout(self: *const App) input.KeyboardLayout {
// We only support keyboard layout detection on macOS.
if (comptime builtin.os.tag != .macos) return .unknown;

// Any layout larger than this is not something we can handle.
var buf: [256]u8 = undefined;
const id = self.keymap.sourceId(&buf) catch |err| {
comptime assert(@TypeOf(err) == error{OutOfMemory});
return .unknown;
};

return input.KeyboardLayout.mapAppleId(id) orelse .unknown;
}

pub fn wakeup(self: *const App) void {
self.opts.wakeup(self.opts.userdata);
}
Expand Down Expand Up @@ -1551,7 +1580,8 @@ pub const CAPI = struct {
@truncate(@as(c_uint, @bitCast(mods_raw))),
));
const result = mods.translation(
surface.core_surface.config.macos_option_as_alt,
surface.core_surface.config.macos_option_as_alt orelse
surface.app.keyboardLayout().detectOptionAsAlt(),
);
return @intCast(@as(input.Mods.Backing, @bitCast(result)));
}
Expand Down
35 changes: 28 additions & 7 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1574,20 +1574,41 @@ keybind: Keybinds = .{},
/// editor, etc.
@"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .visible,

/// macOS doesn't have a distinct "alt" key and instead has the "option"
/// key which behaves slightly differently. On macOS by default, the
/// option key plus a character will sometimes produces a Unicode character.
/// For example, on US standard layouts option-b produces "∫". This may be
/// undesirable if you want to use "option" as an "alt" key for keybindings
/// in terminal programs or shells.
///
/// This configuration lets you change the behavior so that option is treated
/// as alt.
///
/// The default behavior (unset) will depend on your active keyboard
/// layout. If your keyboard layout is one of the keyboard layouts listed
/// below, then the default value is "true". Otherwise, the default
/// value is "false". Keyboard layouts with a default value of "true" are:
///
/// - U.S. Standard
/// - U.S. International
///
/// Note that if an *Option*-sequence doesn't produce a printable character, it
/// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`).
///
/// Explicit values that can be set:
///
/// If `true`, the *Option* key will be treated as *Alt*. This makes terminal
/// sequences expecting *Alt* to work properly, but will break Unicode input
/// sequences on macOS if you use them via the *Alt* key. You may set this to
/// `false` to restore the macOS *Alt* key unicode sequences but this will break
/// terminal sequences expecting *Alt* to work.
/// sequences on macOS if you use them via the *Alt* key.
///
/// You may set this to `false` to restore the macOS *Alt* key unicode
/// sequences but this will break terminal sequences expecting *Alt* to work.
///
/// The values `left` or `right` enable this for the left or right *Option*
/// key, respectively.
///
/// Note that if an *Option*-sequence doesn't produce a printable character, it
/// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`).
///
/// This does not work with GLFW builds.
@"macos-option-as-alt": OptionAsAlt = .false,
@"macos-option-as-alt": ?OptionAsAlt = null,

/// Whether to enable the macOS window shadow. The default value is true.
/// With some window managers and window transparency settings, you may
Expand Down
2 changes: 2 additions & 0 deletions src/input.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const builtin = @import("builtin");

const mouse = @import("input/mouse.zig");
const key = @import("input/key.zig");
const keyboard = @import("input/keyboard.zig");

pub const function_keys = @import("input/function_keys.zig");
pub const keycodes = @import("input/keycodes.zig");
Expand All @@ -13,6 +14,7 @@ pub const Action = key.Action;
pub const Binding = @import("input/Binding.zig");
pub const Link = @import("input/Link.zig");
pub const Key = key.Key;
pub const KeyboardLayout = keyboard.Layout;
pub const KeyEncoder = @import("input/KeyEncoder.zig");
pub const KeyEvent = key.KeyEvent;
pub const InspectorMode = Binding.Action.InspectorMode;
Expand Down
4 changes: 2 additions & 2 deletions src/input/KeyEncoder.zig
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ fn kitty(
// Determine if the Alt modifier should be treated as an actual
// modifier (in which case it prevents associated text) or as
// the macOS Option key, which does not prevent associated text.
const alt_prevents_text = if (comptime builtin.target.isDarwin())
const alt_prevents_text = if (comptime builtin.os.tag == .macos)
switch (self.macos_option_as_alt) {
.left => all_mods.sides.alt == .left,
.right => all_mods.sides.alt == .right,
Expand Down Expand Up @@ -422,7 +422,7 @@ fn legacyAltPrefix(
// On macOS, we only handle option like alt in certain
// circumstances. Otherwise, macOS does a unicode translation
// and we allow that to happen.
if (comptime builtin.target.isDarwin()) {
if (comptime builtin.os.tag == .macos) {
switch (self.macos_option_as_alt) {
.false => return null,
.left => if (mods.sides.alt == .right) return null,
Expand Down
26 changes: 26 additions & 0 deletions src/input/KeymapDarwin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const Keymap = @This();

const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const macos = @import("macos");
const codes = @import("keycodes.zig").entries;
const Key = @import("key.zig").Key;
Expand Down Expand Up @@ -72,6 +73,24 @@ pub fn reload(self: *Keymap) !void {
try self.reinit();
}

/// Get the input source ID for the current keyboard layout. The input
/// source ID is a unique identifier for the keyboard layout which is uniquely
/// defined by Apple.
///
/// This is macOS-only. Other platforms don't have an equivalent of this
/// so this isn't expected to be generally implemented.
pub fn sourceId(self: *const Keymap, buf: []u8) Allocator.Error![]const u8 {
// Get the raw CFStringRef
const id_raw = TISGetInputSourceProperty(
self.source,
kTISPropertyInputSourceID,
) orelse return error.OutOfMemory;

// Convert the CFStringRef to a C string into our buffer.
const id: *CFString = @ptrCast(id_raw);
return id.cstring(buf, .utf8) orelse error.OutOfMemory;
}

/// Reinit reinitializes the keymap. It assumes that all the memory associated
/// with the keymap is already freed.
fn reinit(self: *Keymap) !void {
Expand All @@ -89,6 +108,12 @@ fn reinit(self: *Keymap) !void {
// The CFDataRef contains a UCKeyboardLayout pointer
break :layout @ptrCast(data.getPointer());
};

if (comptime builtin.mode == .Debug) id: {
var buf: [256]u8 = undefined;
const id = self.sourceId(&buf) catch break :id;
std.log.debug("keyboard layout={s}", .{id});
}
}

/// Translate a single key input into a utf8 sequence.
Expand Down Expand Up @@ -200,6 +225,7 @@ extern "c" fn LMGetKbdType() u8;
extern "c" fn UCKeyTranslate(*const UCKeyboardLayout, u16, u16, u32, u32, u32, *u32, c_ulong, *c_ulong, [*]u16) i32;
extern const kTISPropertyLocalizedName: *CFString;
extern const kTISPropertyUnicodeKeyLayoutData: *CFString;
extern const kTISPropertyInputSourceID: *CFString;
const TISInputSource = opaque {};
const UCKeyboardLayout = opaque {};
const kUCKeyActionDown: u16 = 0;
Expand Down
58 changes: 58 additions & 0 deletions src/input/keyboard.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const std = @import("std");
const OptionAsAlt = @import("../config.zig").OptionAsAlt;

/// Keyboard layouts.
///
/// These aren't heavily used in Ghostty and having a fully comprehensive
/// list is not important. We only need to distinguish between a few
/// different layouts for some nice-to-have features, such as setting a default
/// value for "macos-option-as-alt".
pub const Layout = enum {
// Unknown, unmapped layout. Ghostty should not make any assumptions
// about the layout of the keyboard.
unknown,

// The remaining should be fairly self-explanatory:
us_standard,
us_international,

/// Map an Apple keyboard layout ID to a value in this enum. The layout
/// ID can be retrieved using Carbon's TIKeyboardLayoutGetInputSourceProperty
/// function.
///
/// Even though our layout supports "unknown", we return null if we don't
/// recognize the layout ID so callers can detect this scenario.
pub fn mapAppleId(id: []const u8) ?Layout {
if (std.mem.eql(u8, id, "com.apple.keylayout.US")) {
return .us_standard;
} else if (std.mem.eql(u8, id, "com.apple.keylayout.USInternational")) {
return .us_international;
}

return null;
}

/// Returns the default macos-option-as-alt value for this layout.
///
/// We apply some heuristics to change the default based on the keyboard
/// layout if "macos-option-as-alt" is unset. We do this because on some
/// keyboard layouts such as US standard layouts, users generally expect
/// an input such as option-b to map to alt-b but macOS by default will
/// convert it to the codepoint "∫".
///
/// This behavior however is desired on international layout where the
/// option key is used for important, regularly used inputs.
pub fn detectOptionAsAlt(self: Layout) OptionAsAlt {
return switch (self) {
// On US standard, the option key is typically used as alt
// and not as a modifier for other codepoints. For example,
// option-B = ∫ but usually the user wants alt-B.
.us_standard,
.us_international,
=> .true,

.unknown,
=> .false,
};
}
};

0 comments on commit f6d2c4f

Please sign in to comment.