diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/input/KeymapDarwin.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/input/KeymapDarwin.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/input/KeymapDarwin.zig | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/input/KeymapDarwin.zig b/vendor/libghostty-vt/src/input/KeymapDarwin.zig new file mode 100644 index 00000000..a8702730 --- /dev/null +++ b/vendor/libghostty-vt/src/input/KeymapDarwin.zig @@ -0,0 +1,289 @@ +// Keymap is responsible for translating keyboard inputs into localized chars. +/// +/// For example, the physical key "S" on a US-layout keyboard might mean "O" +/// in Dvorak. On international keyboard layouts, it may require multiple +/// keystrokes to produce a single character that is otherwise a single +/// keystroke on a US-layout keyboard. +/// +/// This information is critical to know for many reasons. For keybindings, +/// if a user configures "ctrl+o" to do something, it should work with the +/// physical "ctrl+S" key on a Dvorak keyboard and so on. +/// +/// This is currently only implemented for macOS. +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 Mods = @import("key.zig").Mods; + +/// The current input source that is selected for the keyboard. This can +/// and does change whenever the user selects a new keyboard layout. This +/// change doesn't happen automatically; the user of this struct has to +/// detect it and then call `reload` to update the keymap. +source: *TISInputSource, + +/// The keyboard layout for the current input source. +/// +/// This doesn't need to be freed because its owned by the InputSource. +unicode_layout: *const UCKeyboardLayout, + +pub const Error = error{ + GetInputSourceFailed, + TranslateFailed, +}; + +/// The state that has to be passed in with each call to translate. +/// The contents of this are meant to mostly be opaque and can change +/// for platform-specific reasons. +pub const State = struct { + dead_key: u32 = 0, +}; + +/// The result of a translation. The result of a translation can be multiple +/// states. For example, if the user types a dead key, the result will be +/// "composing" since they're still in the process of composing a full +/// character. +pub const Translation = struct { + /// The translation result. If this is a dead key state, then this will + /// be pre-edit text that can be displayed but will ultimately be replaced. + text: []const u8 = "", + + /// Whether the text is still composing, i.e. this is a dead key state. + composing: bool = false, + + /// The mods that were consumed to produce this translation + mods: Mods = .{}, +}; + +pub fn init() !Keymap { + var keymap: Keymap = .{ .source = undefined, .unicode_layout = undefined }; + try keymap.reinit(); + return keymap; +} + +pub fn deinit(self: *const Keymap) void { + macos.foundation.CFRelease(self.source); +} + +/// Reload the keymap. This must be called if the user changes their +/// keyboard layout. +pub fn reload(self: *Keymap) !void { + macos.foundation.CFRelease(self.source); + 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 { + self.source = TISCopyCurrentKeyboardLayoutInputSource() orelse + return Error.GetInputSourceFailed; + + self.unicode_layout = layout: { + // This returns a CFDataRef + const data_raw = TISGetInputSourceProperty( + self.source, + kTISPropertyUnicodeKeyLayoutData, + ) orelse return Error.GetInputSourceFailed; + const data: *CFData = @ptrCast(data_raw); + + // 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. +pub fn translate( + self: *const Keymap, + out: []u8, + state: *State, + code: u16, + input_mods: Mods, +) !Translation { + // On macOS we strip ctrl because UCKeyTranslate + // converts to the masked values (i.e. ctrl+c becomes 3) + // and we don't want that behavior in Ghostty ever. This makes + // this file not a general-purpose keymap implementation. + const mods: Mods = mods: { + var v = input_mods; + v.ctrl = false; + break :mods v; + }; + + // Get the keycode for the space key, using comptime. + const code_space: u16 = comptime space: for (codes) |entry| { + if (std.mem.eql(u8, entry.code, "Space")) + break :space entry.native; + } else @compileError("space code not found"); + + // Convert our mods from our format to the Carbon API format + const modifier_state: u32 = (CarbonMods{ + .alt = if (mods.alt) true else false, + .ctrl = if (mods.ctrl) true else false, + .meta = if (mods.super) true else false, + .shift = if (mods.shift) true else false, + .caps_lock = if (mods.caps_lock) true else false, + }).ucKeyTranslate(); + + // We use 4 here because the Chromium source code uses 4 and Chrome + // works pretty well. They have a todo to look into longer sequences + // but given how mature that software is I think this is fine. + // + // From Chromium: + // Per Apple docs, the buffer length can be up to 255 but is rarely more than 4. + // https://developer.apple.com/documentation/coreservices/1390584-uckeytranslate + var char: [4]u16 = undefined; + var char_count: c_ulong = 0; + if (UCKeyTranslate( + self.unicode_layout, + code, + kUCKeyActionDown, + modifier_state, + LMGetKbdType(), + kUCKeyTranslateNoDeadKeysBit, + &state.dead_key, + char.len, + &char_count, + &char, + ) != 0) return Error.TranslateFailed; + + // If we got a dead key, then we translate again with "space" + // in order to get the pre-edit text. + const composing = if (state.dead_key != 0 and char_count == 0) composing: { + // We need to copy our dead key state so that it isn't modified. + var dead_key_ignore: u32 = state.dead_key; + if (UCKeyTranslate( + self.unicode_layout, + code_space, + kUCKeyActionDown, + modifier_state, + LMGetKbdType(), + kUCKeyTranslateNoDeadKeysMask, + &dead_key_ignore, + char.len, + &char_count, + &char, + ) != 0) return Error.TranslateFailed; + break :composing true; + } else false; + + // Convert the utf16 to utf8 + const len = try std.unicode.utf16LeToUtf8(out, char[0..char_count]); + return .{ + .text = out[0..len], + .composing = composing, + .mods = mods, + }; +} + +/// Map to the modifiers format used by the UCKeyTranslate function. +/// We use a u32 here because our bit arithmetic is all u32 anyways. +const CarbonMods = packed struct(u32) { + _padding_start: u8 = 0, + meta: bool = false, + shift: bool = false, + caps_lock: bool = false, + alt: bool = false, + ctrl: bool = false, + _padding_end: u19 = 0, + + /// Translate NSEventModifierFlags into the format used by UCKeyTranslate. + fn ucKeyTranslate(self: CarbonMods) u32 { + const int: u32 = @bitCast(self); + return (int >> 8) & 0xFF; + } + + // We got this from dumping the values out of another program since + // I can't find the header for this anywhere. I find various modifier + // headers but they do not match this! 🥺 + test "expected values" { + const testing = std.testing; + try testing.expectEqual(@as(u32, 0x100), @as(u32, @bitCast(CarbonMods{ .meta = true }))); + try testing.expectEqual(@as(u32, 0x200), @as(u32, @bitCast(CarbonMods{ .shift = true }))); + try testing.expectEqual(@as(u32, 0x400), @as(u32, @bitCast(CarbonMods{ .caps_lock = true }))); + try testing.expectEqual(@as(u32, 0x800), @as(u32, @bitCast(CarbonMods{ .alt = true }))); + try testing.expectEqual(@as(u32, 0x1000), @as(u32, @bitCast(CarbonMods{ .ctrl = true }))); + } +}; + +// The documentation for all of these types and functions is in the macOS SDK: +// Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/TextInputSources.h +extern "c" fn TISCopyCurrentKeyboardLayoutInputSource() ?*TISInputSource; +extern "c" fn TISGetInputSourceProperty(*TISInputSource, *CFString) ?*anyopaque; +extern "c" fn LMGetKbdLast() u8; +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; +const kUCKeyActionUp: u16 = 1; +const kUCKeyActionAutoKey: u16 = 2; +const kUCKeyActionDisplay: u16 = 3; +const kUCKeyTranslateNoDeadKeysBit: u32 = 0; +const kUCKeyTranslateNoDeadKeysMask: u32 = 1 << kUCKeyTranslateNoDeadKeysBit; + +const CFData = macos.foundation.Data; +const CFString = macos.foundation.String; + +test { + var keymap = try init(); + defer keymap.deinit(); + + // These tests are all commented because they depend on the user-selected + // keyboard layout... + // + // // Single quote ' which is fine on US, but dead on US-International + // var buf: [4]u8 = undefined; + // var state: State = .{}; + // { + // const result = try keymap.translate(&buf, &state, 0x27, .{}); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } + // + // // Then type "a" which should combine with the dead key to make á + // { + // const result = try keymap.translate(&buf, &state, 0x00, .{}); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } + // + // // Shift+1 = ! on US + // { + // const result = try keymap.translate(&buf, &state, 0x12, .{ .shift = true }); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } + // + // // Scratch space + // { + // const result = try keymap.translate(&buf, &state, 0x00, .{ .ctrl = true }); + // std.log.warn("map: text={s} dead={}", .{ result.text, result.composing }); + // } +} |
