mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-24 20:56:08 +03:00
apprt/gtk-ng: surface input
This commit is contained in:
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user