From 38477ed54719b4ec4a96df2fdebfaf85e47da5d7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 11 Aug 2023 09:16:51 -0700 Subject: [PATCH] apprt/gtk: use manual translation, handle dead key states --- src/apprt/gtk.zig | 253 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 196 insertions(+), 57 deletions(-) diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index fe9570b5f..e202b8778 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -675,6 +675,13 @@ pub const Surface = struct { cursor_pos: apprt.CursorPos, clipboard: c.GValue, + /// Key input states. See gtkKeyPressed for detailed descriptions. + in_keypress: bool = false, + im_context: *c.GtkIMContext, + im_composing: bool = false, + im_buf: [128]u8 = undefined, + im_len: u7 = 0, + pub fn init(self: *Surface, app: *App, opts: Options) !void { const widget = @as(*c.GtkWidget, @ptrCast(opts.gl_area)); c.gtk_gl_area_set_required_version(opts.gl_area, 3, 3); @@ -694,15 +701,6 @@ pub const Surface = struct { c.gtk_widget_add_controller(widget, ec_focus); errdefer c.gtk_widget_remove_controller(widget, ec_focus); - // Tell the key controller that we're interested in getting a full - // input method so raw characters/strings are given too. - const im_context = c.gtk_im_multicontext_new(); - errdefer c.g_object_unref(im_context); - c.gtk_event_controller_key_set_im_context( - @ptrCast(ec_key), - im_context, - ); - // Create a second key controller so we can receive the raw // key-press events BEFORE the input method gets them. const ec_key_press = c.gtk_event_controller_key_new(); @@ -729,6 +727,12 @@ pub const Surface = struct { errdefer c.g_object_unref(ec_scroll); c.gtk_widget_add_controller(widget, ec_scroll); + // The input method context that we use to translate key events into + // characters. This doesn't have an event key controller attached because + // we call it manually from our own key controller. + const im_context = c.gtk_im_multicontext_new(); + errdefer c.g_object_unref(im_context); + // The GL area has to be focusable so that it can receive events c.gtk_widget_set_focusable(widget, 1); c.gtk_widget_set_focus_on_click(widget, 1); @@ -748,6 +752,7 @@ pub const Surface = struct { .size = .{ .width = 800, .height = 600 }, .cursor_pos = .{ .x = 0, .y = 0 }, .clipboard = std.mem.zeroes(c.GValue), + .im_context = im_context, }; errdefer self.* = undefined; @@ -761,11 +766,14 @@ pub const Surface = struct { _ = c.g_signal_connect_data(ec_key_press, "key-released", c.G_CALLBACK(>kKeyReleased), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_focus, "enter", c.G_CALLBACK(>kFocusEnter), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_focus, "leave", c.G_CALLBACK(>kFocusLeave), self, null, G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(>kInputCommit), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(gesture_click, "released", c.G_CALLBACK(>kMouseUp), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_motion, "motion", c.G_CALLBACK(>kMouseMotion), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_scroll, "scroll", c.G_CALLBACK(>kMouseScroll), self, null, G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "preedit-start", c.G_CALLBACK(>kInputPreeditStart), self, null, G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(>kInputPreeditChanged), self, null, G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(>kInputPreeditEnd), self, null, G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(>kInputCommit), self, null, G_CONNECT_DEFAULT); } fn realize(self: *Surface) !void { @@ -792,8 +800,6 @@ pub const Surface = struct { } pub fn deinit(self: *Surface) void { - c.g_value_unset(&self.clipboard); - // We don't allocate anything if we aren't realized. if (!self.realized) return; @@ -803,6 +809,10 @@ pub const Surface = struct { // Clean up our core surface so that all the rendering and IO stop. self.core_surface.deinit(); self.core_surface = undefined; + + // Free all our GTK stuff + c.g_object_unref(self.im_context); + c.g_value_unset(&self.clipboard); } fn render(self: *Surface) !void { @@ -1123,57 +1133,129 @@ pub const Surface = struct { }; } + /// Key press event. This is where we do ALL of our key handling, + /// 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 do the following: + /// + /// 1. Emit a keyCallback for the key press with the right keys. + /// 2. Emit a charCallback if a unicode char was generated from the + /// keypresses, but only if keyCallback didn't consume the input. + /// + /// This callback will first set the "in_keypress" flag to true. This + /// lets our IM callbacks know that we're in a keypress event so they don't + /// emit a charCallback since this function will do it after the keyCallback + /// (remember, the order matters!). + /// + /// Next, we run the keypress through the input method context in order + /// to determine if we're in a dead key state, completed unicode char, etc. + /// This all happens through various callbacks: preedit, commit, etc. + /// These inspect "in_keypress" if they have to and set some instance + /// state. + /// + /// Finally, we map our keys to input.Keys, emit the keyCallback, then + /// emit the charCallback if we have to. + /// + /// 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. fn gtkKeyPressed( - _: *c.GtkEventControllerKey, - keyval_event: c.guint, + ec_key: *c.GtkEventControllerKey, + _: c.guint, keycode: c.guint, - state: c.GdkModifierType, + gtk_mods: c.GdkModifierType, ud: ?*anyopaque, ) callconv(.C) c.gboolean { const self = userdataSelf(ud.?); - const display = c.gtk_widget_get_display(@ptrCast(self.gl_area)).?; + const mods = translateMods(gtk_mods); - // We want to use only the key that corresponds to the hardware key. - // I suspect this logic is actually wrong for customized keyboards, - // maybe international keyboards, but I don't have an easy way to - // test that that I know of... sorry! - var keys: [*c]c.GdkKeymapKey = undefined; - var keyvals: [*c]c.guint = undefined; - var keys_len: c_int = undefined; - const found = c.gdk_display_map_keycode(display, keycode, &keys, &keyvals, &keys_len); - defer if (found > 0) { - c.g_free(keys); - c.g_free(keyvals); - }; + // We mark that we're in a keypress event. We use this in our + // IM commit callback to determine if we need to send a char callback + // to the core surface or not. + self.in_keypress = true; + defer self.in_keypress = false; - // We look for the keyval corresponding to this key pressed with - // zero modifiers. We're assuming this always exist but unsure if - // that assumption is true. - const keyval = keyval: { - if (found > 0) { - for (keys[0..@intCast(keys_len)], 0..) |key, i| { - if (key.group == 0 and key.level == 0) - break :keyval keyvals[i]; - } + // 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 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 .invalid; + + // Pass the event through the IM controller to handle dead key states. + // Filter is true if the event was handled by the IM controller. + const event = c.gtk_event_controller_get_current_event(@ptrCast(ec_key)); + _ = c.gtk_im_context_filter_keypress(self.im_context, event) != 0; + + // If we're not in a dead key state, we want to translate our text + // to some input.Key. + const key = if (!self.im_composing) key: { + if (self.im_len != 1) break :key physical_key; + break :key input.Key.fromASCII(self.im_buf[0]) orelse physical_key; + } else .invalid; + + // If both keys are invalid then we won't call the key callback. But + // if either one is valid, we want to give it a chance. + if (key != .invalid or physical_key != .invalid) { + const consumed = self.core_surface.keyCallback( + .press, + key, + physical_key, + mods, + ) catch |err| { + log.err("error in key callback err={}", .{err}); + return 0; + }; + + // If we consume the key then we want to reset the dead key state. + if (consumed) { + c.gtk_im_context_reset(self.im_context); + + // This is kloodge right now to reset the surface ignore_char + // state. We should refactor the API contract with the surface + // to be that if we consume a key then we don't call the char + // callback. + // + // If you don't do this, then after a consumed char a pure + // char event will be ignored. i.e. an emoji keyboard entry. + self.core_surface.charCallback(0) catch {}; + return 1; + } + } + + // If this is a dead key, then we're composing a character and + // we end processing here. We don't process keybinds for dead keys. + if (self.im_composing) { + // TODO: we ultimately want to update some surface state so that + // we can show the user that we're in dead key mode and the + // precomposed character. For now, we can just ignore and that + // is not incorrect behavior. + return 0; + } + + // Next, we want to call the char callback with each codepoint. + if (self.im_len > 0) { + const text = self.im_buf[0..self.im_len]; + const view = std.unicode.Utf8View.init(text) catch |err| { + log.warn("cannot build utf8 view over input: {}", .{err}); + return 0; + }; + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + self.core_surface.charCallback(cp) catch |err| { + log.err("error in char callback err={}", .{err}); + return 0; + }; } - log.warn("key-press with unknown key keyval={} keycode={}", .{ - keyval_event, - keycode, - }); - return 0; - }; + return 1; + } - const key = translateKey(keyval); - const mods = translateMods(state); - log.debug("key-press code={} key={} mods={}", .{ keycode, key, mods }); - const processed = self.core_surface.keyCallback(.press, key, key, mods) catch |err| { - log.err("error in key callback err={}", .{err}); - return 0; - }; - - // If we processed the key, we say we handled it. - return if (processed) 1 else 0; + return 0; } fn gtkKeyReleased( @@ -1188,12 +1270,52 @@ pub const Surface = struct { const key = translateKey(keyval); const mods = translateMods(state); const self = userdataSelf(ud.?); - self.core_surface.keyCallback(.release, key, key, mods) catch |err| { + const consumed = self.core_surface.keyCallback(.release, key, key, mods) catch |err| { log.err("error in key callback err={}", .{err}); return 0; }; - return 0; + return if (consumed) 1 else 0; + } + + fn gtkInputPreeditStart( + _: *c.GtkIMContext, + ud: ?*anyopaque, + ) callconv(.C) void { + //log.debug("preedit start", .{}); + const self = userdataSelf(ud.?); + if (!self.in_keypress) return; + + // Mark that we are now composing a string with a dead key state. + // We'll record the string in the preedit-changed callback. + self.im_composing = true; + } + + fn gtkInputPreeditChanged( + ctx: *c.GtkIMContext, + ud: ?*anyopaque, + ) callconv(.C) void { + const self = userdataSelf(ud.?); + if (!self.in_keypress) return; + + // Get our pre-edit string that we'll use to show the user. + var buf: [*c]u8 = undefined; + _ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null); + defer c.g_free(buf); + const str = std.mem.sliceTo(buf, 0); + log.debug("preedit str={s}", .{str}); + + // TODO: actually use this string. + } + + fn gtkInputPreeditEnd( + _: *c.GtkIMContext, + ud: ?*anyopaque, + ) callconv(.C) void { + //log.debug("preedit end", .{}); + const self = userdataSelf(ud.?); + if (!self.in_keypress) return; + self.im_composing = false; } fn gtkInputCommit( @@ -1201,13 +1323,30 @@ pub const Surface = struct { bytes: [*:0]u8, ud: ?*anyopaque, ) callconv(.C) void { + const self = userdataSelf(ud.?); const str = std.mem.sliceTo(bytes, 0); + + // If we're in a key event, then we want to buffer the commit so + // that we can send the proper keycallback followed by the char + // callback. + if (self.in_keypress) { + if (str.len <= self.im_buf.len) { + @memcpy(self.im_buf[0..str.len], str); + self.im_len = @intCast(str.len); + } else { + log.warn("not enough buffer space for input method commit", .{}); + } + + return; + } + + // We're not in a keypress, so this was sent from an on-screen emoji + // keyboard or someting like that. Send the characters directly to + // the surface. const view = std.unicode.Utf8View.init(str) catch |err| { log.warn("cannot build utf8 view over input: {}", .{err}); return; }; - - const self = userdataSelf(ud.?); var it = view.iterator(); while (it.nextCodepoint()) |cp| { self.core_surface.charCallback(cp) catch |err| {