diff --git a/src/Surface.zig b/src/Surface.zig index 3e7300d08..9fc5b1d90 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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, @@ -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), diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6a4411a85..451605af7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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 = .{}, }; } @@ -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, @@ -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); } @@ -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))); } diff --git a/src/config/Config.zig b/src/config/Config.zig index fa531dc7e..7771a60ec 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1574,20 +1574,41 @@ keybind: Keybinds = .{}, /// editor, etc. @"macos-titlebar-proxy-icon": MacTitlebarProxyIcon = .visible, -/// 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. +/// 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. /// -/// The values `left` or `right` enable this for the left or right *Option* -/// key, respectively. +/// 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. +/// +/// The values `left` or `right` enable this for the left or right *Option* +/// key, respectively. +/// /// 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 diff --git a/src/input.zig b/src/input.zig index 9e3997d97..83be38d3d 100644 --- a/src/input.zig +++ b/src/input.zig @@ -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"); @@ -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; diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 25d85e78d..4bac7ee6b 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -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, @@ -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, diff --git a/src/input/KeymapDarwin.zig b/src/input/KeymapDarwin.zig index 5ba7c6440..3d81b0f4b 100644 --- a/src/input/KeymapDarwin.zig +++ b/src/input/KeymapDarwin.zig @@ -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; @@ -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 { @@ -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. @@ -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; diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig new file mode 100644 index 000000000..73674df2c --- /dev/null +++ b/src/input/keyboard.zig @@ -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, + }; + } +};