Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

macOS: "option-as-alt" defaults to "true" for US keyboard layouts #2930

Merged
merged 1 commit into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
};
}
};
Loading