diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index ce7b0ced8..820724d38 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -53,6 +53,19 @@ pub fn getCursorPos(self: *const Self) !apprt.CursorPos { return .{ .x = 0, .y = 0 }; } +pub fn supportsClipboard( + self: *const Self, + clipboard_type: apprt.Clipboard, +) bool { + _ = self; + return switch (clipboard_type) { + .standard, + .selection, + .primary, + => true, + }; +} + pub fn clipboardRequest( self: *Self, clipboard_type: apprt.Clipboard, diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 0331a3d3f..dd26ed31a 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -2,10 +2,12 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const adw = @import("adw"); const gdk = @import("gdk"); +const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); +const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); const renderer = @import("../../../renderer.zig"); const CoreSurface = @import("../../../Surface.zig"); @@ -58,10 +60,10 @@ pub const Surface = extern struct { /// The GLAarea that renders the actual surface. This is a binding /// to the template so it doesn't have to be unrefed manually. - gl_area: *gtk.GLArea, + gl_area: *gtk.GLArea = undefined, /// The apprt Surface. - rt_surface: ApprtSurface, + rt_surface: ApprtSurface = undefined, /// The core surface backing this GTK surface. This starts out /// null because it can't be initialized until there is an available @@ -77,6 +79,13 @@ pub const Surface = extern struct { /// Cached metrics for libghostty callbacks size: apprt.SurfaceSize, + /// Various input method state. All related to key input. + in_keyevent: IMKeyEvent = .false, + im_context: ?*gtk.IMMulticontext = null, + im_composing: bool = false, + im_buf: [128]u8 = undefined, + im_len: u7 = 0, + pub var offset: c_int = 0; }; @@ -103,6 +112,54 @@ pub const Surface = extern struct { priv.gl_area.queueRender(); } + /// Key press event (press or release). + /// + /// At a high level, we want to construct an `input.KeyEvent` and + /// pass that to `keyCallback`. At a low level, this is more complicated + /// than it appears because we need to construct all of this information + /// and its not given to us. + /// + /// For all events, we run the GdkEvent through the input method context. + /// This allows the input method to capture the event and trigger + /// callbacks such as preedit, commit, etc. + /// + /// 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 + /// get the underlying logical key, etc. + /// + /// Then we can emit the keyCallback. + pub fn keyEvent( + self: *Surface, + action: input.Action, + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + ) bool { + log.warn("keyEvent action={}", .{action}); + + _ = self; + _ = ec_key; + _ = keyval; + _ = keycode; + _ = gtk_mods; + return false; + } + //--------------------------------------------------------------- // Libghostty Callbacks @@ -224,6 +281,83 @@ pub const Surface = extern struct { priv.config = app.getConfig(); } + const self_widget = self.as(gtk.Widget); + + // Setup our event controllers to get input events + const ec_key = gtk.EventControllerKey.new(); + errdefer ec_key.unref(); + self_widget.addController(ec_key.as(gtk.EventController)); + errdefer self_widget.removeController(ec_key.as(gtk.EventController)); + _ = gtk.EventControllerKey.signals.key_pressed.connect( + ec_key, + *Self, + ecKeyPressed, + self, + .{}, + ); + _ = gtk.EventControllerKey.signals.key_released.connect( + ec_key, + *Self, + ecKeyReleased, + self, + .{}, + ); + + // Focus controller will tell us about focus enter/exit events + const ec_focus = gtk.EventControllerFocus.new(); + errdefer ec_focus.unref(); + self_widget.addController(ec_focus.as(gtk.EventController)); + errdefer self_widget.removeController(ec_focus.as(gtk.EventController)); + _ = gtk.EventControllerFocus.signals.enter.connect( + ec_focus, + *Self, + ecFocusEnter, + self, + .{}, + ); + _ = gtk.EventControllerFocus.signals.leave.connect( + ec_focus, + *Self, + ecFocusLeave, + self, + .{}, + ); + + // Setup our input method state + const im_context = gtk.IMMulticontext.new(); + priv.im_context = im_context; + priv.in_keyevent = .false; + priv.im_composing = false; + priv.im_len = 0; + _ = gtk.IMContext.signals.preedit_start.connect( + im_context, + *Self, + imPreeditStart, + self, + .{}, + ); + _ = gtk.IMContext.signals.preedit_changed.connect( + im_context, + *Self, + imPreeditChanged, + self, + .{}, + ); + _ = gtk.IMContext.signals.preedit_end.connect( + im_context, + *Self, + imPreeditEnd, + self, + .{}, + ); + _ = gtk.IMContext.signals.commit.connect( + im_context, + *Self, + imCommit, + self, + .{}, + ); + // Initialize our GLArea. We could do a lot of this in // the Blueprint file but I think its cleaner to separate // the "UI" part of the blueprint file from the internal logic/config @@ -272,6 +406,10 @@ pub const Surface = extern struct { v.unref(); priv.config = null; } + if (priv.im_context) |v| { + v.unref(); + priv.im_context = null; + } gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -287,8 +425,6 @@ pub const Surface = extern struct { fn finalize(self: *Self) callconv(.C) void { const priv = self.private(); if (priv.core_surface) |v| { - priv.core_surface = null; - // Remove ourselves from the list of known surfaces in the app. // We do this before deinit in case a callback triggers // searching for this surface. @@ -298,6 +434,8 @@ pub const Surface = extern struct { v.deinit(); const alloc = Application.default().allocator(); alloc.destroy(v); + + priv.core_surface = null; } gobject.Object.virtual_methods.finalize.call( @@ -309,16 +447,235 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Signal Handlers + fn ecKeyPressed( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *Self, + ) callconv(.c) c_int { + return @intFromBool(self.keyEvent( + .press, + ec_key, + keyval, + keycode, + gtk_mods, + )); + } + + fn ecKeyReleased( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + state: gdk.ModifierType, + self: *Self, + ) callconv(.c) void { + _ = self.keyEvent( + .release, + ec_key, + keyval, + keycode, + state, + ); + } + + fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.im_context) |im_context| { + im_context.as(gtk.IMContext).focusIn(); + } + + if (priv.core_surface) |surface| { + surface.focusCallback(true) catch |err| { + log.warn("error in focus callback err={}", .{err}); + }; + } + } + + fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.im_context) |im_context| { + im_context.as(gtk.IMContext).focusOut(); + } + + if (priv.core_surface) |surface| { + surface.focusCallback(false) catch |err| { + log.warn("error in focus callback err={}", .{err}); + }; + } + } + + fn imPreeditStart( + _: *gtk.IMMulticontext, + self: *Self, + ) callconv(.c) void { + // log.warn("GTKIM: preedit start", .{}); + + // Start our composing state for the input method and reset our + // input buffer to empty. + const priv = self.private(); + priv.im_composing = true; + priv.im_len = 0; + } + + fn imPreeditChanged( + ctx: *gtk.IMMulticontext, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // Any preedit change should mark that we're composing. Its possible this + // is false using fcitx5-hangul and typing "dkssud" ("안녕"). The + // second "s" results in a "commit" for "안" which sets composing to false, + // but then immediately sends a preedit change for the next symbol. With + // composing set to false we won't commit this text. Therefore, we must + // ensure it is set here. + priv.im_composing = true; + + // We can't set our preedit on our surface unless we're realized. + // We do this now because we want to still keep our input method + // state coherent. + const surface = priv.core_surface orelse return; + + // Get our pre-edit string that we'll use to show the user. + var buf: [*:0]u8 = undefined; + ctx.as(gtk.IMContext).getPreeditString( + &buf, + null, + null, + ); + defer glib.free(buf); + const str = std.mem.sliceTo(buf, 0); + + // Update our preedit state in Ghostty core + // log.warn("GTKIM: preedit change str={s}", .{str}); + surface.preeditCallback(str) catch |err| { + log.warn( + "error in preedit callback err={}", + .{err}, + ); + }; + } + + fn imPreeditEnd( + _: *gtk.IMMulticontext, + self: *Self, + ) callconv(.c) void { + // log.warn("GTKIM: preedit end", .{}); + + // End our composing state for GTK, allowing us to commit the text. + const priv = self.private(); + priv.im_composing = false; + + // End our preedit state in Ghostty core + const surface = priv.core_surface orelse return; + surface.preeditCallback(null) catch |err| { + log.warn("error in preedit callback err={}", .{err}); + }; + } + + fn imCommit( + _: *gtk.IMMulticontext, + bytes: [*:0]u8, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const str = std.mem.sliceTo(bytes, 0); + + // log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{ + // self.im_composing, + // self.in_keyevent, + // str, + // }); + + // We need to handle commit specially if we're in a key event. + // Specifically, GTK will send us a commit event for basic key + // encodings like "a" (on a US layout keyboard). We don't want + // to treat this as IME committed text because we want to associate + // it with a key event (i.e. "a" key press). + switch (priv.in_keyevent) { + // If we're not in a key event then this commit is from + // some other source (i.e. on-screen keyboard, tablet, etc.) + // and we want to commit the text to the core surface. + .false => {}, + + // If we're in a composing state and in a key event then this + // key event is resulting in a commit of multiple keypresses + // and we don't want to encode it alongside the keypress. + .composing => {}, + + // If we're not composing then this commit is just a normal + // key encoding and we want our key event to handle it so + // that Ghostty can be aware of the key event alongside + // the text. + .not_composing => { + if (str.len > priv.im_buf.len) { + log.warn("not enough buffer space for input method commit", .{}); + return; + } + + // Copy our committed text to the buffer + @memcpy(priv.im_buf[0..str.len], str); + priv.im_len = @intCast(str.len); + + // log.debug("input commit len={}", .{priv.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 + priv.im_composing = false; + + // We can't set our preedit on our surface unless we're realized. + // We do this now because we want to still keep our input method + // state coherent. + if (priv.core_surface) |surface| { + // End our preedit state. Well-behaved input methods do this for us + // by triggering a preedit-end event but some do not (ibus 1.5.29). + surface.preeditCallback(null) catch |err| { + log.warn("error in preedit callback err={}", .{err}); + }; + + // Send the text to the core surface, associated with no key (an + // invalid key, which should produce no PTY encoding). + _ = surface.keyCallback(.{ + .action = .press, + .key = .unidentified, + .mods = .{}, + .consumed_mods = .{}, + .composing = false, + .utf8 = str, + }) catch |err| { + log.warn("error in key callback err={}", .{err}); + }; + } + } + fn glareaRealize( _: *gtk.GLArea, self: *Self, ) callconv(.c) void { log.debug("realize", .{}); + // Setup our core surface self.realizeSurface() catch |err| { log.warn("surface failed to realize err={}", .{err}); - return; }; + + // Setup our input method. We do this here because this will + // create a strong reference back to ourself and we want to be + // able to release that in unrealize. + if (self.private().im_context) |im_context| { + im_context.as(gtk.IMContext).setClientWidget(self.as(gtk.Widget)); + } } fn glareaUnrealize( @@ -327,30 +684,34 @@ pub const Surface = extern struct { ) callconv(.c) void { log.debug("unrealize", .{}); - // Get our surface. If we don't have one, there's no work we - // need to do here. + // Notify our core surface const priv = self.private(); - const surface = priv.core_surface orelse return; + if (priv.core_surface) |surface| { + // There is no guarantee that our GLArea context is current + // when unrealize is emitted, so we need to make it current. + gl_area.makeCurrent(); + if (gl_area.getError()) |err| { + // I don't know a scenario this can happen, but it means + // we probably leaked memory because displayUnrealized + // below frees resources that aren't specifically OpenGL + // related. I didn't make the OpenGL renderer handle this + // scenario because I don't know if its even possible + // under valid circumstances, so let's log. + log.warn( + "gl_area_make_current failed in unrealize msg={s}", + .{err.f_message orelse "(no message)"}, + ); + log.warn("OpenGL resources and memory likely leaked", .{}); + return; + } - // There is no guarantee that our GLArea context is current - // when unrealize is emitted, so we need to make it current. - gl_area.makeCurrent(); - if (gl_area.getError()) |err| { - // I don't know a scenario this can happen, but it means - // we probably leaked memory because displayUnrealized - // below frees resources that aren't specifically OpenGL - // related. I didn't make the OpenGL renderer handle this - // scenario because I don't know if its even possible - // under valid circumstances, so let's log. - log.warn( - "gl_area_make_current failed in unrealize msg={s}", - .{err.f_message orelse "(no message)"}, - ); - log.warn("OpenGL resources and memory likely leaked", .{}); - return; + surface.renderer.displayUnrealized(); } - surface.renderer.displayUnrealized(); + // Unset our input method + if (priv.im_context) |im_context| { + im_context.as(gtk.IMContext).setClientWidget(null); + } } fn glareaRender( @@ -375,7 +736,7 @@ pub const Surface = extern struct { gl_area: *gtk.GLArea, width: c_int, height: c_int, - self: *Surface, + self: *Self, ) callconv(.c) void { // Some debug output to help understand what GTK is telling us. { @@ -519,3 +880,17 @@ pub const Surface = extern struct { pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; }; }; + +/// The state of the key event while we're doing IM composition. +/// See gtkKeyPressed for detailed descriptions. +pub const IMKeyEvent = enum { + /// Not in a key event. + false, + + /// In a key event but im_composing was either true or false + /// prior to the calling IME processing. This is important to + /// work around different input methods calling commit and + /// preedit end in a different order. + composing, + not_composing, +}; diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index a13e0c073..a91206803 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -2,15 +2,12 @@ using Gtk 4.0; using Adw 1; template $GhosttySurface: Adw.Bin { - // A box isn't strictly necessary right now but there will be more - // stuff here in the future. There's still a lot to do with surfaces. - Box { - orientation: vertical; - hexpand: true; - + Overlay { GLArea gl_area { hexpand: true; vexpand: true; + focusable: true; + focus-on-click: true; } } }