From 5ef36b39c4487849197fef647bd857b675a5e31a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 15:09:12 -0700 Subject: [PATCH] apprt/gtk-ng: port keyEvent --- src/apprt/gtk-ng/class/application.zig | 17 ++- src/apprt/gtk-ng/class/surface.zig | 200 ++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ee3b8bd06..21557d6e8 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -221,20 +221,20 @@ pub const Application = extern struct { }; // Setup our windowing protocol logic - var winproto: winprotopkg.App = winprotopkg.App.init( + var wp: winprotopkg.App = winprotopkg.App.init( alloc, display, app_id, &config, - ) catch |err| winproto: { + ) catch |err| wp: { // If we fail to detect or setup the windowing protocol // specifies, we fallback to a noop implementation so we can // still launch. log.warn("error initializing windowing protocol err={}", .{err}); - break :winproto .{ .none = .{} }; + break :wp .{ .none = .{} }; }; - errdefer winproto.deinit(alloc); - log.debug("windowing protocol={s}", .{@tagName(winproto)}); + errdefer wp.deinit(alloc); + log.debug("windowing protocol={s}", .{@tagName(wp)}); // Create our GTK Application which encapsulates our process. log.debug("creating GTK application id={s} single-instance={}", .{ @@ -265,7 +265,7 @@ pub const Application = extern struct { .rt_app = rt_app, .core_app = core_app, .config = config_obj, - .winproto = winproto, + .winproto = wp, }; return self; @@ -520,6 +520,11 @@ pub const Application = extern struct { return self.private().rt_app; } + /// Returns the app winproto implementation. + pub fn winproto(self: *Self) *winprotopkg.App { + return &self.private().winproto; + } + //--------------------------------------------------------------- // Libghostty Callbacks diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 54fcd0515..f37e0b583 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -156,12 +156,202 @@ pub const Surface = extern struct { gtk_mods: gdk.ModifierType, ) bool { log.warn("keyEvent action={}", .{action}); + const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; + const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; + const priv = self.private(); + + // The block below is all related to input method handling. See the function + // comment for some high level details and then the comments within + // the block for more specifics. + if (priv.im_context) |im_context| { + // This can trigger an input method so we need to notify the im context + // where the cursor is so it can render the dropdowns in the correct + // place. + if (priv.core_surface) |surface| { + const ime_point = surface.imePoint(); + im_context.as(gtk.IMContext).setCursorLocation(&.{ + .f_x = @intFromFloat(ime_point.x), + .f_y = @intFromFloat(ime_point.y), + .f_width = 1, + .f_height = 1, + }); + } + + // We note that we're in a keypress because we want some logic to + // depend on this. For example, we don't want to send character events + // like "a" via the input "commit" event if we're actively processing + // a keypress because we'd lose access to the keycode information. + // + // We have to maintain some additional state here of whether we + // were composing because different input methods call the callbacks + // in different orders. For example, ibus calls commit THEN preedit + // end but simple calls preedit end THEN commit. + priv.in_keyevent = if (priv.im_composing) .composing else .not_composing; + defer priv.in_keyevent = .false; + + // Pass the event through the input method which returns true if handled. + // Confusingly, not all events handled by the input method result + // in this returning true so we have to maintain some additional + // state about whether we were composing or not to determine if + // we should proceed with key encoding. + // + // Cases where the input method does not mark the event as handled: + // + // - If we change the input method via keypress while we have preedit + // text, the input method will commit the pending text but will not + // mark it as handled. We use the `.composing` state to detect + // this case. + // + // - If we switch input methods (i.e. via ctrl+shift with fcitx), + // the input method will handle the key release event but will not + // mark it as handled. I don't know any way to detect this case so + // it will result in a key event being sent to the key callback. + // For Kitty text encoding, this will result in modifiers being + // triggered despite being technically consumed. At the time of + // writing, both Kitty and Alacritty have the same behavior. I + // know of no way to fix this. + const im_handled = im_context.as(gtk.IMContext).filterKeypress(event) != 0; + // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ + // im_handled, + // self.im_len, + // self.im_composing, + // }); + + // If the input method handled the event, you would think we would + // never proceed with key encoding for Ghostty but that is not the + // case. Input methods will handle basic character encoding like + // typing "a" and we want to associate that with the key event. + // So we have to check additional state to determine if we exit. + if (im_handled) { + // If we are composing then we're in a preedit state and do + // not want to encode any keys. For example: type a deadkey + // such as single quote on a US international keyboard layout. + if (priv.im_composing) return true; + + // If we were composing and now we're not it means that we committed + // the text. We also don't want to encode a key event for this. + // Example: enable Japanese input method, press "konn" and then + // press enter. The final enter should not be encoded and "konn" + // (in hiragana) should be written as "こん". + if (priv.in_keyevent == .composing) return true; + + // Not composing and our input method buffer is empty. This could + // mean that the input method reacted to this event by activating + // an onscreen keyboard or something equivalent. We don't know. + // But the input method handled it and didn't give us text so + // we will just assume we should not encode this. This handles a + // real scenario when ibus starts the emoji input method + // (super+.). + if (priv.im_len == 0) return true; + } + + // At this point, for the sake of explanation of internal state: + // it is possible that im_len > 0 and im_composing == false. This + // means that we received a commit event from the input method that + // we want associated with the key event. This is common: its how + // basic character translation for simple inputs like "a" work. + } + + // We always reset the length of the im buffer. There's only one scenario + // we reach this point with im_len > 0 and that's if we received a commit + // event from the input method. We don't want to keep that state around + // since we've handled it here. + defer priv.im_len = 0; + + // Get the keyvals for this event. + const keyval_unicode = gdk.keyvalToUnicode(keyval); + const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( + priv.gl_area.as(gtk.Widget), + key_event, + keycode, + ); + + // We want to get the physical unmapped key to process physical keybinds. + // (These are keybinds explicitly marked as requesting physical mapping). + const physical_key = keycode: for (input.keycodes.entries) |entry| { + if (entry.native == keycode) break :keycode entry.key; + } else .unidentified; + + // Get our modifier for the event + const mods: input.Mods = gtk_key.eventMods( + event, + physical_key, + gtk_mods, + action, + Application.default().winproto(), + ); + + // Get our consumed modifiers + const consumed_mods: input.Mods = consumed: { + const T = @typeInfo(gdk.ModifierType); + std.debug.assert(T.@"struct".layout == .@"packed"); + const I = T.@"struct".backing_integer.?; + + const masked = @as(I, @bitCast(key_event.getConsumedModifiers())) & @as(I, gdk.MODIFIER_MASK); + break :consumed gtk_key.translateMods(@bitCast(masked)); + }; + + // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ + // key, + // keyval, + // physical_key, + // priv.im_composing, + // priv.im_len, + // mods, + // }); + + // If we have no UTF-8 text, we try to convert our keyval to + // a text value. We have to do this because GTK will not process + // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "". + // But the keyval is set correctly so we can at least extract that. + if (priv.im_len == 0 and keyval_unicode > 0) im: { + if (std.math.cast(u21, keyval_unicode)) |cp| { + // We don't want to send control characters as IM + // text. Control characters are handled already by + // the encoder directly. + if (cp < 0x20) break :im; + + if (std.unicode.utf8Encode(cp, &priv.im_buf)) |len| { + priv.im_len = len; + } else |_| {} + } + } + + // Invoke the core Ghostty logic to handle this input. + const surface = priv.core_surface orelse return false; + const effect = surface.keyCallback(.{ + .action = action, + .key = physical_key, + .mods = mods, + .consumed_mods = consumed_mods, + .composing = priv.im_composing, + .utf8 = priv.im_buf[0..priv.im_len], + .unshifted_codepoint = keyval_unicode_unshifted, + }) catch |err| { + log.err("error in key callback err={}", .{err}); + return false; + }; + + switch (effect) { + .closed => return true, + .ignored => {}, + .consumed => if (action == .press or action == .repeat) { + // If we were in the composing state then we reset our context. + // We do NOT want to reset if we're not in the composing state + // because there is other IME state that we want to preserve, + // such as quotation mark ordering for Chinese input. + if (priv.im_composing) { + if (priv.im_context) |im_context| { + im_context.as(gtk.IMContext).reset(); + } + + surface.preeditCallback(null) catch {}; + } + + return true; + }, + } - _ = self; - _ = ec_key; - _ = keyval; - _ = keycode; - _ = gtk_mods; return false; }