From dd1faf5e50ffc8934fd900e53762678692f308fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Nov 2023 09:51:22 -0800 Subject: [PATCH] macos: handle preedit in AppKit, enables Korean input --- include/ghostty.h | 10 ++- macos/Sources/Ghostty/SurfaceView.swift | 91 +++++++++++++++++++++---- src/apprt/embedded.zig | 65 ++++++++++++++---- 3 files changed, 137 insertions(+), 29 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index ac1077092..f261f1919 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -292,6 +292,14 @@ typedef enum { GHOSTTY_KEY_RIGHT_SUPER, } ghostty_input_key_e; +typedef struct { + ghostty_input_action_e action; + ghostty_input_mods_e mods; + uint32_t keycode; + const char *text; + bool composing; +} ghostty_input_key_s; + typedef struct { ghostty_input_key_e key; ghostty_input_mods_e mods; @@ -414,7 +422,7 @@ void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); void ghostty_surface_set_focus(ghostty_surface_t, bool); void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); -void ghostty_surface_key(ghostty_surface_t, ghostty_input_action_e, uint32_t, ghostty_input_mods_e); +void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char *, uintptr_t); void ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e); void ghostty_surface_mouse_pos(ghostty_surface_t, double, double); diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d14602c5d..16ff2bc99 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -284,6 +284,9 @@ extension Ghostty { private var focused: Bool = true private var cursor: NSCursor = .iBeam private var cursorVisible: CursorVisibility = .visible + + // This is set to non-null during keyDown to accumulate insertText contents + private var keyTextAccumulator: [String]? = nil // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -722,18 +725,36 @@ extension Ghostty { } override func keyDown(with event: NSEvent) { - let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS - keyAction(action, event: event) + // By setting this to non-nil, we note that we'rein a keyDown event. From here, + // we call interpretKeyEvents so that we can handle complex input such as Korean + // language. + keyTextAccumulator = [] + defer { keyTextAccumulator = nil } + self.interpretKeyEvents([event]) - // We specifically DO NOT call interpretKeyEvents because ghostty_surface_key - // automatically handles all key translation, and we don't handle any commands - // currently. - // - // It is possible that in the future we'll have to modify ghostty_surface_key - // and the embedding API so that we can call this because macOS needs to do - // some things with certain keys. I'm not sure. For now this works. - // - // self.interpretKeyEvents([event]) + let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + + // If we have text, then we've composed a character, send that down. We do this + // first because if we completed a preedit, the text will be available here + // AND we'll have a preedit. + var handled: Bool = false + if let list = keyTextAccumulator, list.count > 0 { + handled = true + for text in list { + keyAction(action, event: event, text: text) + } + } + + // If we have marked text, we're in a preedit state. Send that down. + if (markedText.length > 0) { + handled = true + keyAction(action, event: event, preedit: markedText.string) + } + + if (!handled) { + // No text or anything, we want to handle this manually. + keyAction(action, event: event) + } } override func keyUp(with event: NSEvent) { @@ -764,8 +785,41 @@ extension Ghostty { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let surface = self.surface else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_key(surface, action, UInt32(event.keyCode), mods) + + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = nil + key_ev.composing = false + ghostty_surface_key(surface, key_ev) + } + + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { + guard let surface = self.surface else { return } + + preedit.withCString { ptr in + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = ptr + key_ev.composing = true + ghostty_surface_key(surface, key_ev) + } + } + + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { + guard let surface = self.surface else { return } + + text.withCString { ptr in + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = ptr + ghostty_surface_key(surface, key_ev) + } } // MARK: Menu Handlers @@ -873,6 +927,17 @@ extension Ghostty { return } + // If insertText is called, our preedit must be over. + unmarkText() + + // If we have an accumulator we're in another key event so we just + // accumulate and return. + if var acc = keyTextAccumulator { + acc.append(chars) + keyTextAccumulator = acc + return + } + let len = chars.utf8CString.count if (len == 0) { return } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 4042a8dfb..d3950d65e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -244,6 +244,20 @@ pub const Surface = struct { working_directory: [*:0]const u8 = "", }; + /// This is the key event sent for ghostty_surface_key. + pub const KeyEvent = struct { + /// The three below are absolutely required. + action: input.Action, + mods: input.Mods, + keycode: u32, + + /// Optionally, the embedder can handle text translation and send + /// the text value here. If text is non-nil, it is assumed that the + /// embedder also handles dead key states and sets composing as necessary. + text: ?[:0]const u8, + composing: bool, + }; + pub fn init(self: *Surface, app: *App, opts: Options) !void { const nsview = objc.Object.fromId(opts.nsview orelse return error.NSViewMustBeSet); @@ -625,10 +639,12 @@ pub const Surface = struct { pub fn keyCallback( self: *Surface, - action: input.Action, - keycode: u32, - mods: input.Mods, + event: KeyEvent, ) !void { + const action = event.action; + const keycode = event.keycode; + const mods = event.mods; + // True if this is a key down event const is_down = action == .press or action == .repeat; @@ -666,7 +682,12 @@ pub const Surface = struct { // the raw keycode. var buf: [128]u8 = undefined; const result: input.Keymap.Translation = if (is_down) translate: { - const result = try self.app.keymap.translate( + // If the event provided us with text, then we use this as a result + // and do not do manual translation. + const result: input.Keymap.Translation = if (event.text) |text| .{ + .text = text, + .composing = event.composing, + } else try self.app.keymap.translate( &buf, &self.keymap_state, @intCast(keycode), @@ -1165,6 +1186,29 @@ pub const Inspector = struct { pub const CAPI = struct { const global = &@import("../main.zig").state; + /// This is the same as Surface.KeyEvent but this is the raw C API version. + const KeyEvent = extern struct { + action: input.Action, + mods: c_int, + keycode: u32, + text: ?[*:0]const u8, + composing: bool, + + /// Convert to surface key event. + fn keyEvent(self: KeyEvent) Surface.KeyEvent { + return .{ + .action = self.action, + .mods = @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(self.mods))), + )), + .keycode = self.keycode, + .text = if (self.text) |ptr| std.mem.sliceTo(ptr, 0) else null, + .composing = self.composing, + }; + } + }; + /// Create a new app. export fn ghostty_app_new( opts: *const apprt.runtime.App.Options, @@ -1307,18 +1351,9 @@ pub const CAPI = struct { /// with a keypress, i.e. IME keyboard. export fn ghostty_surface_key( surface: *Surface, - action: input.Action, - keycode: u32, - c_mods: c_int, + event: KeyEvent, ) void { - surface.keyCallback( - action, - keycode, - @bitCast(@as( - input.Mods.Backing, - @truncate(@as(c_uint, @bitCast(c_mods))), - )), - ) catch |err| { + surface.keyCallback(event.keyEvent()) catch |err| { log.err("error processing key event err={}", .{err}); return; };