apprt/gtk: fundamentally rework input method handling

Fixes #4332

This commit fundamentally reworks the input method handling in the GTK
apprt, making it work properly (as reported in the linked issue) on both
Wayland and X11. This was tested with both a Gnome desktop on Wayland
and i3 on X11 with fcitx and mozc.

The main changes are:

- Both key press and release events must be forwarded to the input
  method.

- Input method callbacks such as preedit and commit must be expected
  outside of keypress events to handle on-screen keyboards and
  non-keyboard input devices.

- Input methods should always commit when told to. Previously, we would
  only commit when a keypress event was given. This is incorrect. For
  example, it didn't work with input method changes outside the app
  which should result in committed text (as can be seen with "official"
  Gnome apps like Notes or Console).

The key input handling also now generally does less so I think input
latency should be positively affected by this change. I didn't measure.
This commit is contained in:
Mitchell Hashimoto
2025-01-21 14:29:43 -08:00
parent 5cb2fa6f75
commit 52936b9b68

View File

@ -368,10 +368,9 @@ cursor_pos: apprt.CursorPos,
inspector: ?*inspector.Inspector = null, inspector: ?*inspector.Inspector = null,
/// Key input states. See gtkKeyPressed for detailed descriptions. /// Key input states. See gtkKeyPressed for detailed descriptions.
in_keypress: bool = false, in_keyevent: bool = false,
im_context: *c.GtkIMContext, im_context: *c.GtkIMContext,
im_composing: bool = false, im_composing: bool = false,
im_commit_buffered: bool = false,
im_buf: [128]u8 = undefined, im_buf: [128]u8 = undefined,
im_len: u7 = 0, im_len: u7 = 0,
@ -1604,30 +1603,36 @@ fn gtkKeyReleased(
)) 1 else 0; )) 1 else 0;
} }
/// Key press event. This is where we do ALL of our key handling, /// Key press event (press or release).
/// translation to keyboard layouts, dead key handling, etc. Key handling
/// is complicated so this comment will explain what's going on.
/// ///
/// At a high level, we want to construct an `input.KeyEvent` and /// At a high level, we want to construct an `input.KeyEvent` and
/// pass that to `keyCallback`. At a low level, this is more complicated /// pass that to `keyCallback`. At a low level, this is more complicated
/// than it appears because we need to construct all of this information /// than it appears because we need to construct all of this information
/// and its not given to us. /// and its not given to us.
/// ///
/// For press events, we run the keypress through the input method context /// For all events, we run the GdkEvent through the input method context.
/// in order to determine if we're in a dead key state, completed unicode /// This allows the input method to capture the event and trigger
/// char, etc. This all happens through various callbacks: preedit, commit, /// callbacks such as preedit, commit, etc.
/// etc. These inspect "in_keypress" if they have to and set some instance
/// state.
/// ///
/// We then take all of the information in order to determine if we have /// There are a couple important aspects to the prior paragraph: we must
/// send ALL events through the input method context. This is because
/// input methods use both key press and key release events to determine
/// the state of the input method. For example, fcitx uses key release
/// events on modifiers (i.e. ctrl+shift) to switch the input method.
///
/// We set some state to note we're in a key event (self.in_keyevent)
/// because some of the input method callbacks change behavior based on
/// this state. 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.
/// However, a "commit" event may still happen outside of a keypress
/// event from e.g. a tablet or on-screen keyboard.
///
/// Finally, we take all of the information in order to determine if we have
/// a unicode character or if we have to map the keyval to a code to /// a unicode character or if we have to map the keyval to a code to
/// get the underlying logical key, etc. /// get the underlying logical key, etc.
/// ///
/// Finally, we can emit the keyCallback. /// Then we can emit the keyCallback.
///
/// Note we ALSO have an IMContext attached directly to the widget
/// which can emit preedit and commit callbacks. But, if we're not
/// in a keypress, we let those automatically work.
pub fn keyEvent( pub fn keyEvent(
self: *Surface, self: *Surface,
action: input.Action, action: input.Action,
@ -1636,26 +1641,15 @@ pub fn keyEvent(
keycode: c.guint, keycode: c.guint,
gtk_mods: c.GdkModifierType, gtk_mods: c.GdkModifierType,
) bool { ) bool {
// log.warn("GTKIM: keyEvent action={}", .{action});
const event = c.gtk_event_controller_get_current_event( const event = c.gtk_event_controller_get_current_event(
@ptrCast(ec_key), @ptrCast(ec_key),
) orelse return false; ) orelse return false;
const keyval_unicode = c.gdk_keyval_to_unicode(keyval); // The block below is all related to input method handling. See the function
// comment for some high level details and then the comments within
// Get the unshifted unicode value of the keyval. This is used // the block for more specifics.
// by the Kitty keyboard protocol. {
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
@ptrCast(self.gl_area),
event,
keycode,
);
// We always reset our committed text when ending a keypress so that
// future keypresses don't think we have a commit event.
defer self.im_len = 0;
// We only want to send the event through the IM context if we're a press
if (action == .press or action == .repeat) {
// This can trigger an input method so we need to notify the 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 // where the cursor is so it can render the dropdowns in the correct
// place. // place.
@ -1667,41 +1661,77 @@ pub fn keyEvent(
.height = 1, .height = 1,
}); });
// We mark that we're in a keypress event. We use this in our // Pass the event through the IM controller. This will return true
// IM commit callback to determine if we need to send a char callback // if the input method handled the event.
// to the core surface or not. //
self.in_keypress = true; // Confusingly, not all events handled by the input method result
defer self.in_keypress = false; // in this returning true so we have to maintain some local state to
// find those and in one case we simply lose information.
// Pass the event through the IM controller to handle dead key states. //
// Filter is true if the event was handled by the IM controller. // - If we change the input method via keypress while we have preedit
const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0; // text, the input method will commit the pending text but will not
// log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing }); // mark it as handled. We use the `was_composing` variable to detect
// this case.
// If this is a dead key, then we're composing a character and //
// we need to set our proper preedit state. // - If we switch input methods (i.e. via ctrl+shift with fcitx),
if (self.im_composing) preedit: { // the input method will handle the key release event but will not
const text = self.im_buf[0..self.im_len]; // mark it as handled. I don't know any way to detect this case so
self.core_surface.preeditCallback(text) catch |err| { // it will result in a key event being sent to the key callback.
log.err("error in preedit callback err={}", .{err}); // For Kitty text encoding, this will result in modifiers being
break :preedit; // 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 was_composing = self.im_composing;
const im_handled = filter: {
// 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.
self.in_keyevent = true;
defer self.in_keyevent = false;
break :filter c.gtk_im_context_filter_keypress(
self.im_context,
event,
) != 0;
}; };
// log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{
// im_handled,
// self.im_len,
// self.im_composing,
// });
// If we're composing then we don't want to send the key if (self.im_composing) {
// event to the core surface so we always return immediately. // If we're composing and the input method handled this event then
// we don't continue processing it. Any preedit changes or any of that
// would've been handled by the preedit events.
if (im_handled) return true; if (im_handled) return true;
} else { } else if (was_composing) {
// If we aren't composing, then we set our preedit to // If we were composing and now we're not it means that we committed
// empty no matter what. // the text. We also don't want to encode a key event for this.
self.core_surface.preeditCallback(null) catch {}; return true;
}
// If the IM handled this and we have no text, then we just // At this point, for the sake of explanation of internal state:
// return because this probably just changed the input method // it is possible that im_len > 0 and im_composing == false. This
// or something. // means that we received a commit event from the input method that
if (im_handled and self.im_len == 0) return true; // 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 self.im_len = 0;
// Get the keyvals for this event.
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted(
@ptrCast(self.gl_area),
event,
keycode,
);
// We want to get the physical unmapped key to process physical keybinds. // We want to get the physical unmapped key to process physical keybinds.
// (These are keybinds explicitly marked as requesting physical mapping). // (These are keybinds explicitly marked as requesting physical mapping).
const physical_key = keycode: for (input.keycodes.entries) |entry| { const physical_key = keycode: for (input.keycodes.entries) |entry| {
@ -1834,12 +1864,11 @@ fn gtkInputPreeditStart(
_: *c.GtkIMContext, _: *c.GtkIMContext,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
//log.debug("preedit start", .{}); // log.warn("GTKIM: preedit start", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// Mark that we are now composing a string with a dead key state. // Start our composing state for the input method and reset our
// We'll record the string in the preedit-changed callback. // input buffer to empty.
self.im_composing = true; self.im_composing = true;
self.im_len = 0; self.im_len = 0;
} }
@ -1848,54 +1877,35 @@ fn gtkInputPreeditChanged(
ctx: *c.GtkIMContext, ctx: *c.GtkIMContext,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
// log.warn("GTKIM: preedit change", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
// If there's buffered character, send the characters directly to the surface.
if (self.im_composing and self.im_commit_buffered) {
defer self.im_commit_buffered = false;
defer self.im_len = 0;
_ = self.core_surface.keyCallback(.{
.action = .press,
.key = .invalid,
.physical_key = .invalid,
.mods = .{},
.consumed_mods = .{},
.composing = false,
.utf8 = self.im_buf[0..self.im_len],
}) catch |err| {
log.err("error in key callback err={}", .{err});
return;
};
}
if (!self.in_keypress) return;
// Get our pre-edit string that we'll use to show the user. // Get our pre-edit string that we'll use to show the user.
var buf: [*c]u8 = undefined; var buf: [*c]u8 = undefined;
_ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null); _ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null);
defer c.g_free(buf); defer c.g_free(buf);
const str = std.mem.sliceTo(buf, 0); const str = std.mem.sliceTo(buf, 0);
// If our string becomes empty we ignore this. This can happen after // Update our preedit state in Ghostty core
// a commit event when the preedit is being cleared and we don't want self.core_surface.preeditCallback(str) catch |err| {
// to set im_len to zero. This is safe because preeditstart always sets log.err("error in preedit callback err={}", .{err});
// im_len to zero. };
if (str.len == 0) return;
// Copy the preedit string into the im_buf. This is safe because
// commit will always overwrite this.
self.im_len = @intCast(@min(self.im_buf.len, str.len));
@memcpy(self.im_buf[0..self.im_len], str);
} }
fn gtkInputPreeditEnd( fn gtkInputPreeditEnd(
_: *c.GtkIMContext, _: *c.GtkIMContext,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
//log.debug("preedit end", .{}); // log.warn("GTKIM: preedit end", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
if (!self.in_keypress) return;
// End our composing state for GTK, allowing us to commit the text.
self.im_composing = false; self.im_composing = false;
// End our preedit state in Ghostty core
self.core_surface.preeditCallback(null) catch |err| {
log.err("error in preedit callback err={}", .{err});
};
} }
fn gtkInputCommit( fn gtkInputCommit(
@ -1903,38 +1913,39 @@ fn gtkInputCommit(
bytes: [*:0]u8, bytes: [*:0]u8,
ud: ?*anyopaque, ud: ?*anyopaque,
) callconv(.C) void { ) callconv(.C) void {
// log.warn("GTKIM: input commit", .{});
const self = userdataSelf(ud.?); const self = userdataSelf(ud.?);
const str = std.mem.sliceTo(bytes, 0); const str = std.mem.sliceTo(bytes, 0);
// If we're in a key event, then we want to buffer the commit so // If we're in a keyEvent (i.e. a keyboard event) and we're not composing,
// that we can send the proper keycallback followed by the char // then this is just a normal key press resulting in UTF-8 text. We
// callback. // want the keyEvent to handle this so that the UTF-8 text can be associated
if (self.in_keypress) { // with a keyboard event.
if (str.len <= self.im_buf.len) { if (!self.im_composing and self.in_keyevent) {
@memcpy(self.im_buf[0..str.len], str); if (str.len > self.im_buf.len) {
self.im_len = @intCast(str.len);
// If composing is done and character should be committed,
// It should be committed in preedit callback.
if (self.im_composing) {
self.im_commit_buffered = true;
}
// log.debug("input commit len={}", .{self.im_len});
} else {
log.warn("not enough buffer space for input method commit", .{}); log.warn("not enough buffer space for input method commit", .{});
}
return; return;
} }
// This prevents staying in composing state after commit even though // Copy our committed text to the buffer
// input method has changed. @memcpy(self.im_buf[0..str.len], str);
self.im_len = @intCast(str.len);
// log.debug("input commit len={}", .{self.im_len});
return;
}
// If we reach this point from above it means we're composing OR
// not in a keypress. In either case, we want to commit the text
// given to us because that's what GTK is asking us to do. If we're
// not in a keypress it means that this commit came via a non-keyboard
// event (i.e. on-screen keyboard, tablet of some kind, etc.).
// Committing ends composing state
self.im_composing = false; self.im_composing = false;
// We're not in a keypress, so this was sent from an on-screen emoji // Send the text to the core surface, associated with no key (an
// keyboard or something like that. Send the characters directly to // invalid key, which should produce no PTY encoding).
// the surface.
_ = self.core_surface.keyCallback(.{ _ = self.core_surface.keyCallback(.{
.action = .press, .action = .press,
.key = .invalid, .key = .invalid,
@ -1944,7 +1955,7 @@ fn gtkInputCommit(
.composing = false, .composing = false,
.utf8 = str, .utf8 = str,
}) catch |err| { }) catch |err| {
log.err("error in key callback err={}", .{err}); log.warn("error in key callback err={}", .{err});
return; return;
}; };
} }