From 103742881311575a5f794c412f46ef724d86e721 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 14:49:17 -0700 Subject: [PATCH 1/7] apprt/gtk-ng: bring over just enough winproto to compile --- src/apprt/gtk-ng/class/application.zig | 30 ++ src/apprt/gtk-ng/key.zig | 405 ++++++++++++++++++++ src/apprt/gtk-ng/winproto.zig | 157 ++++++++ src/apprt/gtk-ng/winproto/noop.zig | 75 ++++ src/apprt/gtk-ng/winproto/wayland.zig | 501 ++++++++++++++++++++++++ src/apprt/gtk-ng/winproto/x11.zig | 507 +++++++++++++++++++++++++ 6 files changed, 1675 insertions(+) create mode 100644 src/apprt/gtk-ng/key.zig create mode 100644 src/apprt/gtk-ng/winproto.zig create mode 100644 src/apprt/gtk-ng/winproto/noop.zig create mode 100644 src/apprt/gtk-ng/winproto/wayland.zig create mode 100644 src/apprt/gtk-ng/winproto/x11.zig diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index de7715e74..ee3b8bd06 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -3,6 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const adw = @import("adw"); +const gdk = @import("gdk"); const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); @@ -20,6 +21,7 @@ const CoreSurface = @import("../../../Surface.zig"); const adw_version = @import("../adw_version.zig"); const gtk_version = @import("../gtk_version.zig"); +const winprotopkg = @import("../winproto.zig"); const ApprtApp = @import("../App.zig"); const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; @@ -90,6 +92,9 @@ pub const Application = extern struct { /// The configuration for the application. config: *Config, + /// State and logic for the underlying windowing protocol. + winproto: winprotopkg.App, + /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled /// and initialization was successful. @@ -208,6 +213,29 @@ pub const Application = extern struct { break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; }; + const display: *gdk.Display = gdk.Display.getDefault() orelse { + // I'm unsure of any scenario where this happens. Because we don't + // want to litter null checks everywhere, we just exit here. + log.warn("gdk display is null, exiting", .{}); + std.posix.exit(1); + }; + + // Setup our windowing protocol logic + var winproto: winprotopkg.App = winprotopkg.App.init( + alloc, + display, + app_id, + &config, + ) catch |err| winproto: { + // If we fail to detect or setup the windowing protocol + // specifies, we fallback to a noop implementation so we can + // still launch. + log.warn("error initializing windowing protocol err={}", .{err}); + break :winproto .{ .none = .{} }; + }; + errdefer winproto.deinit(alloc); + log.debug("windowing protocol={s}", .{@tagName(winproto)}); + // Create our GTK Application which encapsulates our process. log.debug("creating GTK application id={s} single-instance={}", .{ app_id, @@ -237,6 +265,7 @@ pub const Application = extern struct { .rt_app = rt_app, .core_app = core_app, .config = config_obj, + .winproto = winproto, }; return self; @@ -251,6 +280,7 @@ pub const Application = extern struct { const alloc = self.allocator(); const priv = self.private(); priv.config.unref(); + priv.winproto.deinit(alloc); if (priv.transient_cgroup_base) |base| alloc.free(base); } diff --git a/src/apprt/gtk-ng/key.zig b/src/apprt/gtk-ng/key.zig new file mode 100644 index 000000000..fc3296366 --- /dev/null +++ b/src/apprt/gtk-ng/key.zig @@ -0,0 +1,405 @@ +const std = @import("std"); +const build_options = @import("build_options"); + +const gdk = @import("gdk"); +const glib = @import("glib"); +const gtk = @import("gtk"); + +const input = @import("../../input.zig"); +const winproto = @import("winproto.zig"); + +/// Returns a GTK accelerator string from a trigger. +pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { + var buf_stream = std.io.fixedBufferStream(buf); + const writer = buf_stream.writer(); + + // Modifiers + if (trigger.mods.shift) try writer.writeAll(""); + if (trigger.mods.ctrl) try writer.writeAll(""); + if (trigger.mods.alt) try writer.writeAll(""); + if (trigger.mods.super) try writer.writeAll(""); + + // Write our key + if (!try writeTriggerKey(writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = buf_stream.getWritten(); + return slice[0 .. slice.len - 1 :0]; +} + +/// Returns a XDG-compliant shortcuts string from a trigger. +/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ +pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { + var buf_stream = std.io.fixedBufferStream(buf); + const writer = buf_stream.writer(); + + // Modifiers + if (trigger.mods.shift) try writer.writeAll("SHIFT+"); + if (trigger.mods.ctrl) try writer.writeAll("CTRL+"); + if (trigger.mods.alt) try writer.writeAll("ALT+"); + if (trigger.mods.super) try writer.writeAll("LOGO+"); + + // Write our key + // NOTE: While the spec specifies that only libxkbcommon keysyms are + // expected, using GTK's keysyms should still work as they are identical + // to *X11's* keysyms (which I assume is a subset of libxkbcommon's). + // I haven't been able to any evidence to back up that assumption but + // this works for now + if (!try writeTriggerKey(writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = buf_stream.getWritten(); + return slice[0 .. slice.len - 1 :0]; +} + +fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { + switch (trigger.key) { + .physical => |k| { + const keyval = keyvalFromKey(k) orelse return false; + try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false)); + }, + + .unicode => |cp| { + if (gdk.keyvalName(cp)) |name| { + try writer.writeAll(std.mem.span(name)); + } else { + try writer.print("{u}", .{cp}); + } + }, + } + + return true; +} + +pub fn translateMods(state: gdk.ModifierType) input.Mods { + return .{ + .shift = state.shift_mask, + .ctrl = state.control_mask, + .alt = state.alt_mask, + .super = state.super_mask, + // Lock is dependent on the X settings but we just assume caps lock. + .caps_lock = state.lock_mask, + }; +} + +// Get the unshifted unicode value of the keyval. This is used +// by the Kitty keyboard protocol. +pub fn keyvalUnicodeUnshifted( + widget: *gtk.Widget, + event: *gdk.KeyEvent, + keycode: u32, +) u21 { + const display = widget.getDisplay(); + + // We need to get the currently active keyboard layout so we know + // what group to look at. + const layout = event.getLayout(); + + // Get all the possible keyboard mappings for this keycode. A keycode is the + // physical key pressed. + var keys: [*]gdk.KeymapKey = undefined; + var keyvals: [*]c_uint = undefined; + var n_entries: c_int = 0; + if (display.mapKeycode(keycode, &keys, &keyvals, &n_entries) == 0) return 0; + + defer glib.free(keys); + defer glib.free(keyvals); + + // debugging: + // std.log.debug("layout={}", .{layout}); + // for (0..@intCast(n_entries)) |i| { + // std.log.debug("keymap key={} codepoint={x}", .{ + // keys[i], + // gdk.keyvalToUnicode(keyvals[i]), + // }); + // } + + for (0..@intCast(n_entries)) |i| { + if (keys[i].f_group == layout and + keys[i].f_level == 0) + { + return std.math.cast( + u21, + gdk.keyvalToUnicode(keyvals[i]), + ) orelse 0; + } + } + + return 0; +} + +/// Returns the mods to use a key event from a GTK event. +/// This requires a lot of context because the GdkEvent +/// doesn't contain enough on its own. +pub fn eventMods( + event: *gdk.Event, + physical_key: input.Key, + gtk_mods: gdk.ModifierType, + action: input.Action, + app_winproto: *winproto.App, +) input.Mods { + const device = event.getDevice(); + + var mods = app_winproto.eventMods(device, gtk_mods); + mods.num_lock = if (device) |d| d.getNumLockState() != 0 else false; + + // We use the physical key to determine sided modifiers. As + // far as I can tell there's no other way to reliably determine + // this. + // + // We also set the main modifier to true if either side is true, + // since on both X11/Wayland, GTK doesn't set the main modifier + // if only the modifier key is pressed, but our core logic + // relies on it. + switch (physical_key) { + .shift_left => { + mods.shift = action != .release; + mods.sides.shift = .left; + }, + + .shift_right => { + mods.shift = action != .release; + mods.sides.shift = .right; + }, + + .control_left => { + mods.ctrl = action != .release; + mods.sides.ctrl = .left; + }, + + .control_right => { + mods.ctrl = action != .release; + mods.sides.ctrl = .right; + }, + + .alt_left => { + mods.alt = action != .release; + mods.sides.alt = .left; + }, + + .alt_right => { + mods.alt = action != .release; + mods.sides.alt = .right; + }, + + .meta_left => { + mods.super = action != .release; + mods.sides.super = .left; + }, + + .meta_right => { + mods.super = action != .release; + mods.sides.super = .right; + }, + + else => {}, + } + + return mods; +} + +/// Returns an input key from a keyval or null if we don't have a mapping. +pub fn keyFromKeyval(keyval: c_uint) ?input.Key { + for (keymap) |entry| { + if (entry[0] == keyval) return entry[1]; + } + + return null; +} + +/// Returns a keyval from an input key or null if we don't have a mapping. +pub fn keyvalFromKey(key: input.Key) ?c_uint { + switch (key) { + inline else => |key_comptime| { + return comptime value: { + @setEvalBranchQuota(50_000); + for (keymap) |entry| { + if (entry[1] == key_comptime) break :value entry[0]; + } + + break :value null; + }; + }, + } +} + +test "accelFromTrigger" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + try testing.expectEqualStrings("q", (try accelFromTrigger(&buf, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })).?); + + try testing.expectEqualStrings("backslash", (try accelFromTrigger(&buf, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })).?); +} + +test "xdgShortcutFromTrigger" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })).?); + + try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })).?); +} + +/// A raw entry in the keymap. Our keymap contains mappings between +/// GDK keys and our own key enum. +const RawEntry = struct { c_uint, input.Key }; + +const keymap: []const RawEntry = &.{ + .{ gdk.KEY_a, .key_a }, + .{ gdk.KEY_b, .key_b }, + .{ gdk.KEY_c, .key_c }, + .{ gdk.KEY_d, .key_d }, + .{ gdk.KEY_e, .key_e }, + .{ gdk.KEY_f, .key_f }, + .{ gdk.KEY_g, .key_g }, + .{ gdk.KEY_h, .key_h }, + .{ gdk.KEY_i, .key_i }, + .{ gdk.KEY_j, .key_j }, + .{ gdk.KEY_k, .key_k }, + .{ gdk.KEY_l, .key_l }, + .{ gdk.KEY_m, .key_m }, + .{ gdk.KEY_n, .key_n }, + .{ gdk.KEY_o, .key_o }, + .{ gdk.KEY_p, .key_p }, + .{ gdk.KEY_q, .key_q }, + .{ gdk.KEY_r, .key_r }, + .{ gdk.KEY_s, .key_s }, + .{ gdk.KEY_t, .key_t }, + .{ gdk.KEY_u, .key_u }, + .{ gdk.KEY_v, .key_v }, + .{ gdk.KEY_w, .key_w }, + .{ gdk.KEY_x, .key_x }, + .{ gdk.KEY_y, .key_y }, + .{ gdk.KEY_z, .key_z }, + + .{ gdk.KEY_0, .digit_0 }, + .{ gdk.KEY_1, .digit_1 }, + .{ gdk.KEY_2, .digit_2 }, + .{ gdk.KEY_3, .digit_3 }, + .{ gdk.KEY_4, .digit_4 }, + .{ gdk.KEY_5, .digit_5 }, + .{ gdk.KEY_6, .digit_6 }, + .{ gdk.KEY_7, .digit_7 }, + .{ gdk.KEY_8, .digit_8 }, + .{ gdk.KEY_9, .digit_9 }, + + .{ gdk.KEY_semicolon, .semicolon }, + .{ gdk.KEY_space, .space }, + .{ gdk.KEY_apostrophe, .quote }, + .{ gdk.KEY_comma, .comma }, + .{ gdk.KEY_grave, .backquote }, + .{ gdk.KEY_period, .period }, + .{ gdk.KEY_slash, .slash }, + .{ gdk.KEY_minus, .minus }, + .{ gdk.KEY_equal, .equal }, + .{ gdk.KEY_bracketleft, .bracket_left }, + .{ gdk.KEY_bracketright, .bracket_right }, + .{ gdk.KEY_backslash, .backslash }, + + .{ gdk.KEY_Up, .arrow_up }, + .{ gdk.KEY_Down, .arrow_down }, + .{ gdk.KEY_Right, .arrow_right }, + .{ gdk.KEY_Left, .arrow_left }, + .{ gdk.KEY_Home, .home }, + .{ gdk.KEY_End, .end }, + .{ gdk.KEY_Insert, .insert }, + .{ gdk.KEY_Delete, .delete }, + .{ gdk.KEY_Caps_Lock, .caps_lock }, + .{ gdk.KEY_Scroll_Lock, .scroll_lock }, + .{ gdk.KEY_Num_Lock, .num_lock }, + .{ gdk.KEY_Page_Up, .page_up }, + .{ gdk.KEY_Page_Down, .page_down }, + .{ gdk.KEY_Escape, .escape }, + .{ gdk.KEY_Return, .enter }, + .{ gdk.KEY_Tab, .tab }, + .{ gdk.KEY_BackSpace, .backspace }, + .{ gdk.KEY_Print, .print_screen }, + .{ gdk.KEY_Pause, .pause }, + + .{ gdk.KEY_F1, .f1 }, + .{ gdk.KEY_F2, .f2 }, + .{ gdk.KEY_F3, .f3 }, + .{ gdk.KEY_F4, .f4 }, + .{ gdk.KEY_F5, .f5 }, + .{ gdk.KEY_F6, .f6 }, + .{ gdk.KEY_F7, .f7 }, + .{ gdk.KEY_F8, .f8 }, + .{ gdk.KEY_F9, .f9 }, + .{ gdk.KEY_F10, .f10 }, + .{ gdk.KEY_F11, .f11 }, + .{ gdk.KEY_F12, .f12 }, + .{ gdk.KEY_F13, .f13 }, + .{ gdk.KEY_F14, .f14 }, + .{ gdk.KEY_F15, .f15 }, + .{ gdk.KEY_F16, .f16 }, + .{ gdk.KEY_F17, .f17 }, + .{ gdk.KEY_F18, .f18 }, + .{ gdk.KEY_F19, .f19 }, + .{ gdk.KEY_F20, .f20 }, + .{ gdk.KEY_F21, .f21 }, + .{ gdk.KEY_F22, .f22 }, + .{ gdk.KEY_F23, .f23 }, + .{ gdk.KEY_F24, .f24 }, + .{ gdk.KEY_F25, .f25 }, + + .{ gdk.KEY_KP_0, .numpad_0 }, + .{ gdk.KEY_KP_1, .numpad_1 }, + .{ gdk.KEY_KP_2, .numpad_2 }, + .{ gdk.KEY_KP_3, .numpad_3 }, + .{ gdk.KEY_KP_4, .numpad_4 }, + .{ gdk.KEY_KP_5, .numpad_5 }, + .{ gdk.KEY_KP_6, .numpad_6 }, + .{ gdk.KEY_KP_7, .numpad_7 }, + .{ gdk.KEY_KP_8, .numpad_8 }, + .{ gdk.KEY_KP_9, .numpad_9 }, + .{ gdk.KEY_KP_Decimal, .numpad_decimal }, + .{ gdk.KEY_KP_Divide, .numpad_divide }, + .{ gdk.KEY_KP_Multiply, .numpad_multiply }, + .{ gdk.KEY_KP_Subtract, .numpad_subtract }, + .{ gdk.KEY_KP_Add, .numpad_add }, + .{ gdk.KEY_KP_Enter, .numpad_enter }, + .{ gdk.KEY_KP_Equal, .numpad_equal }, + + .{ gdk.KEY_KP_Separator, .numpad_separator }, + .{ gdk.KEY_KP_Left, .numpad_left }, + .{ gdk.KEY_KP_Right, .numpad_right }, + .{ gdk.KEY_KP_Up, .numpad_up }, + .{ gdk.KEY_KP_Down, .numpad_down }, + .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, + .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, + .{ gdk.KEY_KP_Home, .numpad_home }, + .{ gdk.KEY_KP_End, .numpad_end }, + .{ gdk.KEY_KP_Insert, .numpad_insert }, + .{ gdk.KEY_KP_Delete, .numpad_delete }, + .{ gdk.KEY_KP_Begin, .numpad_begin }, + + .{ gdk.KEY_Copy, .copy }, + .{ gdk.KEY_Cut, .cut }, + .{ gdk.KEY_Paste, .paste }, + + .{ gdk.KEY_Shift_L, .shift_left }, + .{ gdk.KEY_Control_L, .control_left }, + .{ gdk.KEY_Alt_L, .alt_left }, + .{ gdk.KEY_Super_L, .meta_left }, + .{ gdk.KEY_Shift_R, .shift_right }, + .{ gdk.KEY_Control_R, .control_right }, + .{ gdk.KEY_Alt_R, .alt_right }, + .{ gdk.KEY_Super_R, .meta_right }, + + // TODO: media keys +}; diff --git a/src/apprt/gtk-ng/winproto.zig b/src/apprt/gtk-ng/winproto.zig new file mode 100644 index 000000000..d0ea3c205 --- /dev/null +++ b/src/apprt/gtk-ng/winproto.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; + +const gdk = @import("gdk"); + +const Config = @import("../../config.zig").Config; +const input = @import("../../input.zig"); +const key = @import("key.zig"); + +// TODO: As we get to these APIs the compiler should tell us +const ApprtWindow = void; + +pub const noop = @import("winproto/noop.zig"); +pub const x11 = @import("winproto/x11.zig"); +pub const wayland = @import("winproto/wayland.zig"); + +pub const Protocol = enum { + none, + wayland, + x11, +}; + +/// App-state for the underlying windowing protocol. There should be one +/// instance of this struct per application. +pub const App = union(Protocol) { + none: noop.App, + wayland: if (build_options.wayland) wayland.App else noop.App, + x11: if (build_options.x11) x11.App else noop.App, + + pub fn init( + alloc: Allocator, + gdk_display: *gdk.Display, + app_id: [:0]const u8, + config: *const Config, + ) !App { + inline for (@typeInfo(App).@"union".fields) |field| { + if (try field.type.init( + alloc, + gdk_display, + app_id, + config, + )) |v| { + return @unionInit(App, field.name, v); + } + } + + return .{ .none = .{} }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + switch (self.*) { + inline else => |*v| v.deinit(alloc), + } + } + + pub fn eventMods( + self: *App, + device: ?*gdk.Device, + gtk_mods: gdk.ModifierType, + ) input.Mods { + return switch (self.*) { + inline else => |*v| v.eventMods(device, gtk_mods), + } orelse key.translateMods(gtk_mods); + } + + pub fn supportsQuickTerminal(self: App) bool { + return switch (self) { + inline else => |v| v.supportsQuickTerminal(), + }; + } + + /// Set up necessary support for the quick terminal that must occur + /// *before* the window-level winproto object is created. + /// + /// Only has an effect on the Wayland backend, where the gtk4-layer-shell + /// library is initialized. + pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { + switch (self.*) { + inline else => |*v| try v.initQuickTerminal(apprt_window), + } + } +}; + +/// Per-Window state for the underlying windowing protocol. +/// +/// In Wayland, the terminology used is "Surface" and for it, this is +/// really "Surface"-specific state. But Ghostty uses the term "Surface" +/// heavily to mean something completely different, so we use "Window" here +/// to better match what it generally maps to in the Ghostty codebase. +pub const Window = union(Protocol) { + none: noop.Window, + wayland: if (build_options.wayland) wayland.Window else noop.Window, + x11: if (build_options.x11) x11.Window else noop.Window, + + pub fn init( + alloc: Allocator, + app: *App, + apprt_window: *ApprtWindow, + ) !Window { + return switch (app.*) { + inline else => |*v, tag| { + inline for (@typeInfo(Window).@"union".fields) |field| { + if (comptime std.mem.eql( + u8, + field.name, + @tagName(tag), + )) return @unionInit( + Window, + field.name, + try field.type.init( + alloc, + v, + apprt_window, + ), + ); + } + }, + }; + } + + pub fn deinit(self: *Window, alloc: Allocator) void { + switch (self.*) { + inline else => |*v| v.deinit(alloc), + } + } + + pub fn resizeEvent(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.resizeEvent(), + } + } + + pub fn syncAppearance(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.syncAppearance(), + } + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self) { + inline else => |v| v.clientSideDecorationEnabled(), + }; + } + + pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + switch (self.*) { + inline else => |*v| try v.addSubprocessEnv(env), + } + } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + switch (self.*) { + inline else => |*v| try v.setUrgent(urgent), + } + } +}; diff --git a/src/apprt/gtk-ng/winproto/noop.zig b/src/apprt/gtk-ng/winproto/noop.zig new file mode 100644 index 000000000..a03fd1959 --- /dev/null +++ b/src/apprt/gtk-ng/winproto/noop.zig @@ -0,0 +1,75 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gdk = @import("gdk"); + +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); +const ApprtWindow = void; // TODO: fix + +const log = std.log.scoped(.winproto_noop); + +pub const App = struct { + pub fn init( + _: Allocator, + _: *gdk.Display, + _: [:0]const u8, + _: *const Config, + ) !?App { + return null; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn eventMods( + _: *App, + _: ?*gdk.Device, + _: gdk.ModifierType, + ) ?input.Mods { + return null; + } + + pub fn supportsQuickTerminal(_: App) bool { + return false; + } + pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} +}; + +pub const Window = struct { + pub fn init( + _: Allocator, + _: *App, + _: *ApprtWindow, + ) !Window { + return .{}; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn updateConfigEvent( + _: *Window, + _: *const ApprtWindow.DerivedConfig, + ) !void {} + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(_: *Window) !void {} + + /// This returns true if CSD is enabled for this window. This + /// should be the actual present state of the window, not the + /// desired state. + pub fn clientSideDecorationEnabled(self: Window) bool { + _ = self; + return true; + } + + pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} + + pub fn setUrgent(_: *Window, _: bool) !void {} +}; diff --git a/src/apprt/gtk-ng/winproto/wayland.zig b/src/apprt/gtk-ng/winproto/wayland.zig new file mode 100644 index 000000000..83133723c --- /dev/null +++ b/src/apprt/gtk-ng/winproto/wayland.zig @@ -0,0 +1,501 @@ +//! Wayland protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_options = @import("build_options"); + +const gdk = @import("gdk"); +const gdk_wayland = @import("gdk_wayland"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); +const layer_shell = @import("gtk4-layer-shell"); +const wayland = @import("wayland"); + +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); +const ApprtWindow = void; // TODO: fix + +const wl = wayland.client.wl; +const org = wayland.client.org; +const xdg = wayland.client.xdg; + +const log = std.log.scoped(.winproto_wayland); + +/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). +pub const App = struct { + display: *wl.Display, + context: *Context, + + const Context = struct { + kde_blur_manager: ?*org.KdeKwinBlurManager = null, + + // FIXME: replace with `zxdg_decoration_v1` once GTK merges + // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 + kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, + + kde_slide_manager: ?*org.KdeKwinSlideManager = null, + + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, + + xdg_activation: ?*xdg.ActivationV1 = null, + + /// Whether the xdg_wm_dialog_v1 protocol is present. + /// + /// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user + /// creates a quick terminal, and we need to ensure this fails + /// gracefully if this situation occurs. + /// + /// FIXME: This is a temporary workaround - we should remove this when + /// all of our supported distros drop support for affected old + /// gtk4-layer-shell versions. + /// + /// See https://github.com/wmww/gtk4-layer-shell/issues/50 + xdg_wm_dialog_present: bool = false, + }; + + pub fn init( + alloc: Allocator, + gdk_display: *gdk.Display, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = config; + _ = app_id; + + const gdk_wayland_display = gobject.ext.cast( + gdk_wayland.WaylandDisplay, + gdk_display, + ) orelse return null; + + const display: *wl.Display = @ptrCast(@alignCast( + gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, + )); + + // Create our context for our callbacks so we have a stable pointer. + // Note: at the time of writing this comment, we don't really need + // a stable pointer, but it's too scary that we'd need one in the future + // and not have it and corrupt memory or something so let's just do it. + const context = try alloc.create(Context); + errdefer alloc.destroy(context); + context.* = .{}; + + // Get our display registry so we can get all the available interfaces + // and bind to what we need. + const registry = try display.getRegistry(); + registry.setListener(*Context, registryListener, context); + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + // Do another round-trip to get the default decoration mode + if (context.kde_decoration_manager) |deco_manager| { + deco_manager.setListener(*Context, decoManagerListener, context); + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + } + + return .{ + .display = display, + .context = context, + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + alloc.destroy(self.context); + } + + pub fn eventMods( + _: *App, + _: ?*gdk.Device, + _: gdk.ModifierType, + ) ?input.Mods { + return null; + } + + pub fn supportsQuickTerminal(self: App) bool { + if (!layer_shell.isSupported()) { + log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); + return false; + } + + if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ + .major = 1, + .minor = 0, + .patch = 4, + }) == .lt) { + log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); + return false; + } + + return true; + } + + pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { + const window = apprt_window.window.as(gtk.Window); + + layer_shell.initForWindow(window); + layer_shell.setLayer(window, .top); + layer_shell.setNamespace(window, "ghostty-quick-terminal"); + } + + fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { + // Globals should be optional pointers + const T = switch (@typeInfo(field.type)) { + .optional => |o| switch (@typeInfo(o.child)) { + .pointer => |v| v.child, + else => return null, + }, + else => return null, + }; + + // Only process Wayland interfaces + if (!@hasDecl(T, "interface")) return null; + return T; + } + + fn registryListener( + registry: *wl.Registry, + event: wl.Registry.Event, + context: *Context, + ) void { + const ctx_fields = @typeInfo(Context).@"struct".fields; + + switch (event) { + .global => |v| global: { + // We don't actually do anything with this other than checking + // for its existence, so we process this separately. + if (std.mem.orderZ(u8, v.interface, "xdg_wm_dialog_v1") == .eq) + context.xdg_wm_dialog_present = true; + + inline for (ctx_fields) |field| { + const T = getInterfaceType(field) orelse continue; + + if (std.mem.orderZ( + u8, + v.interface, + T.interface.name, + ) != .eq) break :global; + + @field(context, field.name) = registry.bind( + v.name, + T, + T.generated_version, + ) catch |err| { + log.warn( + "error binding interface {s} error={}", + .{ v.interface, err }, + ); + return; + }; + } + }, + + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| remove: { + inline for (ctx_fields) |field| { + if (getInterfaceType(field) == null) continue; + const global = @field(context, field.name) orelse break :remove; + if (global.getId() == v.name) { + global.destroy(); + @field(context, field.name) = null; + } + } + }, + } + } + + fn decoManagerListener( + _: *org.KdeKwinServerDecorationManager, + event: org.KdeKwinServerDecorationManager.Event, + context: *Context, + ) void { + switch (event) { + .default_mode => |mode| { + context.default_deco_mode = @enumFromInt(mode.mode); + }, + } + } +}; + +/// Per-window (wl_surface) state for the Wayland protocol. +pub const Window = struct { + apprt_window: *ApprtWindow, + + /// The Wayland surface for this window. + surface: *wl.Surface, + + /// The context from the app where we can load our Wayland interfaces. + app_context: *App.Context, + + /// A token that, when present, indicates that the window is blurred. + blur_token: ?*org.KdeKwinBlur = null, + + /// Object that controls the decoration mode (client/server/auto) + /// of the window. + decoration: ?*org.KdeKwinServerDecoration = null, + + /// Object that controls the slide-in/slide-out animations of the + /// quick terminal. Always null for windows other than the quick terminal. + slide: ?*org.KdeKwinSlide = null, + + /// Object that, when present, denotes that the window is currently + /// requesting attention from the user. + activation_token: ?*xdg.ActivationTokenV1 = null, + + pub fn init( + alloc: Allocator, + app: *App, + apprt_window: *ApprtWindow, + ) !Window { + _ = alloc; + + const gtk_native = apprt_window.window.as(gtk.Native); + const gdk_surface = gtk_native.getSurface() orelse return error.NotWaylandSurface; + + // This should never fail, because if we're being called at this point + // then we've already asserted that our app state is Wayland. + const gdk_wl_surface = gobject.ext.cast( + gdk_wayland.WaylandSurface, + gdk_surface, + ) orelse return error.NoWaylandSurface; + + const wl_surface: *wl.Surface = @ptrCast(@alignCast( + gdk_wl_surface.getWlSurface() orelse return error.NoWaylandSurface, + )); + + // Get our decoration object so we can control the + // CSD vs SSD status of this surface. + const deco: ?*org.KdeKwinServerDecoration = deco: { + const mgr = app.context.kde_decoration_manager orelse + break :deco null; + + const deco: *org.KdeKwinServerDecoration = mgr.create( + wl_surface, + ) catch |err| { + log.warn("could not create decoration object={}", .{err}); + break :deco null; + }; + + break :deco deco; + }; + + if (apprt_window.isQuickTerminal()) { + _ = gdk.Surface.signals.enter_monitor.connect( + gdk_surface, + *ApprtWindow, + enteredMonitor, + apprt_window, + .{}, + ); + } + + return .{ + .apprt_window = apprt_window, + .surface = wl_surface, + .app_context = app.context, + .decoration = deco, + }; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = alloc; + if (self.blur_token) |blur| blur.release(); + if (self.decoration) |deco| deco.release(); + if (self.slide) |slide| slide.release(); + } + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(self: *Window) !void { + self.syncBlur() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + self.syncDecoration() catch |err| { + log.err("failed to sync blur={}", .{err}); + }; + + if (self.apprt_window.isQuickTerminal()) { + self.syncQuickTerminal() catch |err| { + log.warn("failed to sync quick terminal appearance={}", .{err}); + }; + } + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self.getDecorationMode()) { + .Client => true, + // If we support SSDs, then we should *not* enable CSDs if we prefer SSDs. + // However, if we do not support SSDs (e.g. GNOME) then we should enable + // CSDs even if the user prefers SSDs. + .Server => if (self.app_context.kde_decoration_manager) |_| false else true, + .None => false, + else => unreachable, + }; + } + + pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + _ = self; + _ = env; + } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + const activation = self.app_context.xdg_activation orelse return; + + // If there already is a token, destroy and unset it + if (self.activation_token) |token| token.destroy(); + + self.activation_token = if (urgent) token: { + const token = try activation.getActivationToken(); + token.setSurface(self.surface); + token.setListener(*Window, onActivationTokenEvent, self); + token.commit(); + break :token token; + } else null; + } + + /// Update the blur state of the window. + fn syncBlur(self: *Window) !void { + const manager = self.app_context.kde_blur_manager orelse return; + const blur = self.apprt_window.config.background_blur; + + if (self.blur_token) |tok| { + // Only release token when transitioning from blurred -> not blurred + if (!blur.enabled()) { + manager.unset(self.surface); + tok.release(); + self.blur_token = null; + } + } else { + // Only acquire token when transitioning from not blurred -> blurred + if (blur.enabled()) { + const tok = try manager.create(self.surface); + tok.commit(); + self.blur_token = tok; + } + } + } + + fn syncDecoration(self: *Window) !void { + const deco = self.decoration orelse return; + + // The protocol requests uint instead of enum so we have + // to convert it. + deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode()))); + } + + fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { + return switch (self.apprt_window.config.window_decoration) { + .auto => self.app_context.default_deco_mode orelse .Client, + .client => .Client, + .server => .Server, + .none => .None, + }; + } + + fn syncQuickTerminal(self: *Window) !void { + const window = self.apprt_window.window.as(gtk.Window); + const config = &self.apprt_window.config; + + layer_shell.setKeyboardMode( + window, + switch (config.quick_terminal_keyboard_interactivity) { + .none => .none, + .@"on-demand" => on_demand: { + if (layer_shell.getProtocolVersion() < 4) { + log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); + break :on_demand .exclusive; + } + break :on_demand .on_demand; + }, + .exclusive => .exclusive, + }, + ); + + const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + .center => null, + }; + + for (std.meta.tags(layer_shell.ShellEdge)) |edge| { + if (anchored_edge) |anchored| { + if (edge == anchored) { + layer_shell.setMargin(window, edge, 0); + layer_shell.setAnchor(window, edge, true); + continue; + } + } + + // Arbitrary margin - could be made customizable? + layer_shell.setMargin(window, edge, 20); + layer_shell.setAnchor(window, edge, false); + } + + if (self.slide) |slide| slide.release(); + + self.slide = if (anchored_edge) |anchored| slide: { + const mgr = self.app_context.kde_slide_manager orelse break :slide null; + + const slide = mgr.create(self.surface) catch |err| { + log.warn("could not create slide object={}", .{err}); + break :slide null; + }; + + const slide_location: org.KdeKwinSlide.Location = switch (anchored) { + .top => .top, + .bottom => .bottom, + .left => .left, + .right => .right, + }; + + slide.setLocation(@intCast(@intFromEnum(slide_location))); + slide.commit(); + break :slide slide; + } else null; + } + + /// Update the size of the quick terminal based on monitor dimensions. + fn enteredMonitor( + _: *gdk.Surface, + monitor: *gdk.Monitor, + apprt_window: *ApprtWindow, + ) callconv(.c) void { + const window = apprt_window.window.as(gtk.Window); + const config = &apprt_window.config; + + var monitor_size: gdk.Rectangle = undefined; + monitor.getGeometry(&monitor_size); + + const dims = config.quick_terminal_size.calculate( + config.quick_terminal_position, + .{ + .width = @intCast(monitor_size.f_width), + .height = @intCast(monitor_size.f_height), + }, + ); + + window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); + } + + fn onActivationTokenEvent( + token: *xdg.ActivationTokenV1, + event: xdg.ActivationTokenV1.Event, + self: *Window, + ) void { + const activation = self.app_context.xdg_activation orelse return; + const current_token = self.activation_token orelse return; + + if (token.getId() != current_token.getId()) { + log.warn("received event for unknown activation token; ignoring", .{}); + return; + } + + switch (event) { + .done => |done| { + activation.activate(done.token, self.surface); + token.destroy(); + self.activation_token = null; + }, + } + } +}; diff --git a/src/apprt/gtk-ng/winproto/x11.zig b/src/apprt/gtk-ng/winproto/x11.zig new file mode 100644 index 000000000..c41ec516e --- /dev/null +++ b/src/apprt/gtk-ng/winproto/x11.zig @@ -0,0 +1,507 @@ +//! X11 window protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; + +const adw = @import("adw"); +const gdk = @import("gdk"); +const gdk_x11 = @import("gdk_x11"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); +const xlib = @import("xlib"); + +pub const c = @cImport({ + @cInclude("X11/Xlib.h"); + @cInclude("X11/Xatom.h"); + @cInclude("X11/XKBlib.h"); +}); + +const input = @import("../../../input.zig"); +const Config = @import("../../../config.zig").Config; +const ApprtWindow = void; // TODO: fix + +const log = std.log.scoped(.gtk_x11); + +pub const App = struct { + display: *xlib.Display, + base_event_code: c_int, + atoms: Atoms, + + pub fn init( + _: Allocator, + gdk_display: *gdk.Display, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + // If the display isn't X11, then we don't need to do anything. + const gdk_x11_display = gobject.ext.cast( + gdk_x11.X11Display, + gdk_display, + ) orelse return null; + + const xlib_display = gdk_x11_display.getXdisplay(); + + const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| + pn + else if (builtin.mode == .Debug) + "ghostty-debug" + else + "ghostty"; + + // Set the X11 window class property (WM_CLASS) if are are on an X11 + // display. + // + // Note that we also set the program name here using g_set_prgname. + // This is how the instance name field for WM_CLASS is derived when + // calling gdk_x11_display_set_program_class; there does not seem to be + // a way to set it directly. It does not look like this is being set by + // our other app initialization routines currently, but since we're + // currently deriving its value from x11-instance-name effectively, I + // feel like gating it behind an X11 check is better intent. + // + // This makes the property show up like so when using xprop: + // + // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" + // + // Append "-debug" on both when using the debug build. + glib.setPrgname(x11_program_name); + gdk_x11.X11Display.setProgramClass(gdk_display, app_id); + + // XKB + log.debug("Xkb.init: initializing Xkb", .{}); + log.debug("Xkb.init: running XkbQueryExtension", .{}); + var opcode: c_int = 0; + var base_event_code: c_int = 0; + var base_error_code: c_int = 0; + var major = c.XkbMajorVersion; + var minor = c.XkbMinorVersion; + if (c.XkbQueryExtension( + @ptrCast(@alignCast(xlib_display)), + &opcode, + &base_event_code, + &base_error_code, + &major, + &minor, + ) == 0) { + log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{}); + return error.XkbInitializationError; + } + + log.debug("Xkb.init: running XkbSelectEventDetails", .{}); + if (c.XkbSelectEventDetails( + @ptrCast(@alignCast(xlib_display)), + c.XkbUseCoreKbd, + c.XkbStateNotify, + c.XkbModifierStateMask, + c.XkbModifierStateMask, + ) == 0) { + log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{}); + return error.XkbInitializationError; + } + + return .{ + .display = xlib_display, + .base_event_code = base_event_code, + .atoms = .init(gdk_x11_display), + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + /// Checks for an immediate pending XKB state update event, and returns the + /// keyboard state based on if it finds any. This is necessary as the + /// standard GTK X11 API (and X11 in general) does not include the current + /// key pressed in any modifier state snapshot for that event (e.g. if the + /// pressed key is a modifier, that is not necessarily reflected in the + /// modifiers). + /// + /// Returns null if there is no event. In this case, the caller should fall + /// back to the standard GDK modifier state (this likely means the key + /// event did not result in a modifier change). + pub fn eventMods( + self: App, + device: ?*gdk.Device, + gtk_mods: gdk.ModifierType, + ) ?input.Mods { + _ = device; + _ = gtk_mods; + + // Shoutout to Mozilla for figuring out a clean way to do this, this is + // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. + if (c.XEventsQueued( + @ptrCast(@alignCast(self.display)), + c.QueuedAfterReading, + ) == 0) return null; + + var nextEvent: c.XEvent = undefined; + _ = c.XPeekEvent(@ptrCast(@alignCast(self.display)), &nextEvent); + if (nextEvent.type != self.base_event_code) return null; + + const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); + if (xkb_event.any.xkb_type != c.XkbStateNotify) return null; + + const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event); + // Check the state according to XKB masks. + const lookup_mods = xkb_state_notify_event.lookup_mods; + var mods: input.Mods = .{}; + + log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods}); + if (lookup_mods & c.ShiftMask != 0) mods.shift = true; + if (lookup_mods & c.ControlMask != 0) mods.ctrl = true; + if (lookup_mods & c.Mod1Mask != 0) mods.alt = true; + if (lookup_mods & c.Mod4Mask != 0) mods.super = true; + if (lookup_mods & c.LockMask != 0) mods.caps_lock = true; + + return mods; + } + + pub fn supportsQuickTerminal(_: App) bool { + log.warn("quick terminal is not yet supported on X11", .{}); + return false; + } + + pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} +}; + +pub const Window = struct { + app: *App, + config: *const ApprtWindow.DerivedConfig, + gtk_window: *adw.ApplicationWindow, + x11_surface: *gdk_x11.X11Surface, + + blur_region: Region = .{}, + + pub fn init( + alloc: Allocator, + app: *App, + apprt_window: *ApprtWindow, + ) !Window { + _ = alloc; + + const surface = apprt_window.window.as( + gtk.Native, + ).getSurface() orelse return error.NotX11Surface; + + const x11_surface = gobject.ext.cast( + gdk_x11.X11Surface, + surface, + ) orelse return error.NotX11Surface; + + return .{ + .app = app, + .config = &apprt_window.config, + .gtk_window = apprt_window.window, + .x11_surface = x11_surface, + }; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn resizeEvent(self: *Window) !void { + // The blur region must update with window resizes + try self.syncBlur(); + } + + pub fn syncAppearance(self: *Window) !void { + // The user could have toggled between CSDs and SSDs, + // therefore we need to recalculate the blur region offset. + self.blur_region = blur: { + // NOTE(pluiedev): CSDs are a f--king mistake. + // Please, GNOME, stop this nonsense of making a window ~30% bigger + // internally than how they really are just for your shadows and + // rounded corners and all that fluff. Please. I beg of you. + var x: f64 = 0; + var y: f64 = 0; + + self.gtk_window.as(gtk.Native).getSurfaceTransform(&x, &y); + + // Transform surface coordinates to device coordinates. + const scale: f64 = @floatFromInt(self.gtk_window.as(gtk.Widget).getScaleFactor()); + x *= scale; + y *= scale; + + break :blur .{ + .x = @intFromFloat(x), + .y = @intFromFloat(y), + }; + }; + self.syncBlur() catch |err| { + log.err("failed to synchronize blur={}", .{err}); + }; + self.syncDecorations() catch |err| { + log.err("failed to synchronize decorations={}", .{err}); + }; + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self.config.window_decoration) { + .auto, .client => true, + .server, .none => false, + }; + } + + fn syncBlur(self: *Window) !void { + // FIXME: This doesn't currently factor in rounded corners on Adwaita, + // which means that the blur region will grow slightly outside of the + // window borders. Unfortunately, actually calculating the rounded + // region can be quite complex without having access to existing APIs + // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) + // and I think it's not really noticeable enough to justify the effort. + // (Wayland also has this visual artifact anyway...) + + const gtk_widget = self.gtk_window.as(gtk.Widget); + + // Transform surface coordinates to device coordinates. + const scale = self.gtk_window.as(gtk.Widget).getScaleFactor(); + self.blur_region.width = gtk_widget.getWidth() * scale; + self.blur_region.height = gtk_widget.getHeight() * scale; + + const blur = self.config.background_blur; + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.x11_surface.getXid(), + self.blur_region, + }); + + if (blur.enabled()) { + try self.changeProperty( + Region, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + &self.blur_region, + ); + } else { + try self.deleteProperty(self.app.atoms.kde_blur); + } + } + + fn syncDecorations(self: *Window) !void { + var hints: MotifWMHints = .{}; + + self.getWindowProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{}, + &hints, + ) catch |err| switch (err) { + // motif_wm_hints is already initialized, so this is fine + error.PropertyNotFound => {}, + + error.RequestFailed, + error.PropertyTypeMismatch, + error.PropertyFormatMismatch, + => return err, + }; + + hints.flags.decorations = true; + hints.decorations.all = switch (self.config.window_decoration) { + .server => true, + .auto, .client, .none => false, + }; + + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + } + + pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + var buf: [64]u8 = undefined; + const window_id = try std.fmt.bufPrint( + &buf, + "{}", + .{self.x11_surface.getXid()}, + ); + + try env.put("WINDOWID", window_id); + } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + self.x11_surface.setUrgencyHint(@intFromBool(urgent)); + } + + fn getWindowProperty( + self: *Window, + comptime T: type, + name: c.Atom, + typ: c.Atom, + comptime format: PropertyFormat, + options: struct { + offset: c_long = 0, + length: c_long = std.math.maxInt(c_long), + delete: bool = false, + }, + result: *T, + ) GetWindowPropertyError!void { + // FIXME: Maybe we should switch to libxcb one day. + // Sounds like a much better idea than whatever this is + var actual_type_return: c.Atom = undefined; + var actual_format_return: c_int = undefined; + var nitems_return: c_ulong = undefined; + var bytes_after_return: c_ulong = undefined; + var prop_return: ?format.bufferType() = null; + + const code = c.XGetWindowProperty( + @ptrCast(@alignCast(self.app.display)), + self.x11_surface.getXid(), + name, + options.offset, + options.length, + @intFromBool(options.delete), + typ, + &actual_type_return, + &actual_format_return, + &nitems_return, + &bytes_after_return, + @ptrCast(&prop_return), + ); + if (code != c.Success) return error.RequestFailed; + + if (actual_type_return == c.None) return error.PropertyNotFound; + if (typ != actual_type_return) return error.PropertyTypeMismatch; + if (@intFromEnum(format) != actual_format_return) return error.PropertyFormatMismatch; + + const data_ptr: *T = @ptrCast(prop_return); + result.* = data_ptr.*; + _ = c.XFree(prop_return); + } + + fn changeProperty( + self: *Window, + comptime T: type, + name: c.Atom, + typ: c.Atom, + comptime format: PropertyFormat, + options: struct { + mode: PropertyChangeMode, + }, + value: *T, + ) X11Error!void { + const data: format.bufferType() = @ptrCast(value); + + const status = c.XChangeProperty( + @ptrCast(@alignCast(self.app.display)), + self.x11_surface.getXid(), + name, + typ, + @intFromEnum(format), + @intFromEnum(options.mode), + data, + @divExact(@sizeOf(T), @sizeOf(format.elemType())), + ); + + // For some godforsaken reason Xlib alternates between + // error values (0 = success) and booleans (1 = success), and they look exactly + // the same in the signature (just `int`, since Xlib is written in C89)... + if (status == 0) return error.RequestFailed; + } + + fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { + const status = c.XDeleteProperty( + @ptrCast(@alignCast(self.app.display)), + self.x11_surface.getXid(), + name, + ); + if (status == 0) return error.RequestFailed; + } +}; + +const X11Error = error{ + RequestFailed, +}; + +const GetWindowPropertyError = X11Error || error{ + PropertyNotFound, + PropertyTypeMismatch, + PropertyFormatMismatch, +}; + +const Atoms = struct { + kde_blur: c.Atom, + motif_wm_hints: c.Atom, + + fn init(display: *gdk_x11.X11Display) Atoms { + return .{ + .kde_blur = gdk_x11.x11GetXatomByNameForDisplay( + display, + "_KDE_NET_WM_BLUR_BEHIND_REGION", + ), + .motif_wm_hints = gdk_x11.x11GetXatomByNameForDisplay( + display, + "_MOTIF_WM_HINTS", + ), + }; + } +}; + +const PropertyChangeMode = enum(c_int) { + replace = c.PropModeReplace, + prepend = c.PropModePrepend, + append = c.PropModeAppend, +}; + +const PropertyFormat = enum(c_int) { + _8 = 8, + _16 = 16, + _32 = 32, + + fn elemType(comptime self: PropertyFormat) type { + return switch (self) { + ._8 => c_char, + ._16 => c_int, + ._32 => c_long, + }; + } + + fn bufferType(comptime self: PropertyFormat) type { + // The buffer type has to be a multi-pointer to bytes + // *aligned to the element type* (very important, + // otherwise you'll read garbage!) + // + // I know this is really ugly. X11 is ugly. I consider it apropos. + return [*]align(@alignOf(self.elemType())) u8; + } +}; + +const Region = extern struct { + x: c_long = 0, + y: c_long = 0, + width: c_long = 0, + height: c_long = 0, +}; + +// See Xm/MwmUtil.h, packaged with the Motif Window Manager +const MotifWMHints = extern struct { + flags: packed struct(c_ulong) { + _pad: u1 = 0, + decorations: bool = false, + + // We don't really care about the other flags + _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 2) = 0, + } = .{}, + functions: c_ulong = 0, + decorations: packed struct(c_ulong) { + all: bool = false, + + // We don't really care about the other flags + _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 1) = 0, + } = .{}, + input_mode: c_long = 0, + status: c_ulong = 0, +}; From c23adeef386c186e6e31b89e8782af6f1773530a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 13:59:53 -0700 Subject: [PATCH 2/7] apprt/gtk-ng: surface input --- src/apprt/gtk-ng/Surface.zig | 13 + src/apprt/gtk-ng/class/surface.zig | 427 ++++++++++++++++++++++++++-- src/apprt/gtk-ng/ui/1.2/surface.blp | 9 +- 3 files changed, 417 insertions(+), 32 deletions(-) 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; } } } From 6f0189790741deca6c7942613e7b61db23c00afa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 14:26:14 -0700 Subject: [PATCH 3/7] apprt/gtk-ng: mouse click --- src/apprt/gtk-ng/class/surface.zig | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index dd26ed31a..f893c87a4 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -13,6 +13,7 @@ const renderer = @import("../../../renderer.zig"); const CoreSurface = @import("../../../Surface.zig"); const gresource = @import("../build/gresource.zig"); const adw_version = @import("../adw_version.zig"); +const gtk_key = @import("../key.zig"); const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; @@ -323,6 +324,27 @@ pub const Surface = extern struct { .{}, ); + // Clicks + const gesture_click = gtk.GestureClick.new(); + errdefer gesture_click.unref(); + gesture_click.as(gtk.GestureSingle).setButton(0); + self_widget.addController(gesture_click.as(gtk.EventController)); + errdefer self_widget.removeController(gesture_click.as(gtk.EventController)); + _ = gtk.GestureClick.signals.pressed.connect( + gesture_click, + *Self, + gcMouseDown, + self, + .{}, + ); + _ = gtk.GestureClick.signals.released.connect( + gesture_click, + *Self, + gcMouseUp, + self, + .{}, + ); + // Setup our input method state const im_context = gtk.IMMulticontext.new(); priv.im_context = im_context; @@ -507,6 +529,68 @@ pub const Surface = extern struct { } } + fn gcMouseDown( + gesture: *gtk.GestureClick, + _: c_int, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + + // If we don't have focus, grab it. + const priv = self.private(); + const gl_area_widget = priv.gl_area.as(gtk.Widget); + if (gl_area_widget.hasFocus() == 0) { + _ = gl_area_widget.grabFocus(); + } + + // Report the event + const consumed = if (priv.core_surface) |surface| consumed: { + const gtk_mods = event.getModifierState(); + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + const mods = gtk_key.translateMods(gtk_mods); + break :consumed surface.mouseButtonCallback( + .press, + button, + mods, + ) catch |err| err: { + log.err("error in key callback err={}", .{err}); + break :err false; + }; + } else false; + + // TODO: context menu + _ = consumed; + _ = x; + _ = y; + } + + fn gcMouseUp( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *Self, + ) callconv(.c) void { + const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + + const priv = self.private(); + if (priv.core_surface) |surface| { + const gtk_mods = event.getModifierState(); + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + const mods = gtk_key.translateMods(gtk_mods); + _ = surface.mouseButtonCallback( + .release, + button, + mods, + ) catch |err| { + log.err("error in key callback err={}", .{err}); + return; + }; + } + } + fn imPreeditStart( _: *gtk.IMMulticontext, self: *Self, @@ -894,3 +978,20 @@ pub const IMKeyEvent = enum { composing, not_composing, }; + +fn translateMouseButton(button: c_uint) input.MouseButton { + return switch (button) { + 1 => .left, + 2 => .middle, + 3 => .right, + 4 => .four, + 5 => .five, + 6 => .six, + 7 => .seven, + 8 => .eight, + 9 => .nine, + 10 => .ten, + 11 => .eleven, + else => .unknown, + }; +} From 9659b484b5cf37774eaf4dd82793cbf668b47dfc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 14:33:53 -0700 Subject: [PATCH 4/7] apprt/gtk-ng: cursor position --- src/apprt/gtk-ng/Surface.zig | 3 +- src/apprt/gtk-ng/class/surface.zig | 121 ++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index 820724d38..71e57a1bc 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -49,8 +49,7 @@ pub fn getSize(self: *const Self) !apprt.SurfaceSize { } pub fn getCursorPos(self: *const Self) !apprt.CursorPos { - _ = self; - return .{ .x = 0, .y = 0 }; + return self.surface.getCursorPos(); } pub fn supportsClipboard( diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index f893c87a4..d690cab66 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -79,6 +79,7 @@ pub const Surface = extern struct { /// Cached metrics for libghostty callbacks size: apprt.SurfaceSize, + cursor_pos: apprt.CursorPos, /// Various input method state. All related to key input. in_keyevent: IMKeyEvent = .false, @@ -161,6 +162,23 @@ pub const Surface = extern struct { return false; } + /// Scale x/y by the GDK device scale. + fn scaledCoordinates( + self: *Self, + x: f64, + y: f64, + ) struct { x: f64, y: f64 } { + const gl_area = self.private().gl_area; + const scale_factor: f64 = @floatFromInt( + gl_area.as(gtk.Widget).getScaleFactor(), + ); + + return .{ + .x = x * scale_factor, + .y = y * scale_factor, + }; + } + //--------------------------------------------------------------- // Libghostty Callbacks @@ -216,6 +234,10 @@ pub const Surface = extern struct { return self.private().size; } + pub fn getCursorPos(self: *Self) apprt.CursorPos { + return self.private().cursor_pos; + } + pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { _ = self; @@ -268,6 +290,7 @@ pub const Surface = extern struct { // Initialize some private fields so they aren't undefined priv.rt_surface = .{ .surface = self }; + priv.cursor_pos = .{ .x = 0, .y = 0 }; priv.size = .{ // Funky numbers on purpose so they stand out if for some reason // our size doesn't get properly set. @@ -345,6 +368,26 @@ pub const Surface = extern struct { .{}, ); + // Mouse movement + const ec_motion = gtk.EventControllerMotion.new(); + errdefer ec_motion.unref(); + self_widget.addController(ec_motion.as(gtk.EventController)); + errdefer self_widget.removeController(ec_motion.as(gtk.EventController)); + _ = gtk.EventControllerMotion.signals.motion.connect( + ec_motion, + *Self, + ecMouseMotion, + self, + .{}, + ); + _ = gtk.EventControllerMotion.signals.leave.connect( + ec_motion, + *Self, + ecMouseLeave, + self, + .{}, + ); + // Setup our input method state const im_context = gtk.IMMulticontext.new(); priv.im_context = im_context; @@ -555,7 +598,7 @@ pub const Surface = extern struct { button, mods, ) catch |err| err: { - log.err("error in key callback err={}", .{err}); + log.warn("error in key callback err={}", .{err}); break :err false; }; } else false; @@ -585,7 +628,81 @@ pub const Surface = extern struct { button, mods, ) catch |err| { - log.err("error in key callback err={}", .{err}); + log.warn("error in key callback err={}", .{err}); + return; + }; + } + } + + fn ecMouseMotion( + ec: *gtk.EventControllerMotion, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const event = ec.as(gtk.EventController).getCurrentEvent() orelse return; + const priv = self.private(); + + const scaled = self.scaledCoordinates(x, y); + const pos: apprt.CursorPos = .{ + .x = @floatCast(scaled.x), + .y = @floatCast(scaled.y), + }; + + // There seem to be at least two cases where GTK issues a mouse motion + // event without the cursor actually moving: + // 1. GLArea is resized under the mouse. This has the unfortunate + // side effect of causing focus to potentially change when + // `focus-follows-mouse` is enabled. + // 2. The window title is updated. This can cause the mouse to unhide + // incorrectly when hide-mouse-when-typing is enabled. + // To prevent incorrect behavior, we'll only grab focus and + // continue with callback logic if the cursor has actually moved. + const is_cursor_still = @abs(priv.cursor_pos.x - pos.x) < 1 and + @abs(priv.cursor_pos.y - pos.y) < 1; + if (is_cursor_still) return; + + // If we don't have focus, and we want it, grab it. + if (priv.config) |config| { + const gl_area_widget = priv.gl_area.as(gtk.Widget); + if (gl_area_widget.hasFocus() == 0 and + config.get().@"focus-follows-mouse") + { + _ = gl_area_widget.grabFocus(); + } + } + + // Our pos changed, update + priv.cursor_pos = pos; + + // Notify the callback + if (priv.core_surface) |surface| { + const gtk_mods = event.getModifierState(); + const mods = gtk_key.translateMods(gtk_mods); + surface.cursorPosCallback(priv.cursor_pos, mods) catch |err| { + log.warn("error in cursor pos callback err={}", .{err}); + }; + } + } + + fn ecMouseLeave( + ec_motion: *gtk.EventControllerMotion, + self: *Self, + ) callconv(.c) void { + const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return; + + // Get our modifiers + const priv = self.private(); + if (priv.core_surface) |surface| { + // If we have a core surface then we can send the cursor pos + // callback with an invalid position to indicate the mouse left. + const gtk_mods = event.getModifierState(); + const mods = gtk_key.translateMods(gtk_mods); + surface.cursorPosCallback( + .{ .x = -1, .y = -1 }, + mods, + ) catch |err| { + log.warn("error in cursor pos callback err={}", .{err}); return; }; } From c2ddb6eca6edbfc114c1ced0427a6297614a3453 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 14:38:05 -0700 Subject: [PATCH 5/7] apprt/gtk-ng: scroll --- src/apprt/gtk-ng/class/surface.zig | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index d690cab66..54fcd0515 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -88,6 +88,9 @@ pub const Surface = extern struct { im_buf: [128]u8 = undefined, im_len: u7 = 0, + /// True when we have a precision scroll in progress + precision_scroll: bool = false, + pub var offset: c_int = 0; }; @@ -290,6 +293,7 @@ pub const Surface = extern struct { // Initialize some private fields so they aren't undefined priv.rt_surface = .{ .surface = self }; + priv.precision_scroll = false; priv.cursor_pos = .{ .x = 0, .y = 0 }; priv.size = .{ // Funky numbers on purpose so they stand out if for some reason @@ -388,6 +392,33 @@ pub const Surface = extern struct { .{}, ); + // Scroll + const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes); + errdefer ec_scroll.unref(); + self_widget.addController(ec_scroll.as(gtk.EventController)); + errdefer self_widget.removeController(ec_scroll.as(gtk.EventController)); + _ = gtk.EventControllerScroll.signals.scroll.connect( + ec_scroll, + *Self, + ecMouseScroll, + self, + .{}, + ); + _ = gtk.EventControllerScroll.signals.scroll_begin.connect( + ec_scroll, + *Self, + ecMouseScrollPrecisionBegin, + self, + .{}, + ); + _ = gtk.EventControllerScroll.signals.scroll_end.connect( + ec_scroll, + *Self, + ecMouseScrollPrecisionEnd, + self, + .{}, + ); + // Setup our input method state const im_context = gtk.IMMulticontext.new(); priv.im_context = im_context; @@ -708,6 +739,55 @@ pub const Surface = extern struct { } } + fn ecMouseScrollPrecisionBegin( + _: *gtk.EventControllerScroll, + self: *Self, + ) callconv(.c) void { + self.private().precision_scroll = true; + } + + fn ecMouseScrollPrecisionEnd( + _: *gtk.EventControllerScroll, + self: *Self, + ) callconv(.c) void { + self.private().precision_scroll = false; + } + + fn ecMouseScroll( + _: *gtk.EventControllerScroll, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) c_int { + const priv = self.private(); + if (priv.core_surface) |surface| { + // Multiply precision scrolls by 10 to get a better response from + // touchpad scrolling + const multiplier: f64 = if (priv.precision_scroll) 10.0 else 1.0; + const scroll_mods: input.ScrollMods = .{ + .precision = priv.precision_scroll, + }; + + const scaled = self.scaledCoordinates(x, y); + surface.scrollCallback( + // We invert because we apply natural scrolling to the values. + // This behavior has existed for years without Linux users complaining + // but I suspect we'll have to make this configurable in the future + // or read a system setting. + scaled.x * -1 * multiplier, + scaled.y * -1 * multiplier, + scroll_mods, + ) catch |err| { + log.warn("error in scroll callback err={}", .{err}); + return 0; + }; + + return 1; + } + + return 0; + } + fn imPreeditStart( _: *gtk.IMMulticontext, self: *Self, From 5ef36b39c4487849197fef647bd857b675a5e31a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 15:09:12 -0700 Subject: [PATCH 6/7] apprt/gtk-ng: port keyEvent --- src/apprt/gtk-ng/class/application.zig | 17 ++- src/apprt/gtk-ng/class/surface.zig | 200 ++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ee3b8bd06..21557d6e8 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -221,20 +221,20 @@ pub const Application = extern struct { }; // Setup our windowing protocol logic - var winproto: winprotopkg.App = winprotopkg.App.init( + var wp: winprotopkg.App = winprotopkg.App.init( alloc, display, app_id, &config, - ) catch |err| winproto: { + ) catch |err| wp: { // If we fail to detect or setup the windowing protocol // specifies, we fallback to a noop implementation so we can // still launch. log.warn("error initializing windowing protocol err={}", .{err}); - break :winproto .{ .none = .{} }; + break :wp .{ .none = .{} }; }; - errdefer winproto.deinit(alloc); - log.debug("windowing protocol={s}", .{@tagName(winproto)}); + errdefer wp.deinit(alloc); + log.debug("windowing protocol={s}", .{@tagName(wp)}); // Create our GTK Application which encapsulates our process. log.debug("creating GTK application id={s} single-instance={}", .{ @@ -265,7 +265,7 @@ pub const Application = extern struct { .rt_app = rt_app, .core_app = core_app, .config = config_obj, - .winproto = winproto, + .winproto = wp, }; return self; @@ -520,6 +520,11 @@ pub const Application = extern struct { return self.private().rt_app; } + /// Returns the app winproto implementation. + pub fn winproto(self: *Self) *winprotopkg.App { + return &self.private().winproto; + } + //--------------------------------------------------------------- // Libghostty Callbacks diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 54fcd0515..f37e0b583 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -156,12 +156,202 @@ pub const Surface = extern struct { gtk_mods: gdk.ModifierType, ) bool { log.warn("keyEvent action={}", .{action}); + const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; + const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; + const priv = self.private(); + + // The block below is all related to input method handling. See the function + // comment for some high level details and then the comments within + // the block for more specifics. + if (priv.im_context) |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 + // place. + if (priv.core_surface) |surface| { + const ime_point = surface.imePoint(); + im_context.as(gtk.IMContext).setCursorLocation(&.{ + .f_x = @intFromFloat(ime_point.x), + .f_y = @intFromFloat(ime_point.y), + .f_width = 1, + .f_height = 1, + }); + } + + // 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. + // + // We have to maintain some additional state here of whether we + // were composing because different input methods call the callbacks + // in different orders. For example, ibus calls commit THEN preedit + // end but simple calls preedit end THEN commit. + priv.in_keyevent = if (priv.im_composing) .composing else .not_composing; + defer priv.in_keyevent = .false; + + // Pass the event through the input method which returns true if handled. + // Confusingly, not all events handled by the input method result + // in this returning true so we have to maintain some additional + // state about whether we were composing or not to determine if + // we should proceed with key encoding. + // + // Cases where the input method does not mark the event as handled: + // + // - If we change the input method via keypress while we have preedit + // text, the input method will commit the pending text but will not + // mark it as handled. We use the `.composing` state to detect + // this case. + // + // - If we switch input methods (i.e. via ctrl+shift with fcitx), + // the input method will handle the key release event but will not + // mark it as handled. I don't know any way to detect this case so + // it will result in a key event being sent to the key callback. + // For Kitty text encoding, this will result in modifiers being + // 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 im_handled = im_context.as(gtk.IMContext).filterKeypress(event) != 0; + // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ + // im_handled, + // self.im_len, + // self.im_composing, + // }); + + // If the input method handled the event, you would think we would + // never proceed with key encoding for Ghostty but that is not the + // case. Input methods will handle basic character encoding like + // typing "a" and we want to associate that with the key event. + // So we have to check additional state to determine if we exit. + if (im_handled) { + // If we are composing then we're in a preedit state and do + // not want to encode any keys. For example: type a deadkey + // such as single quote on a US international keyboard layout. + if (priv.im_composing) return true; + + // If we were composing and now we're not it means that we committed + // the text. We also don't want to encode a key event for this. + // Example: enable Japanese input method, press "konn" and then + // press enter. The final enter should not be encoded and "konn" + // (in hiragana) should be written as "こん". + if (priv.in_keyevent == .composing) return true; + + // Not composing and our input method buffer is empty. This could + // mean that the input method reacted to this event by activating + // an onscreen keyboard or something equivalent. We don't know. + // But the input method handled it and didn't give us text so + // we will just assume we should not encode this. This handles a + // real scenario when ibus starts the emoji input method + // (super+.). + if (priv.im_len == 0) return true; + } + + // At this point, for the sake of explanation of internal state: + // it is possible that im_len > 0 and im_composing == false. This + // means that we received a commit event from the input method that + // 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 priv.im_len = 0; + + // Get the keyvals for this event. + const keyval_unicode = gdk.keyvalToUnicode(keyval); + const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( + priv.gl_area.as(gtk.Widget), + key_event, + keycode, + ); + + // 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 .unidentified; + + // Get our modifier for the event + const mods: input.Mods = gtk_key.eventMods( + event, + physical_key, + gtk_mods, + action, + Application.default().winproto(), + ); + + // Get our consumed modifiers + const consumed_mods: input.Mods = consumed: { + const T = @typeInfo(gdk.ModifierType); + std.debug.assert(T.@"struct".layout == .@"packed"); + const I = T.@"struct".backing_integer.?; + + const masked = @as(I, @bitCast(key_event.getConsumedModifiers())) & @as(I, gdk.MODIFIER_MASK); + break :consumed gtk_key.translateMods(@bitCast(masked)); + }; + + // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ + // key, + // keyval, + // physical_key, + // priv.im_composing, + // priv.im_len, + // mods, + // }); + + // If we have no UTF-8 text, we try to convert our keyval to + // a text value. We have to do this because GTK will not process + // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "". + // But the keyval is set correctly so we can at least extract that. + if (priv.im_len == 0 and keyval_unicode > 0) im: { + if (std.math.cast(u21, keyval_unicode)) |cp| { + // We don't want to send control characters as IM + // text. Control characters are handled already by + // the encoder directly. + if (cp < 0x20) break :im; + + if (std.unicode.utf8Encode(cp, &priv.im_buf)) |len| { + priv.im_len = len; + } else |_| {} + } + } + + // Invoke the core Ghostty logic to handle this input. + const surface = priv.core_surface orelse return false; + const effect = surface.keyCallback(.{ + .action = action, + .key = physical_key, + .mods = mods, + .consumed_mods = consumed_mods, + .composing = priv.im_composing, + .utf8 = priv.im_buf[0..priv.im_len], + .unshifted_codepoint = keyval_unicode_unshifted, + }) catch |err| { + log.err("error in key callback err={}", .{err}); + return false; + }; + + switch (effect) { + .closed => return true, + .ignored => {}, + .consumed => if (action == .press or action == .repeat) { + // If we were in the composing state then we reset our context. + // We do NOT want to reset if we're not in the composing state + // because there is other IME state that we want to preserve, + // such as quotation mark ordering for Chinese input. + if (priv.im_composing) { + if (priv.im_context) |im_context| { + im_context.as(gtk.IMContext).reset(); + } + + surface.preeditCallback(null) catch {}; + } + + return true; + }, + } - _ = self; - _ = ec_key; - _ = keyval; - _ = keycode; - _ = gtk_mods; return false; } From 238015c171879b3c6a5f09ae0e76d60c3431d7e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 18 Jul 2025 15:17:39 -0700 Subject: [PATCH 7/7] termio: simplify logging to remove undefined access --- src/termio/Thread.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 7773ea7cd..edf966df7 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -310,7 +310,7 @@ fn drainMailbox( // If we have a message we always redraw redraw = true; - log.debug("mailbox message={}", .{message}); + log.debug("mailbox message={s}", .{@tagName(message)}); switch (message) { .crash => @panic("crash request, crashing intentionally"), .change_config => |config| {