apprt/gtk-ng: surface input

This commit is contained in:
Mitchell Hashimoto
2025-07-18 13:59:53 -07:00
parent 1037428813
commit c23adeef38
3 changed files with 417 additions and 32 deletions

View File

@ -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,

View File

@ -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<space>" ("안녕"). 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,
};

View File

@ -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;
}
}
}