From 83063e55275e0b0757f338141ba69624b78fa708 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 3 Aug 2025 13:38:07 -0500 Subject: [PATCH 01/83] gtk-ng: prevent split button from becoming focused --- src/apprt/gtk-ng/ui/1.5/window.blp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index e13fb44f7..c70a8b350 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -50,6 +50,8 @@ template $GhosttyWindow: Adw.ApplicationWindow { tooltip-text: _("New Tab"); dropdown-tooltip: _("New Split"); menu-model: split_menu; + can-focus: false; + focus-on-click: false; } [end] From c8fce8850b81e77c453ac27905aab9439d698030 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Aug 2025 08:43:21 -0700 Subject: [PATCH 02/83] apprt/gtk-ng: global shortcuts --- src/apprt/gtk-ng/class/application.zig | 48 ++ src/apprt/gtk-ng/class/global_shortcuts.zig | 623 ++++++++++++++++++++ src/apprt/gtk/GlobalShortcuts.zig | 1 + src/input/Binding.zig | 11 + 4 files changed, 683 insertions(+) create mode 100644 src/apprt/gtk-ng/class/global_shortcuts.zig diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index a8a9015b3..4f4cc77d4 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -19,6 +19,7 @@ const internal_os = @import("../../../os/main.zig"); const systemd = @import("../../../os/systemd.zig"); const terminal = @import("../../../terminal/main.zig"); const xev = @import("../../../global.zig").xev; +const Binding = @import("../../../input.zig").Binding; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); @@ -34,6 +35,7 @@ const Surface = @import("surface.zig").Surface; const Window = @import("window.zig").Window; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; +const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; const log = std.log.scoped(.gtk_ghostty_application); @@ -105,6 +107,9 @@ pub const Application = extern struct { /// State and logic for the underlying windowing protocol. winproto: winprotopkg.App, + /// The global shortcut logic. + global_shortcuts: *GlobalShortcuts, + /// 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. @@ -305,6 +310,7 @@ pub const Application = extern struct { .winproto = wp, .css_provider = css_provider, .custom_css_providers = .empty, + .global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}), }; // Signals @@ -332,6 +338,7 @@ pub const Application = extern struct { const priv = self.private(); priv.config.unref(); priv.winproto.deinit(alloc); + priv.global_shortcuts.unref(); if (priv.transient_cgroup_base) |base| alloc.free(base); if (gdk.Display.getDefault()) |display| { gtk.StyleContext.removeProviderForDisplay( @@ -935,6 +942,9 @@ pub const Application = extern struct { // Setup our action map self.startupActionMap(); + // Setup our global shortcuts + self.startupGlobalShortcuts(); + // Setup our cgroup for the application. self.startupCgroup() catch |err| { log.warn("cgroup initialization failed err={}", .{err}); @@ -1073,6 +1083,34 @@ pub const Application = extern struct { } } + /// Setup our global shortcuts. + fn startupGlobalShortcuts(self: *Self) void { + const priv = self.private(); + + // On startup, our dbus connection should be available. + priv.global_shortcuts.setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + + // Setup a binding so that the shortcut config always matches the app. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + priv.global_shortcuts.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Setup the signal handler for global shortcut triggers + _ = GlobalShortcuts.signals.trigger.connect( + priv.global_shortcuts, + *Application, + globalShortcutTrigger, + self, + .{}, + ); + } + const CgroupError = error{ DbusConnectionFailed, CgroupInitFailed, @@ -1303,6 +1341,16 @@ pub const Application = extern struct { dialog.present(null); } + fn globalShortcutTrigger( + _: *GlobalShortcuts, + action: *const Binding.Action, + self: *Self, + ) callconv(.c) void { + self.core().performAllAction(self.rt(), action.*) catch |err| { + log.warn("failed to perform action={}", .{err}); + }; + } + fn actionReloadConfig( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk-ng/class/global_shortcuts.zig new file mode 100644 index 000000000..b15a0d5a9 --- /dev/null +++ b/src/apprt/gtk-ng/class/global_shortcuts.zig @@ -0,0 +1,623 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const Binding = @import("../../../input.zig").Binding; +const gresource = @import("../build/gresource.zig"); +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_global_shortcuts); + +pub const GlobalShortcuts = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyGlobalShortcuts", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The configuration that this is using.", + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const @"dbus-connection" = struct { + pub const name = "dbus-connection"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gio.DBusConnection, + .{ + .nick = "Dbus Connection", + .blurb = "The dbus connection to use.", + .accessor = C.privateObjFieldAccessor("dbus_connection"), + }, + ); + }; + }; + + const Private = struct { + /// The configuration that this is using. + config: ?*Config = null, + + /// The dbus connection. + dbus_connection: ?*gio.DBusConnection = null, + + /// An arena allocator that is present for each refresh. + arena: ?std.heap.ArenaAllocator = null, + + /// A mapping from a unique ID to an action. + /// Currently the unique ID is simply the serialized representation of the + /// trigger that was used for the action as triggers are unique in the keymap, + /// but this may change in the future. + map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, + + /// The handle of the current global shortcuts portal session, + /// as a D-Bus object path. + handle: ?[:0]const u8 = null, + + /// The D-Bus signal subscription for the response signal on requests. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + response_subscription: c_uint = 0, + + /// The D-Bus signal subscription for the keybind activate signal. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + activate_subscription: c_uint = 0, + + pub var offset: c_int = 0; + }; + + pub const signals = struct { + /// Emitted whenever a global shortcut is triggered. + pub const trigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const Binding.Action}, + void, + ); + }; + }; + + fn init(self: *Self, _: *Class) callconv(.C) void { + _ = gobject.Object.signals.notify.connect( + self, + *Self, + propConfig, + self, + .{ .detail = "config" }, + ); + } + + fn close(self: *Self) void { + const priv = self.private(); + const dbus = priv.dbus_connection orelse return; + + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + if (priv.activate_subscription != 0) { + dbus.signalUnsubscribe(priv.activate_subscription); + priv.activate_subscription = 0; + } + + if (priv.handle) |handle| { + // Close existing session + dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + priv.handle = null; + } + + if (priv.arena) |*arena| { + arena.deinit(); + priv.arena = null; + priv.map = .{}; // Uses arena memory + } + } + + fn refresh(self: *Self) !void { + // Always close our previous state first. + self.close(); + + const priv = self.private(); + + // We need configuration to proceed. + const config = if (priv.config) |v| v.get() else return; + + // Setup our new arena that we'll use for memory allocations. + assert(priv.arena == null); + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Our map starts out empty again. We don't need to worry about + // memory because its part of the arena we clear. + priv.map = .{}; + errdefer priv.map = .{}; + + // Update map + var trigger_buf: [256]u8 = undefined; + var it = config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + const leaf = switch (entry.value_ptr.*) { + // Global shortcuts can't have leaders + .leader => continue, + .leaf => |leaf| leaf, + }; + if (!leaf.flags.global) continue; + + const trigger = try key.xdgShortcutFromTrigger( + &trigger_buf, + entry.key_ptr.*, + ) orelse continue; + + try priv.map.put( + alloc, + try alloc.dupeZ(u8, trigger), + leaf.action, + ); + } + + // Store our arena + priv.arena = arena; + + // Create our session if we have global shortcuts. + if (priv.map.count() > 0) try self.request(.create_session); + } + + const Method = enum { + create_session, + bind_shortcuts, + + fn name(self: Method) [:0]const u8 { + return switch (self) { + .create_session => "CreateSession", + .bind_shortcuts => "BindShortcuts", + }; + } + + /// Construct the payload expected by the XDG portal call. + fn makePayload( + self: Method, + shortcuts: *GlobalShortcuts, + request_token: [:0]const u8, + ) ?*glib.Variant { + switch (self) { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + .create_session => { + var session_token: Token = undefined; + return glib.Variant.newParsed( + "({'handle_token': <%s>, 'session_handle_token': <%s>},)", + request_token.ptr, + generateToken(&session_token).ptr, + ); + }, + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + .bind_shortcuts => { + const priv = shortcuts.private(); + const handle = priv.handle orelse return null; + + const bind_type = glib.VariantType.new("a(sa{sv})"); + defer glib.free(bind_type); + + var binds: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&binds, bind_type); + + var action_buf: [256]u8 = undefined; + + var it = priv.map.iterator(); + while (it.next()) |entry| { + const trigger = entry.key_ptr.*.ptr; + const action = std.fmt.bufPrintZ( + &action_buf, + "{}", + .{entry.value_ptr.*}, + ) catch continue; + + binds.addParsed( + "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", + trigger, + action.ptr, + trigger, + ); + } + + return glib.Variant.newParsed( + "(%o, %*, '', {'handle_token': <%s>})", + handle.ptr, + binds.end(), + request_token.ptr, + ); + }, + } + } + + fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { + switch (self) { + .create_session => { + var handle: ?[*:0]u8 = null; + if (vardict.lookup("session_handle", "&s", &handle) == 0) { + log.warn( + "session handle not found in response={s}", + .{vardict.print(@intFromBool(true))}, + ); + return; + } + + const priv = shortcuts.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + priv.handle = alloc.dupeZ(u8, std.mem.span(handle.?)) catch { + log.warn("out of memory: failed to clone session handle", .{}); + return; + }; + log.debug("session_handle={?s}", .{handle}); + + // Subscribe to keybind activations + assert(priv.activate_subscription == 0); + priv.activate_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.GlobalShortcuts", + "Activated", + "/org/freedesktop/portal/desktop", + handle, + .{ .match_arg0_path = true }, + shortcutActivated, + shortcuts, + null, + ); + + shortcuts.request(.bind_shortcuts) catch |err| { + log.warn("failed to bind shortcuts={}", .{err}); + return; + }; + }, + .bind_shortcuts => {}, + } + } + }; + + /// Submit a request to the global shortcuts portal. + fn request( + self: *Self, + comptime method: Method, + ) Allocator.Error!void { + // NOTE(pluiedev): + // XDG Portals are really, really poorly-designed pieces of hot garbage. + // How the protocol is _initially_ designed to work is as follows: + // + // 1. The client calls a method which returns the path of a Request object; + // 2. The client waits for the Response signal under said object path; + // 3. When the signal arrives, the actual return value and status code + // become available for the client for further processing. + // + // THIS DOES NOT WORK. Once the first two steps are complete, the client + // needs to immediately start listening for the third step, but an overeager + // server implementation could easily send the Response signal before the + // client is even ready, causing communications to break down over a simple + // race condition/two generals' problem that even _TCP_ had figured out + // decades ago. Worse yet, you get exactly _one_ chance to listen for the + // signal, or else your communication attempt so far has all been in vain. + // + // And they know this. Instead of fixing their freaking protocol, they just + // ask clients to manually construct the expected object path and subscribe + // to the request signal beforehand, making the whole response value of + // the original call COMPLETELY MEANINGLESS. + // + // Furthermore, this is _entirely undocumented_ aside from one tiny + // paragraph under the documentation for the Request interface, and + // anyone would be forgiven for missing it without reading the libportal + // source code. + // + // When in Rome, do as the Romans do, I guess...? + + const callbacks = struct { + fn gotResponseHandle( + source: ?*gobject.Object, + res: *gio.AsyncResult, + _: ?*anyopaque, + ) callconv(.c) void { + const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; + + var err: ?*glib.Error = null; + defer if (err) |err_| err_.free(); + + const params_ = dbus_.callFinish(res, &err) orelse { + if (err) |err_| log.warn("request failed={s} ({})", .{ + err_.f_message orelse "(unknown)", + err_.f_code, + }); + return; + }; + defer params_.unref(); + + // TODO: XDG recommends updating the signal subscription if the actual + // returned request path is not the same as the expected request + // path, to retain compatibility with older versions of XDG portals. + // Although it suffers from the race condition outlined above, + // we should still implement this at some point. + } + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response + fn responded( + dbus: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params_: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self_cb: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + const priv = self_cb.private(); + + // Unsubscribe from the response signal + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + var response: u32 = 0; + var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); + params_.get("(u@a{sv})", &response, &vardict); + + switch (response) { + 0 => { + log.debug("request successful", .{}); + method.onResponse(self_cb, vardict.?); + }, + 1 => log.debug("request was cancelled by user", .{}), + 2 => log.warn("request ended unexpectedly", .{}), + else => log.warn("unrecognized response code={}", .{response}), + } + } + }; + + var request_token_buf: Token = undefined; + const request_token = generateToken(&request_token_buf); + + const payload = method.makePayload(self, request_token) orelse return; + const request_path = try self.getRequestPath(request_token); + + const priv = self.private(); + const dbus = priv.dbus_connection.?; + + assert(priv.response_subscription == 0); + priv.response_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + callbacks.responded, + self, + null, + ); + + dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + payload, + null, + .{}, + -1, + null, + callbacks.gotResponseHandle, + null, + ); + } + + /// Get the XDG portal request path for the current Ghostty instance. + /// + /// If this sounds like nonsense, see `request` for an explanation as to + /// why we need to do this. + /// + /// Precondition: dbus connection exists, arena setup + fn getRequestPath(self: *Self, token: [:0]const u8) Allocator.Error![:0]const u8 { + const priv = self.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + // for the syntax XDG portals expect. + + // `getUniqueName` should never return null here as we're using an ordinary + // message bus connection. If it doesn't, something is very wrong + const unique_name = std.mem.span(dbus.getUniqueName().?); + + const object_path = try std.mem.joinZ( + alloc, + "/", + &.{ + "/org/freedesktop/portal/desktop/request", + unique_name[1..], // Remove leading `:` + token, + }, + ); + + // Sanitize the unique name by replacing every `.` with `_`. + // In effect, this will turn a unique name like `:1.192` into `1_192`. + // Valid D-Bus object path components never contain `.`s anyway, so we're + // free to replace all instances of `.` here and avoid extra allocation. + std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; + } + + //--------------------------------------------------------------- + // Property Handlers + + pub fn setDbusConnection( + self: *Self, + dbus_connection: ?*gio.DBusConnection, + ) void { + const priv = self.private(); + + // If we have a prior dbus connection we need to close our prior + // registrations first. + if (priv.dbus_connection) |v| { + self.close(); + v.unref(); + priv.dbus_connection = null; + } + + priv.dbus_connection = null; + if (dbus_connection) |v| { + v.ref(); // Weird this doesn't return self + priv.dbus_connection = v; + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + self.as(gobject.Object).notifyByPspec(properties.@"dbus-connection".impl.param_spec); + } + + fn propConfig( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn shortcutActivated( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + + // 2nd value in the tuple is the activated shortcut ID + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated + var shortcut_id: [*:0]const u8 = undefined; + params.getChild(1, "&s", &shortcut_id); + log.debug("activated={s}", .{shortcut_id}); + + const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return; + signals.trigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.C) void { + // Since we drop references here we may lose access to things like + // dbus connections, so we need to close all our connections right + // away instead of in finalize. + self.close(); + + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + if (priv.dbus_connection) |v| { + v.unref(); + priv.dbus_connection = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.C) void { + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.@"dbus-connection".impl, + }); + + // Signals + signals.trigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; + +const Token = [16]u8; + +/// Generate a random token suitable for use in requests. +fn generateToken(buf: *Token) [:0]const u8 { + // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL + // 7 + 8 + 1 = 16 + return std.fmt.bufPrintZ( + buf, + "ghostty_{x:0<7}", + .{std.crypto.random.int(u28)}, + ) catch unreachable; +} diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig index ac9dbaa8a..2506bef97 100644 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -335,6 +335,7 @@ fn request( var response: u32 = 0; var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); params_.get("(u@a{sv})", &response, &vardict); switch (response) { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f76da360a..b20319810 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -5,6 +5,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); const ziglyph = @import("ziglyph"); const key = @import("key.zig"); const KeyEvent = key.KeyEvent; @@ -729,6 +730,16 @@ pub const Action = union(enum) { pub const Key = @typeInfo(Action).@"union".tag_type.?; + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + Action, + .{ .name = "GhosttyBindingAction" }, + ), + + .none => void, + }; + pub const CrashThread = enum { main, io, From ee6d9b3116c3451da72c6746f85f8a136990f268 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Aug 2025 11:20:38 -0700 Subject: [PATCH 03/83] apprt/gtk-ng: surface context menu --- src/apprt/gtk-ng/class/surface.zig | 54 ++++++++++- src/apprt/gtk-ng/class/window.zig | 53 +++++++---- src/apprt/gtk-ng/ui/1.2/surface.blp | 136 +++++++++++++++++++++++++--- src/apprt/gtk/menu.zig | 4 - 4 files changed, 210 insertions(+), 37 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 3242337c2..ecddb6e79 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -327,6 +327,18 @@ pub const Surface = extern struct { ); }; + /// Emitted just prior to the context menu appearing. + pub const menu = struct { + pub const name = "menu"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + /// Emitted when the focus wants to be brought to the top and /// focused. pub const @"present-request" = struct { @@ -462,6 +474,7 @@ pub const Surface = extern struct { // Template binds child_exited_overlay: *ChildExited, + context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, progress_bar_overlay: *gtk.ProgressBar, @@ -1473,6 +1486,16 @@ pub const Surface = extern struct { self.close(.{ .surface = false }); } + fn contextMenuClosed( + _: *gtk.PopoverMenu, + self: *Self, + ) callconv(.c) void { + // When the context menu closes, it moves focus back to the tab + // bar if there are tabs. That's not correct. We need to grab it + // on the surface. + self.grabFocus(); + } + fn dtDrop( _: *gtk.DropTarget, value: *gobject.Value, @@ -1647,9 +1670,9 @@ pub const Surface = extern struct { } // Report the event + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); 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, @@ -1661,10 +1684,28 @@ pub const Surface = extern struct { }; } else false; - // TODO: context menu - _ = consumed; - _ = x; - _ = y; + // If a right click isn't consumed, mouseButtonCallback selects the hovered + // word and returns false. We can use this to handle the context menu + // opening under normal scenarios. + if (!consumed and button == .right) { + signals.menu.impl.emit( + self, + null, + .{}, + null, + ); + + const rect: gdk.Rectangle = .{ + .f_x = @intFromFloat(x), + .f_y = @intFromFloat(y), + .f_width = 1, + .f_height = 1, + }; + + const popover = priv.context_menu.as(gtk.Popover); + popover.setPointingTo(&rect); + popover.popup(); + } } fn gcMouseUp( @@ -2259,6 +2300,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("url_left", .{}); class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("child_exited_overlay", .{}); + class.bindTemplateChildPrivate("context_menu", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); class.bindTemplateChildPrivate("drop_target", .{}); @@ -2288,6 +2330,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter); class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave); class.bindTemplateCallback("child_exited_close", &childExitedClose); + class.bindTemplateCallback("context_menu_closed", &contextMenuClosed); class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); @@ -2315,6 +2358,7 @@ pub const Surface = extern struct { signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-write".impl.register(.{}); signals.init.impl.register(.{}); + signals.menu.impl.register(.{}); signals.@"present-request".impl.register(.{}); signals.@"toggle-fullscreen".impl.register(.{}); signals.@"toggle-maximize".impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 127aff1e1..009a815b5 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -590,6 +590,27 @@ pub const Window = extern struct { }; } + /// Sync the state of any actions on this window. + fn syncActions(self: *Self) void { + const has_selection = selection: { + const surface = self.getActiveSurface() orelse + break :selection false; + const core_surface = surface.core() orelse + break :selection false; + break :selection core_surface.hasSelection(); + }; + + const action_map: *gio.ActionMap = gobject.ext.cast( + gio.ActionMap, + self, + ) orelse return; + const action: *gio.SimpleAction = gobject.ext.cast( + gio.SimpleAction, + action_map.lookupAction("copy") orelse return, + ) orelse return; + action.setEnabled(@intFromBool(has_selection)); + } + fn toggleCssClass(self: *Self, class: [:0]const u8, value: bool) void { const widget = self.as(gtk.Widget); if (value) @@ -845,23 +866,7 @@ pub const Window = extern struct { const active = button.getActive() != 0; if (!active) return; - const has_selection = selection: { - const surface = self.getActiveSurface() orelse - break :selection false; - const core_surface = surface.core() orelse - break :selection false; - break :selection core_surface.hasSelection(); - }; - - const action_map: *gio.ActionMap = gobject.ext.cast( - gio.ActionMap, - self, - ) orelse return; - const action: *gio.SimpleAction = gobject.ext.cast( - gio.SimpleAction, - action_map.lookupAction("copy") orelse return, - ) orelse return; - action.setEnabled(@intFromBool(has_selection)); + self.syncActions(); } fn propQuickTerminal( @@ -1210,6 +1215,13 @@ pub const Window = extern struct { self, .{}, ); + _ = Surface.signals.menu.connect( + surface, + *Self, + surfaceMenu, + self, + .{}, + ); _ = Surface.signals.@"toggle-fullscreen".connect( surface, *Self, @@ -1355,6 +1367,13 @@ pub const Window = extern struct { } } + fn surfaceMenu( + _: *Surface, + self: *Self, + ) callconv(.c) void { + self.syncActions(); + } + fn surfacePresentRequest( surface: *Surface, self: *Self, diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 4cbbef097..ab34cadac 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -15,19 +15,32 @@ template $GhosttySurface: Adw.Bin { focusable: false; focus-on-click: false; - GLArea gl_area { - realize => $gl_realize(); - unrealize => $gl_unrealize(); - render => $gl_render(); - resize => $gl_resize(); + child: Box { hexpand: true; vexpand: true; - focusable: true; - focus-on-click: true; - has-stencil-buffer: false; - has-depth-buffer: false; - use-es: false; - } + + GLArea gl_area { + realize => $gl_realize(); + unrealize => $gl_unrealize(); + render => $gl_render(); + resize => $gl_resize(); + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + has-stencil-buffer: false; + has-depth-buffer: false; + use-es: false; + } + + PopoverMenu context_menu { + closed => $context_menu_closed(); + menu-model: context_menu_model; + flags: nested; + halign: start; + has-arrow: false; + } + }; [overlay] ProgressBar progress_bar_overlay { @@ -122,3 +135,104 @@ IMMulticontext im_context { preedit-end => $im_preedit_end(); commit => $im_commit(); } + +menu context_menu_model { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "win.prompt-title"; + } + + item { + label: _("Split Up"); + action: "win.split-up"; + } + + item { + label: _("Split Down"); + action: "win.split-down"; + } + + item { + label: _("Split Left"); + action: "win.split-left"; + } + + item { + label: _("Split Right"); + action: "win.split-right"; + } + } + + submenu { + label: _("Tab"); + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "win.close-tab"; + } + } + + submenu { + label: _("Window"); + + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + } + + section { + submenu { + label: _("Config"); + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + } +} diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig index d9d0083d0..50d0d1227 100644 --- a/src/apprt/gtk/menu.zig +++ b/src/apprt/gtk/menu.zig @@ -82,10 +82,6 @@ pub fn Menu( return self.menu_widget.as(gtk.Widget).getVisible() != 0; } - pub fn setVisible(self: *const Self, visible: bool) void { - self.menu_widget.as(gtk.Widget).setVisible(@intFromBool(visible)); - } - /// Refresh the menu. Right now that means enabling/disabling the "Copy" /// menu item based on whether there is an active selection or not, but /// that may change in the future. From 55c68d809d882c73560de8f9e31380a49e250151 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Aug 2025 12:31:51 -0700 Subject: [PATCH 04/83] apprt/gtk-ng: action accelerators, clean up explicit error sets --- src/apprt/gtk-ng/class/application.zig | 60 +++++++++++++++++++++ src/apprt/gtk-ng/class/global_shortcuts.zig | 21 ++++++-- src/apprt/gtk-ng/key.zig | 10 +++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 4f4cc77d4..d653d3e99 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -15,6 +15,7 @@ const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); const CoreApp = @import("../../../App.zig"); const configpkg = @import("../../../config.zig"); +const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); const systemd = @import("../../../os/systemd.zig"); const terminal = @import("../../../terminal/main.zig"); @@ -24,6 +25,7 @@ const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); const ext = @import("../ext.zig"); +const key = @import("../key.zig"); const adw_version = @import("../adw_version.zig"); const gtk_version = @import("../gtk_version.zig"); const winprotopkg = @import("../winproto.zig"); @@ -861,6 +863,61 @@ pub const Application = extern struct { } } + fn syncActionAccelerators(self: *Self) void { + self.syncActionAccelerator("app.quit", .{ .quit = {} }); + self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); + self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); + self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); + self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); + self.syncActionAccelerator("win.close", .{ .close_window = {} }); + self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); + self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); + self.syncActionAccelerator("win.close-tab", .{ .close_tab = {} }); + self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); + self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); + self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); + self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); + self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); + self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); + self.syncActionAccelerator("win.reset", .{ .reset = {} }); + self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); + self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); + } + + fn syncActionAccelerator( + self: *Self, + gtk_action: [:0]const u8, + action: input.Binding.Action, + ) void { + const gtk_app = self.as(gtk.Application); + + // Reset it initially + const zero = [_:null]?[*:0]const u8{}; + gtk_app.setAccelsForAction(gtk_action, &zero); + + const config = self.private().config.get(); + const trigger = config.keybind.set.getTrigger(action) orelse return; + var buf: [1024]u8 = undefined; + const accel = if (key.accelFromTrigger( + &buf, + trigger, + )) |accel_| + accel_ orelse return + else |err| switch (err) { + // This should really never, never happen. Its not critical enough + // to actually crash, but this is a bug somewhere. An accelerator + // for a trigger can't possibly be more than 1024 bytes. + error.NoSpaceLeft => { + log.warn("accelerator somehow longer than 1024 bytes: {}", .{trigger}); + return; + }, + }; + const accels = [_:null]?[*:0]const u8{accel}; + + gtk_app.setAccelsForAction(gtk_action, &accels); + } + //--------------------------------------------------------------- // Properties @@ -891,6 +948,9 @@ pub const Application = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + // Sync our accelerators for menu items. + self.syncActionAccelerators(); + // Load our runtime and custom CSS. If this fails then our window is // just stuck with the old CSS but we don't want to fail the entire // config change operation. diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk-ng/class/global_shortcuts.zig index b15a0d5a9..512dac57e 100644 --- a/src/apprt/gtk-ng/class/global_shortcuts.zig +++ b/src/apprt/gtk-ng/class/global_shortcuts.zig @@ -152,7 +152,7 @@ pub const GlobalShortcuts = extern struct { } } - fn refresh(self: *Self) !void { + fn refresh(self: *Self) Allocator.Error!void { // Always close our previous state first. self.close(); @@ -173,7 +173,7 @@ pub const GlobalShortcuts = extern struct { errdefer priv.map = .{}; // Update map - var trigger_buf: [256]u8 = undefined; + var trigger_buf: [1024]u8 = undefined; var it = config.keybind.set.bindings.iterator(); while (it.next()) |entry| { const leaf = switch (entry.value_ptr.*) { @@ -183,10 +183,23 @@ pub const GlobalShortcuts = extern struct { }; if (!leaf.flags.global) continue; - const trigger = try key.xdgShortcutFromTrigger( + const trigger = if (key.xdgShortcutFromTrigger( &trigger_buf, entry.key_ptr.*, - ) orelse continue; + )) |shortcut_| + shortcut_ orelse continue + else |err| switch (err) { + // If there isn't space to translate the trigger, then our + // buffer might be too small (but 1024 is insane!). In any case + // we don't want to stop registering globals. + error.NoSpaceLeft => { + log.warn( + "buffer too small to translate trigger, ignoring={}", + .{entry.key_ptr.*}, + ); + continue; + }, + }; try priv.map.put( alloc, diff --git a/src/apprt/gtk-ng/key.zig b/src/apprt/gtk-ng/key.zig index fc3296366..344d7de43 100644 --- a/src/apprt/gtk-ng/key.zig +++ b/src/apprt/gtk-ng/key.zig @@ -9,7 +9,10 @@ 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 { +pub fn accelFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{NoSpaceLeft}!?[:0]const u8 { var buf_stream = std.io.fixedBufferStream(buf); const writer = buf_stream.writer(); @@ -30,7 +33,10 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u /// 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 { +pub fn xdgShortcutFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{NoSpaceLeft}!?[:0]const u8 { var buf_stream = std.io.fixedBufferStream(buf); const writer = buf_stream.writer(); From 8c85bae9311040be9c0f0327f76109118a5c1f7d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 4 Aug 2025 12:41:38 -0700 Subject: [PATCH 05/83] apprt/gtk-ng: background-opacity doesn't need to be a window property We only need properties for things that are bound via the blueprint files. Otherwise, its kind of just a pain. This fixes a bug where it wasn't being properly set initially anyways because we didn't trigger syncAppearance. --- src/apprt/gtk-ng/class/window.zig | 42 +++++------------------------- src/apprt/gtk-ng/ui/1.5/window.blp | 1 - 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 009a815b5..7e2348785 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -117,23 +117,6 @@ pub const Window = extern struct { ); }; - pub const @"background-opaque" = struct { - pub const name = "background-opaque"; - const impl = gobject.ext.defineProperty( - name, - Self, - bool, - .{ - .nick = "Background Opaque", - .blurb = "True if the background should be opaque.", - .default = true, - .accessor = gobject.ext.typedAccessor(Self, bool, .{ - .getter = Self.getBackgroundOpaque, - }), - }, - ); - }; - pub const @"quick-terminal" = struct { pub const name = "quick-terminal"; const impl = gobject.ext.defineProperty( @@ -553,7 +536,6 @@ pub const Window = extern struct { // Trigger all our dynamic properties that depend on the config. inline for (&.{ - "background-opaque", "headerbar-visible", "tabs-autohide", "tabs-visible", @@ -568,6 +550,12 @@ pub const Window = extern struct { // Remainder uses the config const config = if (priv.config) |v| v.get() else return; + // Only add a solid background if we're opaque. + self.toggleCssClass( + "background", + config.@"background-opacity" >= 1, + ); + // Apply class to color headerbar if window-theme is set to `ghostty` and // GTK version is before 4.16. The conditional is because above 4.16 // we use GTK CSS color variables. @@ -751,12 +739,6 @@ pub const Window = extern struct { return config.@"gtk-titlebar"; } - fn getBackgroundOpaque(self: *Self) bool { - const priv = self.private(); - const config = (priv.config orelse return true).get(); - return config.@"background-opacity" >= 1.0; - } - fn getTabsAutohide(self: *Self) bool { const priv = self.private(); const config = if (priv.config) |v| v.get() else return true; @@ -889,16 +871,6 @@ pub const Window = extern struct { } } - /// Add or remove "background" CSS class depending on if the background - /// should be opaque. - fn propBackgroundOpaque( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - self.toggleCssClass("background", self.getBackgroundOpaque()); - } - fn propScaleFactor( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -1604,7 +1576,6 @@ pub const Window = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, - properties.@"background-opaque".impl, properties.config.impl, properties.debug.impl, properties.@"headerbar-visible".impl, @@ -1634,7 +1605,6 @@ pub const Window = extern struct { class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow); class.bindTemplateCallback("notify_n_pages", &tabViewNPages); class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage); - class.bindTemplateCallback("notify_background_opaque", &propBackgroundOpaque); class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("notify_fullscreened", &propFullscreened); class.bindTemplateCallback("notify_maximized", &propMaximized); diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index c70a8b350..121a1b45a 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -8,7 +8,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { close-request => $close_request(); realize => $realize(); - notify::background-opaque => $notify_background_opaque(); notify::config => $notify_config(); notify::fullscreened => $notify_fullscreened(); notify::maximized => $notify_maximized(); From ce68a864f0fc4abb51e4ddc3bab0367a35ca4c8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 07:24:31 +0000 Subject: [PATCH 06/83] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.13 to 1.2.14. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/9ff6d4004df1c3fd97cecafe010c874d77c48599...a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.14 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/test.yml | 42 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 2a1c98d48..f0d9f4bda 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ba1095896..5f78a733b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 03f6076a3..d65b976dd 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2a6ea8f9..aabb77fd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -101,7 +101,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -166,7 +166,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -199,7 +199,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -243,7 +243,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -414,7 +414,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -509,7 +509,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -554,7 +554,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -603,7 +603,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -651,7 +651,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -706,7 +706,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -734,7 +734,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -761,7 +761,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -788,7 +788,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -815,7 +815,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -842,7 +842,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -876,7 +876,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -903,7 +903,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -938,7 +938,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix @@ -996,7 +996,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 80d99b23f..4a995ae9a 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 with: path: | /nix From 5cf5f71c720b1da0a3cacc541acd1c4d1e947a3c Mon Sep 17 00:00:00 2001 From: trag1c Date: Tue, 5 Aug 2025 16:28:55 +0200 Subject: [PATCH 07/83] update Polish translations --- po/pl_PL.UTF-8.po | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/po/pl_PL.UTF-8.po b/po/pl_PL.UTF-8.po index 018146fb7..da1280a1a 100644 --- a/po/pl_PL.UTF-8.po +++ b/po/pl_PL.UTF-8.po @@ -3,14 +3,15 @@ # Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Bartosz Sokorski , 2025. +# trag1c , 2025. # msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-22 17:18+0000\n" -"PO-Revision-Date: 2025-03-17 12:15+0100\n" -"Last-Translator: Bartosz Sokorski \n" +"PO-Revision-Date: 2025-08-05 16:27+0200\n" +"Last-Translator: trag1c \n" "Language-Team: Polish \n" "Language: pl\n" "MIME-Version: 1.0\n" @@ -89,7 +90,7 @@ msgstr "Podziel w prawo" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "" +msgstr "Wykonaj komendę…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -162,7 +163,7 @@ msgstr "Otwórz konfigurację" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "" +msgstr "Paleta komend" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" @@ -210,12 +211,12 @@ msgstr "Zezwól" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 msgid "Remember choice for this split" -msgstr "" +msgstr "Zapamiętaj wybór dla tego podziału" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "" +msgstr "Przeładuj konfigurację, by ponownie wyświetlić ten komunikat" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 @@ -280,15 +281,15 @@ msgstr "Skopiowano do schowka" #: src/apprt/gtk/Surface.zig:1268 msgid "Cleared clipboard" -msgstr "" +msgstr "Wyczyszczono schowek" #: src/apprt/gtk/Surface.zig:2525 msgid "Command succeeded" -msgstr "" +msgstr "Komenda wykonana pomyślnie" #: src/apprt/gtk/Surface.zig:2527 msgid "Command failed" -msgstr "" +msgstr "Komenda nie powiodła się" #: src/apprt/gtk/Window.zig:216 msgid "Main Menu" @@ -300,7 +301,7 @@ msgstr "Zobacz otwarte karty" #: src/apprt/gtk/Window.zig:266 msgid "New Split" -msgstr "" +msgstr "Nowy podział" #: src/apprt/gtk/Window.zig:329 msgid "" From c1060d56b3088d5f88cdd6142a3fa0cb0749011e Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Tue, 5 Aug 2025 22:56:28 +0800 Subject: [PATCH 08/83] macOS: properly handle buffer in zh locale canonicalization --- src/os/i18n.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/os/i18n.zig b/src/os/i18n.zig index 2ecae27ac..fa8c125c7 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -133,7 +133,12 @@ pub fn canonicalizeLocale( locale: []const u8, ) error{NoSpaceLeft}![:0]const u8 { // Fix zh locales for macOS - if (fixZhLocale(locale)) |fixed| return fixed; + if (fixZhLocale(locale)) |fixed| { + if (buf.len < fixed.len + 1) return error.NoSpaceLeft; + @memcpy(buf[0..fixed.len], fixed); + buf[fixed.len] = 0; + return buf[0..fixed.len :0]; + } // Buffer must be 16 or at least as long as the locale and null term if (buf.len < @max(16, locale.len + 1)) return error.NoSpaceLeft; From a50605c5df7ccf8119719881c3c2ce6321436180 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 5 Aug 2025 10:24:58 -0500 Subject: [PATCH 09/83] gitignore: ignore core dumps created by valgrind --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 95eb1d5c3..e451b171a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ glad.zip /Box_test.ppm /Box_test_diff.ppm /ghostty.qcow2 + +vgcore.* From 6ae333869eae88634d4c8a18974047ae0e9e9817 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 5 Aug 2025 10:32:03 -0500 Subject: [PATCH 10/83] zig: switch all uses of callconv(.C) to callconv(.c) --- src/apprt/gtk-ng/class/application.zig | 10 +++++----- .../gtk-ng/class/clipboard_confirmation_dialog.zig | 10 +++++----- src/apprt/gtk-ng/class/close_confirmation_dialog.zig | 8 ++++---- src/apprt/gtk-ng/class/config.zig | 4 ++-- src/apprt/gtk-ng/class/config_errors_dialog.zig | 8 ++++---- src/apprt/gtk-ng/class/dialog.zig | 2 +- src/apprt/gtk-ng/class/global_shortcuts.zig | 6 +++--- src/apprt/gtk-ng/class/resize_overlay.zig | 6 +++--- src/apprt/gtk-ng/class/surface.zig | 8 ++++---- src/apprt/gtk-ng/class/surface_child_exited.zig | 10 +++++----- src/apprt/gtk-ng/class/tab.zig | 8 ++++---- src/apprt/gtk-ng/class/window.zig | 8 ++++---- src/apprt/gtk/ClipboardConfirmationWindow.zig | 4 ++-- src/apprt/gtk/Window.zig | 2 +- 14 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index d653d3e99..5e56b8a85 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -979,7 +979,7 @@ pub const Application = extern struct { //--------------------------------------------------------------- // Virtual Methods - fn startup(self: *Self) callconv(.C) void { + fn startup(self: *Self) callconv(.c) void { log.debug("startup", .{}); gio.Application.virtual_methods.startup.call( @@ -1237,7 +1237,7 @@ pub const Application = extern struct { priv.transient_cgroup_base = path; } - fn activate(self: *Self) callconv(.C) void { + fn activate(self: *Self) callconv(.c) void { log.debug("activate", .{}); // Queue a new window @@ -1253,7 +1253,7 @@ pub const Application = extern struct { ); } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config_errors_dialog.get()) |diag| { diag.close(); @@ -1272,7 +1272,7 @@ pub const Application = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { self.deinit(); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -1545,7 +1545,7 @@ pub const Application = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { // Register our compiled resources exactly once. { const c = @cImport({ diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig index cb9a27444..5f17035a6 100644 --- a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig @@ -150,7 +150,7 @@ pub const ClipboardConfirmationDialog = extern struct { return gobject.ext.newInstance(Self, .{}); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Trigger initial values @@ -239,7 +239,7 @@ pub const ClipboardConfirmationDialog = extern struct { fn response( self: *Self, response_id: [*:0]const u8, - ) callconv(.C) void { + ) callconv(.c) void { const remember: bool = if (comptime can_remember) remember: { const priv = self.private(); break :remember priv.remember_choice.getActive() != 0; @@ -262,7 +262,7 @@ pub const ClipboardConfirmationDialog = extern struct { } } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.clipboard_contents) |v| { v.unref(); @@ -280,7 +280,7 @@ pub const ClipboardConfirmationDialog = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.request) |v| { glib.ext.destroy(v); @@ -304,7 +304,7 @@ pub const ClipboardConfirmationDialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), if (comptime adw_version.atLeast(1, 4, 0)) diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig index 579f792ce..e64dfee98 100644 --- a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig @@ -81,7 +81,7 @@ pub const CloseConfirmationDialog = extern struct { }); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); } @@ -102,7 +102,7 @@ pub const CloseConfirmationDialog = extern struct { fn response( self: *Self, response_id: [*:0]const u8, - ) callconv(.C) void { + ) callconv(.c) void { if (std.mem.orderZ(u8, response_id, "close") == .eq) { signals.@"close-request".impl.emit( self, @@ -120,7 +120,7 @@ pub const CloseConfirmationDialog = extern struct { } } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -143,7 +143,7 @@ pub const CloseConfirmationDialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(Dialog); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk-ng/class/config.zig b/src/apprt/gtk-ng/class/config.zig index e40602f47..f1f058458 100644 --- a/src/apprt/gtk-ng/class/config.zig +++ b/src/apprt/gtk-ng/class/config.zig @@ -139,7 +139,7 @@ pub const Config = extern struct { return text_buf; } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { self.private().config.deinit(); gobject.Object.virtual_methods.finalize.call( @@ -159,7 +159,7 @@ pub const Config = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.Object.virtual_methods.finalize.implement(class, &finalize); gobject.ext.registerProperties(class, &.{ properties.@"diagnostics-buffer", diff --git a/src/apprt/gtk-ng/class/config_errors_dialog.zig b/src/apprt/gtk-ng/class/config_errors_dialog.zig index 52591e622..203c572fc 100644 --- a/src/apprt/gtk-ng/class/config_errors_dialog.zig +++ b/src/apprt/gtk-ng/class/config_errors_dialog.zig @@ -67,7 +67,7 @@ pub const ConfigErrorsDialog = extern struct { }); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); } @@ -82,7 +82,7 @@ pub const ConfigErrorsDialog = extern struct { fn response( self: *Self, response_id: [*:0]const u8, - ) callconv(.C) void { + ) callconv(.c) void { if (std.mem.orderZ(u8, response_id, "reload") != .eq) return; signals.@"reload-config".impl.emit( self, @@ -92,7 +92,7 @@ pub const ConfigErrorsDialog = extern struct { ); } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -134,7 +134,7 @@ pub const ConfigErrorsDialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(Dialog); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk-ng/class/dialog.zig b/src/apprt/gtk-ng/class/dialog.zig index fd7fed21b..41a1988ba 100644 --- a/src/apprt/gtk-ng/class/dialog.zig +++ b/src/apprt/gtk-ng/class/dialog.zig @@ -82,7 +82,7 @@ pub const Dialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { _ = class; } diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk-ng/class/global_shortcuts.zig index 512dac57e..9088e6b02 100644 --- a/src/apprt/gtk-ng/class/global_shortcuts.zig +++ b/src/apprt/gtk-ng/class/global_shortcuts.zig @@ -103,7 +103,7 @@ pub const GlobalShortcuts = extern struct { }; }; - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { _ = gobject.Object.signals.notify.connect( self, *Self, @@ -570,7 +570,7 @@ pub const GlobalShortcuts = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { // Since we drop references here we may lose access to things like // dbus connections, so we need to close all our connections right // away instead of in finalize. @@ -603,7 +603,7 @@ pub const GlobalShortcuts = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { // Properties gobject.ext.registerProperties(class, &.{ properties.config.impl, diff --git a/src/apprt/gtk-ng/class/resize_overlay.zig b/src/apprt/gtk-ng/class/resize_overlay.zig index 321d3d565..75e580127 100644 --- a/src/apprt/gtk-ng/class/resize_overlay.zig +++ b/src/apprt/gtk-ng/class/resize_overlay.zig @@ -147,7 +147,7 @@ pub const ResizeOverlay = extern struct { pub var offset: c_int = 0; }; - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); const priv = self.private(); @@ -228,7 +228,7 @@ pub const ResizeOverlay = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.idler) |v| { if (glib.Source.remove(v) == 0) { @@ -271,7 +271,7 @@ pub const ResizeOverlay = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index ecddb6e79..9e5a4dd50 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1158,7 +1158,7 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Virtual Methods - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); const priv = self.private(); @@ -1207,7 +1207,7 @@ pub const Surface = extern struct { self.propConfig(undefined, null); } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -1231,7 +1231,7 @@ pub const Surface = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.core_surface) |v| { // Remove ourselves from the list of known surfaces in the app. @@ -2283,7 +2283,7 @@ pub const Surface = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( diff --git a/src/apprt/gtk-ng/class/surface_child_exited.zig b/src/apprt/gtk-ng/class/surface_child_exited.zig index 3bf29285f..69d1306d6 100644 --- a/src/apprt/gtk-ng/class/surface_child_exited.zig +++ b/src/apprt/gtk-ng/class/surface_child_exited.zig @@ -72,7 +72,7 @@ const SurfaceChildExitedBanner = extern struct { pub var offset: c_int = 0; }; - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); } @@ -134,7 +134,7 @@ const SurfaceChildExitedBanner = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -146,7 +146,7 @@ const SurfaceChildExitedBanner = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.data) |v| { glib.ext.destroy(v); @@ -170,7 +170,7 @@ const SurfaceChildExitedBanner = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ @@ -255,7 +255,7 @@ const SurfaceChildExitedNoop = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { _ = class; signals.@"close-request".impl.register(.{}); } diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 9787e991e..3aa41c5ff 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -133,7 +133,7 @@ pub const Tab = extern struct { priv.surface.setParent(parent); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // If our configuration is null then we get the configuration @@ -183,7 +183,7 @@ pub const Tab = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -202,7 +202,7 @@ pub const Tab = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.title) |v| { glib.free(@constCast(@ptrCast(v))); @@ -250,7 +250,7 @@ pub const Tab = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(Surface); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 7e2348785..107de00e7 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -260,7 +260,7 @@ pub const Window = extern struct { }); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // If our configuration is null then we get the configuration @@ -892,7 +892,7 @@ pub const Window = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -911,7 +911,7 @@ pub const Window = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); priv.tab_bindings.unref(); priv.winproto.deinit(Application.default().allocator()); @@ -1562,7 +1562,7 @@ pub const Window = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(DebugWarning); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index bf1549021..a1d622143 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -184,14 +184,14 @@ fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { self.destroy(); } -fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { +fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void { const dialog = gobject.ext.cast(DialogType, dialog_.?).?; const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); const response = dialog.chooseFinish(result); self.handleResponse(response); } -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.C) void { +fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { self.handleResponse(response); } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index e6b502c80..2f026e33c 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -1121,7 +1121,7 @@ fn gtkActionToggleCommandPalette( _: *gio.SimpleAction, _: ?*glib.Variant, self: *Window, -) callconv(.C) void { +) callconv(.c) void { self.performBindingAction(.toggle_command_palette); } From cf77897388c24ee29933e6e65912c477c51cc1d3 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 5 Aug 2025 10:33:08 -0500 Subject: [PATCH 11/83] gtk-ng: port the command palette --- src/apprt/gtk-ng/build/gresource.zig | 1 + src/apprt/gtk-ng/class.zig | 6 + src/apprt/gtk-ng/class/application.zig | 11 +- src/apprt/gtk-ng/class/command_palette.zig | 575 ++++++++++++++++++++ src/apprt/gtk-ng/class/surface.zig | 24 + src/apprt/gtk-ng/class/window.zig | 78 +++ src/apprt/gtk-ng/css/style.css | 13 + src/apprt/gtk-ng/key.zig | 2 +- src/apprt/gtk-ng/ui/1.5/command-palette.blp | 109 ++++ valgrind.supp | 20 + 10 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 src/apprt/gtk-ng/class/command_palette.zig create mode 100644 src/apprt/gtk-ng/ui/1.5/command-palette.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 2c835a172..f5b91ce48 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -44,6 +44,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, + .{ .major = 1, .minor = 5, .name = "command-palette" }, }; /// CSS files in css_path diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index dc024c5cf..170df1acb 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -29,6 +29,12 @@ pub fn Common( return @ptrCast(@alignCast(gobject.Object.ref(self.as(gobject.Object)))); } + /// If the reference count is 1 and the object is floating, clear the + /// floating attribute. Otherwise, increase the reference count by 1. + pub fn refSink(self: *Self) *Self { + return @ptrCast(@alignCast(gobject.Object.refSink(self.as(gobject.Object)))); + } + /// Decrease the reference count of the object. pub fn unref(self: *Self) void { gobject.Object.unref(self.as(gobject.Object)); diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index d653d3e99..91359ee7c 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -607,10 +607,10 @@ pub const Application = extern struct { .toggle_quick_terminal => return Action.toggleQuickTerminal(self), .toggle_tab_overview => return Action.toggleTabOverview(target), .toggle_window_decorations => return Action.toggleWindowDecorations(target), + .toggle_command_palette => return Action.toggleCommandPalette(target), // Unimplemented but todo on gtk-ng branch .prompt_title, - .toggle_command_palette, .inspector, // TODO: splits .new_split, @@ -2111,6 +2111,15 @@ const Action = struct { }, } } + + pub fn toggleCommandPalette(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().toggleCommandPalette(); + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig new file mode 100644 index 000000000..5b274cad9 --- /dev/null +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -0,0 +1,575 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const adw = @import("adw"); +const gio = @import("gio"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const input = @import("../../../input.zig"); +const gresource = @import("../build/gresource.zig"); +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Window = @import("window.zig").Window; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_command_palette); + +pub const CommandPalette = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCommandPalette", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The configuration that this command palette is using.", + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when a command from the command palette is activated. The + /// action contains pointers to allocated data so if a receiver of this + /// signal needs to keep the action around it will need to clone the + /// action or there may be use-after-free errors. + pub const tigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const input.Binding.Action}, + void, + ); + }; + }; + + const Private = struct { + /// The configuration that this command palette is using. + config: ?*Config = null, + + /// The dialog object containing the palette UI. + dialog: *adw.Dialog, + + /// The search input text field. + search: *gtk.SearchEntry, + + /// The view containing each result row. + view: *gtk.ListView, + + /// The model that provides filtered data for the view to display. + model: *gtk.SingleSelection, + + /// The list that serves as the data source of the model. + /// This is where all command data is ultimately stored. + source: *gio.ListStore, + + pub var offset: c_int = 0; + }; + + /// Create a new instance of the command palette. The caller will own a + /// reference to the object. + pub fn new() *Self { + const self = gobject.ext.newInstance(Self, .{}); + + // Sink ourselves so that we aren't floating anymore. We'll unref + // ourselves when the palette is closed or an action is activated. + _ = self.refSink(); + + // Bump the ref so that the caller has a reference. + return self.ref(); + } + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Listen for any changes to our config. + _ = gobject.Object.signals.notify.connect( + self, + ?*anyopaque, + propConfig, + null, + .{ + .detail = "config", + }, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.source.removeAll(); + + if (priv.config) |config| { + config.unref(); + priv.config = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propConfig(self: *CommandPalette, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + const priv = self.private(); + + const config = priv.config orelse { + log.warn("command palette does not have a config!", .{}); + return; + }; + + const cfg = config.get(); + + // Clear existing binds + priv.source.removeAll(); + + for (cfg.@"command-palette-entry".value.items) |command| { + // Filter out actions that are not implemented or don't make sense + // for GTK. + switch (command.action) { + .close_all_windows, + .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, + => continue, + + else => {}, + } + + const cmd = Command.new(config, command); + const cmd_ref = cmd.as(gobject.Object); + priv.source.append(cmd_ref); + cmd_ref.unref(); + } + } + + fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // ESC was pressed - close the palette + const priv = self.private(); + _ = priv.dialog.close(); + self.unref(); + } + + fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // If Enter is pressed, activate the selected entry + const priv = self.private(); + self.activated(priv.model.getSelected()); + } + + fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { + self.activated(pos); + } + + //--------------------------------------------------------------- + + /// Show or hide the command palette dialog. If the dialog is shown it will + /// be modal over the given window. + pub fn toggle(self: *CommandPalette, window: *Window) void { + const priv = self.private(); + + // If the dialog has been shown, close it and unref ourselves so all of + // our memory is reclaimed. + if (priv.dialog.as(gtk.Widget).getRealized() != 0) { + _ = priv.dialog.close(); + self.unref(); + return; + } + + // Show the dialog + priv.dialog.present(window.as(gtk.Widget)); + + // Focus on the search bar when opening the dialog + _ = priv.search.as(gtk.Widget).grabFocus(); + } + + /// Helper function to send a signal containing the action that should be + /// performed. + fn activated(self: *CommandPalette, pos: c_uint) void { + const priv = self.private(); + + // Close before running the action in order to avoid being replaced by + // another dialog (such as the change title dialog). If that occurs then + // the command palette dialog won't be counted as having closed properly + // and cannot receive focus when reopened. + _ = priv.dialog.close(); + + // We are always done with the command palette when this finishes, even + // if there were errors. + defer self.unref(); + + // Use priv.model and not priv.source here to use the list of *visible* results + const object = priv.model.as(gio.ListModel).getObject(pos) orelse return; + defer object.unref(); + + const cmd = gobject.ext.cast(Command, object) orelse return; + + const action = cmd.getAction() orelse return; + + // Signal that an an action has been selected. Signals are synchronous + // so we shouldn't need to worry about cloning the action. + signals.tigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(Command); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "command-palette", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("dialog", .{}); + class.bindTemplateChildPrivate("search", .{}); + class.bindTemplateChildPrivate("view", .{}); + class.bindTemplateChildPrivate("model", .{}); + class.bindTemplateChildPrivate("source", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_config", &propConfig); + class.bindTemplateCallback("search_stopped", &searchStopped); + class.bindTemplateCallback("search_activated", &searchActivated); + class.bindTemplateCallback("row_activated", &rowActivated); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + }); + + // Signals + signals.tigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; + +/// Object that wraps around a command. +/// +/// As GTK list models only accept objects that are within the GObject hierarchy, +/// we have to construct a wrapper to be easily consumed by the list model. +const Command = extern struct { + pub const Self = @This(); + pub const Parent = gobject.Object; + parent: Parent, + + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCommand", + .instanceInit = &init, + .classInit = Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The configuration that this command palette is using.", + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const action_key = struct { + pub const name = "action-key"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .nick = "Action Key", + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetActionKey, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const action = struct { + pub const name = "action"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .nick = "Action", + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetAction, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const title = struct { + pub const name = "title"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .nick = "Title", + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetTitle, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const description = struct { + pub const name = "description"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .nick = "Description", + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetDescription, + .getter_transfer = .none, + }, + ), + }, + ); + }; + }; + + pub const Private = struct { + /// The configuration we should use to get keybindings. + config: ?*Config = null, + + /// Arena used to manage our allocations. + arena: ArenaAllocator, + + /// The command. + command: ?input.Command = null, + + /// Cache the formatted action. + action: ?[:0]const u8 = null, + + /// Cache the formatted action_key. + action_key: ?[:0]const u8 = null, + + pub var offset: c_int = 0; + }; + + pub fn new(config: *Config, command: input.Command) *Self { + const self = gobject.ext.newInstance(Self, .{ + .config = config, + }); + + const priv = self.private(); + priv.command = command.clone(priv.arena.allocator()) catch null; + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + // NOTE: we do not watch for changes to the config here as the command + // palette will destroy and recreate this object if/when the config + // changes. + + const priv = self.private(); + priv.arena = .init(Application.default().allocator()); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config) |config| { + config.unref(); + priv.config = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.arena.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + + fn propGetActionKey(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + if (priv.action_key) |action_key| return action_key; + + const command = priv.command orelse return null; + + priv.action_key = std.fmt.allocPrintZ( + priv.arena.allocator(), + "{}", + .{command.action}, + ) catch null; + + return priv.action_key; + } + + fn propGetAction(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + if (priv.action) |action| return action; + + const command = priv.command orelse return null; + + const cfg = if (priv.config) |config| config.get() else return null; + const keybinds = cfg.keybind.set; + + const alloc = priv.arena.allocator(); + + priv.action = action: { + var buf: [64]u8 = undefined; + const trigger = keybinds.getTrigger(command.action) orelse break :action null; + const accel = (key.accelFromTrigger(&buf, trigger) catch break :action null) orelse break :action null; + break :action alloc.dupeZ(u8, accel) catch return null; + }; + + return priv.action; + } + + fn propGetTitle(self: *Self) ?[:0]const u8 { + const priv = self.private(); + const command = priv.command orelse return null; + return command.title; + } + + fn propGetDescription(self: *Self) ?[:0]const u8 { + const priv = self.private(); + const command = priv.command orelse return null; + return command.description; + } + + //--------------------------------------------------------------- + + /// Return a copy of the action. Callers must ensure that they do not use + /// the action beyond the lifetime of this object because it has internally + /// allocated data that will be freed when this object is. + pub fn getAction(self: *Self) ?input.Binding.Action { + const priv = self.private(); + const command = priv.command orelse return null; + return command.action; + } + + //--------------------------------------------------------------- + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.action_key.impl, + properties.action.impl, + properties.title.impl, + properties.description.impl, + }); + + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index ecddb6e79..4223a4653 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -377,6 +377,19 @@ pub const Surface = extern struct { void, ); }; + + /// Emitted when this surface requests that the command palette be + /// toggled. + pub const @"toggle-command-palette" = struct { + pub const name = "toggle-command-palette"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; }; const Private = struct { @@ -566,6 +579,16 @@ pub const Surface = extern struct { ); } + pub fn toggleCommandPalette(self: *Self) bool { + signals.@"toggle-command-palette".impl.emit( + self, + null, + .{}, + null, + ); + return true; + } + /// Set the current progress report state. pub fn setProgressReport( self: *Self, @@ -2362,6 +2385,7 @@ pub const Surface = extern struct { signals.@"present-request".impl.register(.{}); signals.@"toggle-fullscreen".impl.register(.{}); signals.@"toggle-maximize".impl.register(.{}); + signals.@"toggle-command-palette".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 7e2348785..403701f58 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -25,6 +25,7 @@ const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseCo const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; +const CommandPalette = @import("command_palette.zig").CommandPalette; const log = std.log.scoped(.gtk_ghostty_window); @@ -244,6 +245,9 @@ pub const Window = extern struct { /// See tabOverviewOpen for why we have this. tab_overview_focus_timer: ?c_uint = null, + /// A weak reference to a command palette. + command_palette: gobject.WeakRef = std.mem.zeroes(gobject.WeakRef), + // Template bindings tab_overview: *adw.TabOverview, tab_bar: *adw.TabBar, @@ -332,6 +336,7 @@ pub const Window = extern struct { .{ "paste", actionPaste, null }, .{ "reset", actionReset, null }, .{ "clear", actionClear, null }, + .{ "toggle-command-palette", actionToggleCommandPalette, null }, }; const action_map = self.as(gio.ActionMap); @@ -1208,6 +1213,13 @@ pub const Window = extern struct { self, .{}, ); + _ = Surface.signals.@"toggle-command-palette".connect( + surface, + *Self, + surfaceToggleCommandPalette, + self, + .{}, + ); // If we've never had a surface initialize yet, then we register // this signal. Its theoretically possible to launch multiple surfaces @@ -1418,6 +1430,15 @@ pub const Window = extern struct { // We react to the changes in the propMaximized callback } + /// React to a signal from a surface requesting that the command palette + /// be toggled. + fn surfaceToggleCommandPalette( + _: *Surface, + self: *Self, + ) callconv(.c) void { + self.toggleCommandPalette(); + } + fn surfaceInit( surface: *Surface, self: *Self, @@ -1551,6 +1572,63 @@ pub const Window = extern struct { self.performBindingAction(.clear_screen); } + /// Toggle the command palette. + fn toggleCommandPalette(self: *Window) void { + const priv = self.private(); + // Get a reference to a command palette. First check the weak reference + // that we save to see if we already have stored. If we don't then + // create a new one. + const command_palette = gobject.ext.cast(CommandPalette, priv.command_palette.get()) orelse command_palette: { + // Create a fresh command palette. + const command_palette = CommandPalette.new(); + + // Synchronize our config to the command palette's config. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + command_palette.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Listen to the activate signal to know if the user selected an option in + // the command palette. + _ = CommandPalette.signals.tigger.connect( + command_palette, + *Window, + signalCommandPaletteTrigger, + self, + .{}, + ); + + break :command_palette command_palette; + }; + defer command_palette.unref(); + + // Save a weak reference to the command palette. We use a weak reference to avoid + // reference counting cycles that might cause problems later. + priv.command_palette.set(command_palette.as(gobject.Object)); + + // Tell the command palette to toggle itself. If the dialog gets + // presented (instead of hidden) it will be modal over our window. + command_palette.toggle(self); + } + + // React to a signal from a command palette asking an action to be performed. + fn signalCommandPaletteTrigger(_: *CommandPalette, action: *const input.Binding.Action, self: *Self) callconv(.c) void { + // If the activation actually has an action, perform it. + self.performBindingAction(action.*); + } + + /// React to a GTK action requesting that the command palette be toggled. + fn actionToggleCommandPalette( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.toggleCommandPalette(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index 1e3e09d9f..970c91b03 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -101,3 +101,16 @@ label.resize-overlay { /* after GTK 4.16 is a requirement, switch to the following: */ /* background-color: color-mix(in srgb, var(--error-bg-color), transparent); */ } + +/* + * Command Palette + */ +.command-palette-search > image:first-child { + margin-left: 8px; + margin-right: 4px; +} + +.command-palette-search > image:last-child { + margin-left: 4px; + margin-right: 8px; +} diff --git a/src/apprt/gtk-ng/key.zig b/src/apprt/gtk-ng/key.zig index 344d7de43..a00b0312e 100644 --- a/src/apprt/gtk-ng/key.zig +++ b/src/apprt/gtk-ng/key.zig @@ -60,7 +60,7 @@ pub fn xdgShortcutFromTrigger( return slice[0 .. slice.len - 1 :0]; } -fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { +fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) error{NoSpaceLeft}!bool { switch (trigger.key) { .physical => |k| { const keyval = keyvalFromKey(k) orelse return false; diff --git a/src/apprt/gtk-ng/ui/1.5/command-palette.blp b/src/apprt/gtk-ng/ui/1.5/command-palette.blp new file mode 100644 index 000000000..0ccae1f0a --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/command-palette.blp @@ -0,0 +1,109 @@ +using Gtk 4.0; +using Gio 2.0; +using Adw 1; + +Adw.Dialog dialog { + content-width: 700; + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + [title] + Gtk.SearchEntry search { + hexpand: true; + placeholder-text: _("Execute a command…"); + stop-search => $search_stopped(); + activate => $search_activated(); + + styles [ + "command-palette-search", + ] + } + } + + Gtk.ScrolledWindow { + min-content-height: 300; + + Gtk.ListView view { + show-separators: true; + single-click-activate: true; + activate => $row_activated(); + + model: Gtk.SingleSelection model { + model: Gtk.FilterListModel { + incremental: true; + + filter: Gtk.AnyFilter { + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.title; + search: bind search.text; + } + + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.action-key; + search: bind search.text; + } + }; + + model: Gio.ListStore source { + item-type: typeof<$GhosttyCommand>; + }; + }; + }; + + styles [ + "rich-list", + ] + + factory: Gtk.BuilderListItemFactory { + template Gtk.ListItem { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + tooltip-text: bind template.item as <$GhosttyCommand>.description; + + Gtk.Box { + orientation: vertical; + hexpand: true; + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "title", + ] + + label: bind template.item as <$GhosttyCommand>.title; + } + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "subtitle", + "monospace", + ] + + label: bind template.item as <$GhosttyCommand>.action-key; + } + } + + Gtk.ShortcutLabel { + accelerator: bind template.item as <$GhosttyCommand>.action; + valign: center; + } + }; + } + }; + } + } + } +} diff --git a/valgrind.supp b/valgrind.supp index 3535ecc45..2a0d2b9fa 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -840,6 +840,26 @@ fun:FcConfigSubstituteWithPat } +{ + FcConfigValues + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + obj:/usr/lib*/libfontconfig.so* + obj:/usr/lib*/libfontconfig.so* + fun:FcConfigValues +} + +{ + FcValueSave + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + obj:/usr/lib*/libfontconfig.so* + obj:/usr/lib*/libfontconfig.so* + fun:FcValueSave +} + # Pixman { pixman_image_composite32 From 19fde96d30fe49eb8b29e1efa31fb97780df79c9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 09:38:08 -0700 Subject: [PATCH 12/83] funny typos --- src/apprt/gtk-ng/class/command_palette.zig | 6 +++--- src/apprt/gtk-ng/class/window.zig | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig index 5b274cad9..2dc3e1a74 100644 --- a/src/apprt/gtk-ng/class/command_palette.zig +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -50,7 +50,7 @@ pub const CommandPalette = extern struct { /// action contains pointers to allocated data so if a receiver of this /// signal needs to keep the action around it will need to clone the /// action or there may be use-after-free errors. - pub const tigger = struct { + pub const trigger = struct { pub const name = "trigger"; pub const connect = impl.connect; const impl = gobject.ext.defineSignal( @@ -240,7 +240,7 @@ pub const CommandPalette = extern struct { // Signal that an an action has been selected. Signals are synchronous // so we shouldn't need to worry about cloning the action. - signals.tigger.impl.emit( + signals.trigger.impl.emit( self, null, .{&action}, @@ -290,7 +290,7 @@ pub const CommandPalette = extern struct { }); // Signals - signals.tigger.impl.register(.{}); + signals.trigger.impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 403701f58..7c036e2f7 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1593,7 +1593,7 @@ pub const Window = extern struct { // Listen to the activate signal to know if the user selected an option in // the command palette. - _ = CommandPalette.signals.tigger.connect( + _ = CommandPalette.signals.trigger.connect( command_palette, *Window, signalCommandPaletteTrigger, From 7db2ab9863a4b6382f7035eaa48758cdefea5bb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 00:29:00 +0000 Subject: [PATCH 13/83] build(deps): bump actions/download-artifact from 4.3.0 to 5.0.0 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 5.0.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...634f93cb2916e3fdff6788551b99b062d0335ce0) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 10 +++++----- .github/workflows/test.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 5f78a733b..5d3bfe067 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -279,7 +279,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: macos @@ -302,7 +302,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Download macOS Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: macos @@ -350,17 +350,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: source-tarball diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aabb77fd3..a5d940771 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -406,7 +406,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: source-tarball - name: Extract tarball @@ -969,7 +969,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@01628ae51ea5d6b0c90109c7dccbf511953aff29 # v0.0.18 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: source-tarball From 288601e386565f841de4962a81db5e1d07dad550 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 5 Aug 2025 19:51:13 -0500 Subject: [PATCH 14/83] gtk-ng: fix OSC 22 not changing mouse shape on -ng --- src/apprt/gtk-ng/class/surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 3a81a488b..4251b56a8 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1495,7 +1495,7 @@ pub const Surface = extern struct { }; // Set our new cursor. - priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr); + self.as(gtk.Widget).setCursorFromName(name.ptr); } //--------------------------------------------------------------- From 35e3ac8b4cf9e763bdec9a8d3b164260c4fd224e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 5 Aug 2025 19:52:14 -0500 Subject: [PATCH 15/83] gtk-ng: remove use of deprecated use-es GLArea property --- src/apprt/gtk-ng/ui/1.2/surface.blp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index ab34cadac..e671a0d82 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -30,7 +30,7 @@ template $GhosttySurface: Adw.Bin { focus-on-click: true; has-stencil-buffer: false; has-depth-buffer: false; - use-es: false; + allowed-apis: gl; } PopoverMenu context_menu { From 5fbdb8c4591f0524130002c9097aa3e8c6df17fa Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 6 Aug 2025 14:44:40 +0800 Subject: [PATCH 16/83] build: allow disabling i18n GNU gettext simply is a PITA on certain platforms (i.e. Windows, musl Linux, etc.) and currently it's not possible to cleanly remove i18n from the build process, making building Ghostty on the aforementioned platforms difficult. By providing users with a way to opt-out of the i18n mechanisms (or opt-in, on platforms where i18n is disabled by default) we can make sure that people at least have *some* way of building Ghostty before i18n mechanisms can be integrated neatly. --- build.zig | 16 ++++++++++------ src/build/Config.zig | 13 +++++++++++++ src/build/GhosttyXcodebuild.zig | 6 +++--- src/build_config.zig | 1 + src/os/i18n.zig | 6 ++++++ 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/build.zig b/build.zig index 1c98b2fa5..4acca53cc 100644 --- a/build.zig +++ b/build.zig @@ -27,7 +27,7 @@ pub fn build(b: *std.Build) !void { // Ghostty resources like terminfo, shell integration, themes, etc. const resources = try buildpkg.GhosttyResources.init(b, &config); - const i18n = try buildpkg.GhosttyI18n.init(b, &config); + const i18n = if (config.i18n) try buildpkg.GhosttyI18n.init(b, &config) else null; // Ghostty dependencies used by many artifacts. const deps = try buildpkg.SharedDeps.init(b, &config); @@ -79,7 +79,7 @@ pub fn build(b: *std.Build) !void { if (config.app_runtime != .none) { exe.install(); resources.install(); - i18n.install(); + if (i18n) |v| v.install(); } else { // Libghostty // @@ -112,7 +112,7 @@ pub fn build(b: *std.Build) !void { // The xcframework build always installs resources because our // macOS xcode project contains references to them. resources.install(); - i18n.install(); + if (i18n) |v| v.install(); } // Ghostty macOS app @@ -122,7 +122,7 @@ pub fn build(b: *std.Build) !void { .{ .xcframework = &xcframework, .docs = &docs, - .i18n = &i18n, + .i18n = if (i18n) |v| &v else null, .resources = &resources, }, ); @@ -166,7 +166,7 @@ pub fn build(b: *std.Build) !void { .{ .xcframework = &xcframework_native, .docs = &docs, - .i18n = &i18n, + .i18n = if (i18n) |v| &v else null, .resources = &resources, }, ); @@ -204,5 +204,9 @@ pub fn build(b: *std.Build) !void { // update-translations does what it sounds like and updates the "pot" // files. These should be committed to the repo. - translations_step.dependOn(i18n.update_step); + if (i18n) |v| { + translations_step.dependOn(v.update_step); + } else { + try translations_step.addError("cannot update translations when i18n is disabled", .{}); + } } diff --git a/src/build/Config.zig b/src/build/Config.zig index 69a9dd8a0..175745dc6 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -37,6 +37,7 @@ font_backend: font.Backend = .freetype, x11: bool = false, wayland: bool = false, sentry: bool = true, +i18n: bool = true, wasm_shared: bool = true, /// Ghostty exe properties @@ -175,6 +176,16 @@ pub fn init(b: *std.Build) !Config { "Enables linking against X11 libraries when using the GTK rendering backend.", ) orelse gtk_targets.x11; + config.i18n = b.option( + bool, + "i18n", + "Enables gettext-based internationalization. Enabled by default only for macOS, and other Unix-like systems like Linux and FreeBSD when using glibc.", + ) orelse switch (target.result.os.tag) { + .macos, .ios => true, + .linux, .freebsd => target.result.isGnuLibC(), + else => false, + }; + //--------------------------------------------------------------- // Ghostty Exe Properties @@ -420,6 +431,7 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { step.addOption(bool, "x11", self.x11); step.addOption(bool, "wayland", self.wayland); step.addOption(bool, "sentry", self.sentry); + step.addOption(bool, "i18n", self.i18n); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(rendererpkg.Impl, "renderer", self.renderer); @@ -467,6 +479,7 @@ pub fn fromOptions() Config { .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, .wasm_shared = options.wasm_shared, + .i18n = options.i18n, }; } diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index d3bda032d..0afb64007 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -17,7 +17,7 @@ xctest: *std.Build.Step.Run, pub const Deps = struct { xcframework: *const XCFramework, docs: *const Docs, - i18n: *const I18n, + i18n: ?*const I18n, resources: *const Resources, }; @@ -81,7 +81,7 @@ pub fn init( // We also need all these resources because the xcode project // references them via symlinks. deps.resources.addStepDependencies(&step.step); - deps.i18n.addStepDependencies(&step.step); + if (deps.i18n) |v| v.addStepDependencies(&step.step); deps.docs.installDummy(&step.step); // Expect success @@ -113,7 +113,7 @@ pub fn init( // We also need all these resources because the xcode project // references them via symlinks. deps.resources.addStepDependencies(&step.step); - deps.i18n.addStepDependencies(&step.step); + if (deps.i18n) |v| v.addStepDependencies(&step.step); deps.docs.installDummy(&step.step); // Expect success diff --git a/src/build_config.zig b/src/build_config.zig index 3dac47463..903197717 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -41,6 +41,7 @@ pub const flatpak = options.flatpak; pub const app_runtime: apprt.Runtime = config.app_runtime; pub const font_backend: font.Backend = config.font_backend; pub const renderer: rendererpkg.Impl = config.renderer; +pub const i18n: bool = config.i18n; /// The bundle ID for the app. This is used in many places and is currently /// hardcoded here. We could make this configurable in the future if there diff --git a/src/os/i18n.zig b/src/os/i18n.zig index 2ecae27ac..69baf7a2c 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -73,6 +73,8 @@ pub const InitError = error{ /// want to set the domain for the entire application since this is also /// used by libghostty. pub fn init(resources_dir: []const u8) InitError!void { + if (comptime !build_config.i18n) return; + switch (builtin.os.tag) { // i18n is unsupported on Windows .windows => return, @@ -102,11 +104,13 @@ pub fn init(resources_dir: []const u8) InitError!void { /// This should only be called for apprts that are fully owning the /// Ghostty application. This should not be called for libghostty users. pub fn initGlobalDomain() error{OutOfMemory}!void { + if (comptime !build_config.i18n) return; _ = textdomain(build_config.bundle_id) orelse return error.OutOfMemory; } /// Translate a message for the Ghostty domain. pub fn _(msgid: [*:0]const u8) [*:0]const u8 { + if (comptime !build_config.i18n) return msgid; return dgettext(build_config.bundle_id, msgid); } @@ -132,6 +136,8 @@ pub fn canonicalizeLocale( buf: []u8, locale: []const u8, ) error{NoSpaceLeft}![:0]const u8 { + if (comptime !build_config.i18n) return locale; + // Fix zh locales for macOS if (fixZhLocale(locale)) |fixed| return fixed; From eb96ff075771dc269ef64bee9b19d13368d1237b Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 7 Aug 2025 02:48:45 +0800 Subject: [PATCH 17/83] font: disable discretionary ligatures by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #5372 Discretionary ligatures (denoted by the OpenType feature tag `dlig`) are sometimes used by programming fonts (e.g. Iosevka) to provide more "complex" and uncommon ligatures that may be useful in a programming context. Unfortunately, this has some nasty side effects with certain Japanese fallback fonts (#5372) due to perhaps a misaligned understanding of the OpenType spec[^spec]. The spec details that `dlig` ligatures should only be used to contract sequences of glyphs together into one glyph, and that it should be used only for "special effect", **at the user's preference** (emphasis mine). Indeed, it also suggests that: > UI suggestion: This feature should be off by default. All of this, combined with the fact that historical, nowadays unused and even unintelligible Kanji ligatures are explicitly included as examples of discretionary ligatures, shows that in the Japanese context at least that the "level of discretion" is significantly higher than what is found in programming fonts, where it is more understood to be "opinionated and uncommon", rather than "obsolete and unreadable". Furthermore, it appears that a lot of common programming fonts don't even make use of the `dlig` feature — JetBrains Mono, FiraCode and MonoLisa lack a `dlig` feature altogether, while Inconsolata seems to only use it for ligatures that are more commonly found in `liga` or `calt`, such as the `->` ligature. To a lot of people, then, this change would literally alter nothing. Therefore, it's my opinion that we should disable `dlig` by default. It's arguably not being used correctly in the programming font space (or at least not in a way that's coherent with other fonts), and it only provides a marginal benefit while potentially rendering entire sentences in Japanese (and possibly other languages) unreadable out of the box. If someone upgrades to tip or 1.2 and then asks "why aren't the ligatures working anymore", then at least they can always just turn on `dlig` by themselves. [^spec]: https://learn.microsoft.com/en-us/typography/opentype/spec/features_ae#tag-dlig --- src/font/shaper/feature.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 66d0cb1f7..5fce7d6eb 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -287,7 +287,6 @@ pub const FeatureList = struct { /// These features are hardcoded to always be on by default. Users /// can turn them off by setting the features to "-liga" for example. pub const default_features = [_]Feature{ - .{ .tag = "dlig".*, .value = 1 }, .{ .tag = "liga".*, .value = 1 }, }; From 8774e88d4e97be63b9d9140c26ef47ce01e57c92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 00:55:06 +0000 Subject: [PATCH 18/83] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.14 to 1.2.15. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02...f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/test.yml | 42 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f0d9f4bda..8c141f8e8 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 5d3bfe067..dc6dd7741 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index d65b976dd..ba08a1fe3 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5d940771..db1cbeb55 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -101,7 +101,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -166,7 +166,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -199,7 +199,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -243,7 +243,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -414,7 +414,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -509,7 +509,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -554,7 +554,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -603,7 +603,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -651,7 +651,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -706,7 +706,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -734,7 +734,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -761,7 +761,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -788,7 +788,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -815,7 +815,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -842,7 +842,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -876,7 +876,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -903,7 +903,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -938,7 +938,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix @@ -996,7 +996,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 4a995ae9a..9f0b02a14 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a2c6b4830ed1830cc8ee3ee33f4210a856f4ae02 # v1.2.14 + uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 with: path: | /nix From 3b898a98006c0928f760d4438b06cabbb8ebe8d9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 6 Aug 2025 21:18:44 -0500 Subject: [PATCH 19/83] gtk/gtk-ng: update zig-gobject to pick up fix for ianprime0509/zig-gobject#115 --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 792f9879e..0fa9476cb 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,8 +55,8 @@ .gobject = .{ // https://github.com/jcollie/ghostty-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst", - .hash = "gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE", + .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst", + .hash = "gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ccda58fd..3bdda9b00 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE": { + "gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma": { "name": "gobject", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst", - "hash": "sha256-ybeHo+NwcVZuyU037XB+/OofDoIoLsPnyNCG2jyiXC0=" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst", + "hash": "sha256-43IIiHR5J7PfgG9JXSlGgC6WztC10fXyIhGZfY9xceQ=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c3fe7b2fa..25822ebed 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -122,11 +122,11 @@ in }; } { - name = "gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE"; + name = "gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst"; - hash = "sha256-ybeHo+NwcVZuyU037XB+/OofDoIoLsPnyNCG2jyiXC0="; + url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst"; + hash = "sha256-43IIiHR5J7PfgG9JXSlGgC6WztC10fXyIhGZfY9xceQ="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 466a753ff..accc9fca6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d6 https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst +https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 7da4009fd..a5e0783f0 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE", - "sha256": "c9b787a3e37071566ec94d37ed707efcea1f0e82282ec3e7c8d086da3ca25c2d" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma", + "sha256": "e3720888747927b3df806f495d2946802e96ced0b5d1f5f22211997d8f7171e4" }, { "type": "archive", From f107b2f9106ae57b76bc37819d36a068109e293d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 7 Aug 2025 11:40:00 +0800 Subject: [PATCH 20/83] font/{harfbuzz,coretext}: enable dlig for test shaper Some of the tests rely on dlig and I'm far too lazy to rewrite those tests now --- src/font/shaper/coretext.zig | 5 ++++- src/font/shaper/harfbuzz.zig | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index afc7d9adb..285a5a6b9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1833,7 +1833,10 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); - var shaper = try Shaper.init(alloc, .{}); + var shaper = try Shaper.init(alloc, .{ + // Some of our tests rely on dlig being enabled by default + .features = &.{"dlig"}, + }); errdefer shaper.deinit(); return TestShaper{ diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 8a0beab8b..b5c96797f 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1296,7 +1296,10 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); - var shaper = try Shaper.init(alloc, .{}); + var shaper = try Shaper.init(alloc, .{ + // Some of our tests rely on dlig being enabled by default + .features = &.{"dlig"}, + }); errdefer shaper.deinit(); return TestShaper{ From 7811c04f9d75582f9ba12527a4919211ff8612b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 10:18:09 -0700 Subject: [PATCH 21/83] apprt/gtk-ng: SplitTree data structure --- src/apprt/gtk-ng.zig | 1 + src/apprt/gtk-ng/split_tree.zig | 310 ++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 src/apprt/gtk-ng/split_tree.zig diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index de9255fe9..8f0489b80 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -7,6 +7,7 @@ pub const resourcesDir = internal_os.resourcesDir; // The exported API, custom for the apprt. pub const class = @import("gtk-ng/class.zig"); +pub const SplitTree = @import("gtk-ng/split_tree.zig").SplitTree; pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; test { diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/apprt/gtk-ng/split_tree.zig new file mode 100644 index 000000000..6d86ed902 --- /dev/null +++ b/src/apprt/gtk-ng/split_tree.zig @@ -0,0 +1,310 @@ +const std = @import("std"); +const assert = std.debug.assert; +const ArenaAllocator = std.heap.ArenaAllocator; +const Allocator = std.mem.Allocator; + +/// SplitTree represents a tree of view types that can be divided. +/// +/// Concretely for Ghostty, it represents a tree of terminal views. In +/// its basic state, there are no splits and it is a single full-sized +/// terminal. However, it can be split arbitrarily many times among two +/// axes (horizontal and vertical) to create a tree of terminal views. +/// +/// This is an immutable tree structure, meaning all operations on it +/// will return a new tree with the operation applied. This allows us to +/// store versions of the tree in a history for easy undo/redo. To facilitate +/// this, the stored View type must implement reference counting; this is left +/// as an implementation detail of the View type. +/// +/// The View type will be stored as a pointer within the tree and must +/// implement a number of functions to work properly: +/// +/// - `fn ref(*View, Allocator) Allocator.Error!*View` - Increase a +/// reference count of the view. The Allocator will be the allocator provided +/// to the tree operation. This is allowed to copy the value if it wants to; +/// the returned value is expected to be a new reference (but that may +/// just be a copy). +/// +/// - `fn unref(*View, Allocator) void` - Decrease the reference count of a +/// view. The Allocator will be the allocator provided to the tree +/// operation. +/// +/// - `fn eql(*const View, *const View) bool` - Check if two views are equal. +/// +pub fn SplitTree(comptime V: type) type { + return struct { + const Self = @This(); + + /// The view that this tree contains. + pub const View = V; + + /// The arena allocator used for all allocations in the tree. + /// Since the tree is an immutable structure, this lets us + /// cleanly free all memory when the tree is deinitialized. + arena: ArenaAllocator, + + /// All the nodes in the tree. Node at index 0 is always the root. + nodes: []const Node, + + /// An empty tree. + pub const empty: Self = .{ + // Arena can be undefined because we have zero allocated nodes. + // If our nodes are empty our deinit function doesn't touch the + // arena. + .arena = undefined, + .nodes = &.{}, + }; + + pub const Node = union(enum) { + leaf: *View, + split: Split, + + /// A handle into the nodes array. This lets us keep track of + /// nodes with 16-bit handles rather than full pointer-width + /// values. + pub const Handle = u16; + }; + + pub const Split = struct { + layout: Layout, + ratio: f16, + left: Node.Handle, + right: Node.Handle, + + pub const Layout = enum { horizontal, vertical }; + pub const Direction = enum { left, right, down, up }; + }; + + /// Initialize a new tree with a single view. + pub fn init(gpa: Allocator, view: *View) Allocator.Error!Self { + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const nodes = try alloc.alloc(Node, 1); + nodes[0] = .{ .leaf = try view.ref(gpa) }; + errdefer view.unref(gpa); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + pub fn deinit(self: *Self) void { + // Important: only free memory if we have memory to free, + // because we use an undefined arena for empty trees. + if (self.nodes.len > 0) { + // Unref all our views + const gpa: Allocator = self.arena.child_allocator; + for (self.nodes) |node| switch (node) { + .leaf => |view| view.unref(gpa), + .split => {}, + }; + self.arena.deinit(); + } + + self.* = undefined; + } + + /// Insert another tree into this tree at the given node in the + /// specified direction. The other tree will be inserted in the + /// new direction. For example, if the direction is "right" then + /// `insert` is inserted right of the existing node. + /// + /// The allocator will be used for the newly created tree. + /// The previous trees will not be freed, but reference counts + /// for the views will be increased accordingly for the new tree. + pub fn split( + self: *const Self, + gpa: Allocator, + at: Node.Handle, + direction: Split.Direction, + insert: *const Self, + ) Allocator.Error!Self { + // The new arena for our new tree. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // We know we're going to need the sum total of the nodes + // between the two trees plus one for the new split node. + const nodes = try alloc.alloc(Node, self.nodes.len + insert.nodes.len + 1); + if (nodes.len > std.math.maxInt(Node.Handle)) return error.OutOfMemory; + + // We can copy our nodes exactly as they are, since they're + // mostly not changing (only `at` is changing). + @memcpy(nodes[0..self.nodes.len], self.nodes); + + // We can copy the destination nodes as well directly next to + // the source nodes. We just have to go through and offset + // all the handles in the destination tree to account for + // the shift. + const nodes_inserted = nodes[self.nodes.len..][0..insert.nodes.len]; + @memcpy(nodes_inserted, insert.nodes); + for (nodes_inserted) |*node| switch (node.*) { + .leaf => {}, + .split => |*s| { + // We need to offset the handles in the split + s.left += @intCast(self.nodes.len); + s.right += @intCast(self.nodes.len); + }, + }; + + // Determine our split layout and if we're on the left + const layout: Split.Layout, const left: bool = switch (direction) { + .left => .{ .horizontal, true }, + .right => .{ .horizontal, false }, + .up => .{ .vertical, true }, + .down => .{ .vertical, false }, + }; + + // Copy our previous value to the end of the nodes list and + // create our new split node. + nodes[nodes.len - 1] = nodes[at]; + nodes[at] = .{ .split = .{ + .layout = layout, + .ratio = 0.5, + .left = @intCast(if (left) self.nodes.len else nodes.len - 1), + .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), + } }; + + // We need to increase the reference count of all the nodes. + // Careful accounting here so that we properly unref on error + // only the nodes we referenced. + var reffed: usize = 0; + errdefer for (0..reffed) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| view.unref(gpa), + } + }; + for (0..nodes.len) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| nodes[i] = .{ .leaf = try view.ref(gpa) }, + } + reffed = i; + } + assert(reffed == nodes.len - 1); + + return .{ .arena = arena, .nodes = nodes }; + } + + /// Format the tree in a human-readable format. + /// + /// NOTE: This is currently in node-order but we should change this + /// to spatial ASCII drawings once we have better support for that. + pub fn format( + self: *const Self, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + if (self.nodes.len == 0) { + try writer.writeAll("empty"); + } else { + try self.formatNode(writer, 0, 0); + } + } + + fn formatNode( + self: *const Self, + writer: anytype, + handle: Node.Handle, + depth: usize, + ) !void { + const node = self.nodes[handle]; + + // Write indentation + for (0..depth) |_| try writer.writeAll(" "); + + // Write node + switch (node) { + .leaf => try writer.print("leaf({d})", .{handle}), + .split => |s| { + try writer.print( + "split({s}, {d:.2})\n", + .{ @tagName(s.layout), s.ratio }, + ); + try self.formatNode(writer, s.left, depth + 1); + try writer.writeAll("\n"); + try self.formatNode(writer, s.right, depth + 1); + }, + } + } + }; +} + +const TestTree = SplitTree(TestView); + +const TestView = struct { + const Self = @This(); + + pub fn ref(self: *Self, alloc: Allocator) Allocator.Error!*Self { + const ptr = try alloc.create(Self); + ptr.* = self.*; + return ptr; + } + + pub fn unref(self: *Self, alloc: Allocator) void { + alloc.destroy(self); + } +}; + +test "SplitTree: empty tree" { + const testing = std.testing; + const alloc = testing.allocator; + var t: TestTree = .empty; + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\empty + ); +} + +test "SplitTree: single node" { + const testing = std.testing; + const alloc = testing.allocator; + var v: TestTree.View = .{}; + var t: TestTree = try .init(alloc, &v); + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\leaf(0) + ); +} + +test "SplitTree: split" { + const testing = std.testing; + const alloc = testing.allocator; + var v: TestTree.View = .{}; + + var t1: TestTree = try .init(alloc, &v); + defer t1.deinit(); + var t2: TestTree = try .init(alloc, &v); + defer t2.deinit(); + + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split(horizontal, 0.50) + \\ leaf(2) + \\ leaf(1) + ); +} From 5c30ac0e8e8dfb83a11a08e4a73a8a3904dc3053 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 15:59:42 -0700 Subject: [PATCH 22/83] apprt/gtk-ng: spatial tree --- src/apprt/gtk-ng/split_tree.zig | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/apprt/gtk-ng/split_tree.zig index 6d86ed902..121a103eb 100644 --- a/src/apprt/gtk-ng/split_tree.zig +++ b/src/apprt/gtk-ng/split_tree.zig @@ -191,6 +191,146 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes }; } + /// Spatial representation of the split tree. This can be used to + /// better understand the layout of the tree in a 2D space. + /// + /// The bounds of the representation are always based on each split + /// being exactly 1 unit wide and high. The x and y coordinates + /// are offsets into that space. This means that the spatial + /// representation is a normalized representation of the actual + /// space. + /// + /// The top-left corner of the tree is always (0, 0). + /// + /// We use a normalized form because we can calculate it without + /// accessing to the actual rendered view sizes. These actual sizes + /// may not be available at various times because GUI toolkits often + /// only make them available once they're part of a widget tree and + /// a SplitTree can represent views that aren't currently visible. + pub const Spatial = struct { + /// The slots of the spatial representation in the same order + /// as the tree it was created from. + slots: []const Slot, + + pub const empty: Spatial = .{ .slots = &.{} }; + + const Slot = struct { + x: f16, + y: f16, + width: f16, + height: f16, + }; + + pub fn deinit(self: *const Spatial, alloc: Allocator) void { + alloc.free(self.slots); + self.* = undefined; + } + }; + + /// Returns the spatial representation of this tree. See Spatial + /// for more details. + pub fn spatial( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Spatial { + // No nodes, empty spatial representation. + if (self.nodes.len == 0) return .empty; + + // Get our total dimensions. + const dim = self.dimensions(0); + + // Create our slots which will match our nodes exactly. + const slots = try alloc.alloc(Spatial.Slot, self.nodes.len); + errdefer alloc.free(slots); + slots[0] = .{ + .x = 0, + .y = 0, + .width = dim.width, + .height = dim.height, + }; + self.fillSpatialSlots(slots, 0); + + return .{ .slots = slots }; + } + + fn fillSpatialSlots( + self: *const Self, + slots: []Spatial.Slot, + current: Node.Handle, + ) void { + assert(slots[current].width > 0 and slots[current].height > 0); + + switch (self.nodes[current]) { + // Leaf node, current slot is already filled by caller. + .leaf => {}, + + .split => |s| { + switch (s.layout) { + .horizontal => { + slots[s.left] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width * s.ratio, + .height = slots[current].height, + }; + slots[s.right] = .{ + .x = slots[current].x + slots[current].width * s.ratio, + .y = slots[current].y, + .width = slots[current].width * (1 - s.ratio), + .height = slots[current].height, + }; + }, + + .vertical => { + slots[s.left] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width, + .height = slots[current].height * s.ratio, + }; + slots[s.right] = .{ + .x = slots[current].x, + .y = slots[current].y + slots[current].height * s.ratio, + .width = slots[current].width, + .height = slots[current].height * (1 - s.ratio), + }; + }, + } + + self.fillSpatialSlots(slots, s.left); + self.fillSpatialSlots(slots, s.right); + }, + } + } + + /// Get the dimensions of the tree starting from the given node. + /// + /// This creates relative dimensions (see Spatial) by assuming each + /// leaf is exactly 1x1 unit in size. + fn dimensions(self: *const Self, current: Node.Handle) struct { + width: u16, + height: u16, + } { + return switch (self.nodes[current]) { + .leaf => .{ .width = 1, .height = 1 }, + .split => |s| split: { + const left = self.dimensions(s.left); + const right = self.dimensions(s.right); + break :split switch (s.layout) { + .horizontal => .{ + .width = left.width + right.width, + .height = @max(left.height, right.height), + }, + + .vertical => .{ + .width = @max(left.width, right.width), + .height = left.height + right.height, + }, + }; + }, + }; + } + /// Format the tree in a human-readable format. /// /// NOTE: This is currently in node-order but we should change this From 52e264948d1d413dd8cbcd5da7293ea29bb59a0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 5 Aug 2025 16:36:38 -0700 Subject: [PATCH 23/83] apprt/gtk-ng: ASCII output for SplitTree --- src/apprt/gtk-ng/split_tree.zig | 164 ++++++++++++++++++++++++++------ 1 file changed, 133 insertions(+), 31 deletions(-) diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/apprt/gtk-ng/split_tree.zig index 121a103eb..275916e68 100644 --- a/src/apprt/gtk-ng/split_tree.zig +++ b/src/apprt/gtk-ng/split_tree.zig @@ -245,8 +245,8 @@ pub fn SplitTree(comptime V: type) type { slots[0] = .{ .x = 0, .y = 0, - .width = dim.width, - .height = dim.height, + .width = @floatFromInt(dim.width), + .height = @floatFromInt(dim.height), }; self.fillSpatialSlots(slots, 0); @@ -346,34 +346,101 @@ pub fn SplitTree(comptime V: type) type { if (self.nodes.len == 0) { try writer.writeAll("empty"); - } else { - try self.formatNode(writer, 0, 0); + return; } - } - fn formatNode( - self: *const Self, - writer: anytype, - handle: Node.Handle, - depth: usize, - ) !void { - const node = self.nodes[handle]; + // Use our arena's GPA to allocate some intermediate memory. + // Requiring allocation for formatting is nasty but this is really + // only used for debugging and testing and shouldn't hit OOM + // scenarios. + var arena: ArenaAllocator = .init(self.arena.child_allocator); + defer arena.deinit(); + const alloc = arena.allocator(); - // Write indentation - for (0..depth) |_| try writer.writeAll(" "); + // Get our spatial representation. + const sp = try self.spatial(alloc); - // Write node - switch (node) { - .leaf => try writer.print("leaf({d})", .{handle}), - .split => |s| { - try writer.print( - "split({s}, {d:.2})\n", - .{ @tagName(s.layout), s.ratio }, - ); - try self.formatNode(writer, s.left, depth + 1); - try writer.writeAll("\n"); - try self.formatNode(writer, s.right, depth + 1); - }, + // We need space for whitespace and ASCII art so add that. + // We need to accommodate the leaf handle, whitespace, and + // then the border. + const cell_width = cell_width: { + // The width we need for the largest label. + const max_label_width = std.math.log10(sp.slots.len) + 1; + + // Border + whitespace + label + whitespace + border. + break :cell_width 2 + max_label_width + 2; + }; + const cell_height = cell_height: { + // Border + label + border. No whitespace needed on the + // vertical axis. + break :cell_height 1 + 1 + 1; + }; + + // Make a grid that can fit our entire ASCII diagram. We know + // the width/height based on node 0. + const grid = grid: { + // Get our initial width/height. Each leaf is 1x1 in this. + var width: usize = @intFromFloat(@ceil(sp.slots[0].width)); + var height: usize = @intFromFloat(@ceil(sp.slots[0].height)); + + // We need space for whitespace and ASCII art so add that. + // We need to accommodate the leaf handle, whitespace, and + // then the border. + width *= cell_width; + height *= cell_height; + + const rows = try alloc.alloc([]u8, height); + for (0..rows.len) |y| { + rows[y] = try alloc.alloc(u8, width + 1); + @memset(rows[y], ' '); + rows[y][width] = '\n'; + } + break :grid rows; + }; + + // Draw each node + for (sp.slots, 0..) |slot, handle| { + var x: usize = @intFromFloat(@ceil(slot.x)); + var y: usize = @intFromFloat(@ceil(slot.y)); + var width: usize = @intFromFloat(@ceil(slot.width)); + var height: usize = @intFromFloat(@ceil(slot.height)); + x *= cell_width; + y *= cell_height; + width *= cell_width; + height *= cell_height; + + // Top border + { + const top = grid[y][x..][0..width]; + top[0] = '+'; + for (1..width - 1) |i| top[i] = '-'; + top[width - 1] = '+'; + } + + // Bottom border + { + const bottom = grid[y + height - 1][x..][0..width]; + bottom[0] = '+'; + for (1..width - 1) |i| bottom[i] = '-'; + bottom[width - 1] = '+'; + } + + // Left border + for (y + 1..y + height - 1) |y_cur| grid[y_cur][x] = '|'; + for (y + 1..y + height - 1) |y_cur| grid[y_cur][x + width - 1] = '|'; + + // Draw the handle in the center + const x_mid = width / 2 + x; + const y_mid = height / 2 + y; + const label_width = std.math.log10(handle + 1) + 1; + const label_start = x_mid - label_width / 2; + const row = grid[y_mid][label_start..]; + _ = try std.fmt.bufPrint(row, "{d}", .{handle}); + } + + // Output every row + for (grid) |row| { + try writer.writeAll(row); } } }; @@ -418,11 +485,14 @@ test "SplitTree: single node" { const str = try std.fmt.allocPrint(alloc, "{}", .{t}); defer alloc.free(str); try testing.expectEqualStrings(str, - \\leaf(0) + \\+---+ + \\| 0 | + \\+---+ + \\ ); } -test "SplitTree: split" { +test "SplitTree: split horizontal" { const testing = std.testing; const alloc = testing.allocator; var v: TestTree.View = .{}; @@ -443,8 +513,40 @@ test "SplitTree: split" { const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); defer alloc.free(str); try testing.expectEqualStrings(str, - \\split(horizontal, 0.50) - \\ leaf(2) - \\ leaf(1) + \\+---++---+ + \\| 2 || 1 | + \\+---++---+ + \\ + ); +} + +test "SplitTree: split vertical" { + const testing = std.testing; + const alloc = testing.allocator; + var v: TestTree.View = .{}; + + var t1: TestTree = try .init(alloc, &v); + defer t1.deinit(); + var t2: TestTree = try .init(alloc, &v); + defer t2.deinit(); + + var t3 = try t1.split( + alloc, + 0, // at root + .down, // split down + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| 2 | + \\+---+ + \\+---+ + \\| 1 | + \\+---+ + \\ ); } From 3e767c166c6f2b87105d6e48b48eeeed9df94cdf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 07:29:20 -0700 Subject: [PATCH 24/83] datastruct: split tree node removal --- src/apprt/gtk-ng.zig | 1 - src/datastruct/main.zig | 2 + .../gtk-ng => datastruct}/split_tree.zig | 384 +++++++++++++++++- 3 files changed, 367 insertions(+), 20 deletions(-) rename src/{apprt/gtk-ng => datastruct}/split_tree.zig (63%) diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index 8f0489b80..de9255fe9 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -7,7 +7,6 @@ pub const resourcesDir = internal_os.resourcesDir; // The exported API, custom for the apprt. pub const class = @import("gtk-ng/class.zig"); -pub const SplitTree = @import("gtk-ng/split_tree.zig").SplitTree; pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; test { diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 4f45f9483..14ee0e504 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -6,6 +6,7 @@ const cache_table = @import("cache_table.zig"); const circ_buf = @import("circ_buf.zig"); const intrusive_linked_list = @import("intrusive_linked_list.zig"); const segmented_pool = @import("segmented_pool.zig"); +const split_tree = @import("split_tree.zig"); pub const lru = @import("lru.zig"); pub const BlockingQueue = blocking_queue.BlockingQueue; @@ -13,6 +14,7 @@ pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; pub const SegmentedPool = segmented_pool.SegmentedPool; +pub const SplitTree = split_tree.SplitTree; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/apprt/gtk-ng/split_tree.zig b/src/datastruct/split_tree.zig similarity index 63% rename from src/apprt/gtk-ng/split_tree.zig rename to src/datastruct/split_tree.zig index 275916e68..69cb5201a 100644 --- a/src/apprt/gtk-ng/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -31,6 +31,12 @@ const Allocator = std.mem.Allocator; /// /// - `fn eql(*const View, *const View) bool` - Check if two views are equal. /// +/// Optionally the following functions can also be implemented: +/// +/// - `fn splitTreeLabel(*const View) []const u8` - Return a label that is used +/// for the debug view. If this isn't specified then the node handle +/// will be used. +/// pub fn SplitTree(comptime V: type) type { return struct { const Self = @This(); @@ -107,6 +113,38 @@ pub fn SplitTree(comptime V: type) type { self.* = undefined; } + /// An iterator over all the views in the tree. + pub fn iterator( + self: *const Self, + ) Iterator { + return .{ .nodes = self.nodes }; + } + + pub const Iterator = struct { + i: Node.Handle = 0, + nodes: []const Node, + + pub const Entry = struct { + handle: Node.Handle, + view: *View, + }; + + pub fn next(self: *Iterator) ?Entry { + // If we have no nodes, return null. + if (self.i >= self.nodes.len) return null; + + // Get the current node and increment the index. + const handle = self.i; + self.i += 1; + const node = self.nodes[handle]; + + return switch (node) { + .leaf => |v| .{ .handle = handle, .view = v }, + .split => self.next(), + }; + } + }; + /// Insert another tree into this tree at the given node in the /// specified direction. The other tree will be inserted in the /// new direction. For example, if the direction is "right" then @@ -169,6 +207,159 @@ pub fn SplitTree(comptime V: type) type { .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), } }; + // We need to increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ .arena = arena, .nodes = nodes }; + } + + /// Remove a node from the tree. + pub fn remove( + self: *Self, + gpa: Allocator, + at: Node.Handle, + ) Allocator.Error!Self { + assert(at < self.nodes.len); + + // If we're removing node zero then we're clearing the tree. + if (at == 0) return .empty; + + // The new arena for our new tree. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate our new nodes list with the number of nodes we'll + // need after the removal. + const nodes = try alloc.alloc(Node, self.countAfterRemoval( + 0, + at, + 0, + )); + + // Traverse the tree and copy all our nodes into place. + assert(self.removeNode( + nodes, + 0, + 0, + at, + ) > 0); + + // Increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + fn removeNode( + self: *Self, + nodes: []Node, + new_offset: Node.Handle, + current: Node.Handle, + target: Node.Handle, + ) Node.Handle { + assert(current != target); + + switch (self.nodes[current]) { + // Leaf is simple, just copy it over. We don't ref anything + // yet because it'd make undo (errdefer) harder. We do that + // all at once later. + .leaf => |view| { + nodes[new_offset] = .{ .leaf = view }; + return 1; + }, + + .split => |s| { + // If we're removing one of the split node sides then + // we remove the split node itself as well and only add + // the other (non-removed) side. + if (s.left == target) return self.removeNode( + nodes, + new_offset, + s.right, + target, + ); + if (s.right == target) return self.removeNode( + nodes, + new_offset, + s.left, + target, + ); + + // Neither side is being directly removed, so we traverse. + const left = self.removeNode( + nodes, + new_offset + 1, + s.left, + target, + ); + assert(left > 0); + const right = self.removeNode( + nodes, + new_offset + 1 + left, + s.right, + target, + ); + assert(right > 0); + nodes[new_offset] = .{ .split = .{ + .layout = s.layout, + .ratio = s.ratio, + .left = new_offset + 1, + .right = new_offset + 1 + left, + } }; + + return left + right + 1; + }, + } + } + + /// Returns the number of nodes that would be needed to store + /// the tree if the target node is removed. + fn countAfterRemoval( + self: *Self, + current: Node.Handle, + target: Node.Handle, + acc: usize, + ) usize { + assert(current != target); + + return switch (self.nodes[current]) { + // Leaf is simple, always takes one node. + .leaf => acc + 1, + + // Split is slightly more complicated. If either side is the + // target to remove, then we remove the split node as well + // so our count is just the count of the other side. + // + // If neither side is the target, then we count both sides + // and add one to account for the split node itself. + .split => |s| if (s.left == target) self.countAfterRemoval( + s.right, + target, + acc, + ) else if (s.right == target) self.countAfterRemoval( + s.left, + target, + acc, + ) else self.countAfterRemoval( + s.left, + target, + acc, + ) + self.countAfterRemoval( + s.right, + target, + acc, + ) + 1, + }; + } + + /// Reference all the nodes in the given slice, handling unref if + /// any fail. This should be called LAST so you don't have to undo + /// the refs at any further point after this. + fn refNodes(gpa: Allocator, nodes: []Node) Allocator.Error!void { // We need to increase the reference count of all the nodes. // Careful accounting here so that we properly unref on error // only the nodes we referenced. @@ -187,8 +378,6 @@ pub fn SplitTree(comptime V: type) type { reffed = i; } assert(reffed == nodes.len - 1); - - return .{ .arena = arena, .nodes = nodes }; } /// Spatial representation of the split tree. This can be used to @@ -360,13 +549,28 @@ pub fn SplitTree(comptime V: type) type { // Get our spatial representation. const sp = try self.spatial(alloc); + // The width we need for the largest label. + const max_label_width: usize = max_label_width: { + if (!@hasDecl(View, "splitTreeLabel")) { + break :max_label_width std.math.log10(sp.slots.len) + 1; + } + + var max: usize = 0; + for (self.nodes) |node| switch (node) { + .split => {}, + .leaf => |view| { + const label = view.splitTreeLabel(); + max = @max(max, label.len); + }, + }; + + break :max_label_width max; + }; + // We need space for whitespace and ASCII art so add that. // We need to accommodate the leaf handle, whitespace, and // then the border. const cell_width = cell_width: { - // The width we need for the largest label. - const max_label_width = std.math.log10(sp.slots.len) + 1; - // Border + whitespace + label + whitespace + border. break :cell_width 2 + max_label_width + 2; }; @@ -400,6 +604,13 @@ pub fn SplitTree(comptime V: type) type { // Draw each node for (sp.slots, 0..) |slot, handle| { + // We only draw leaf nodes. Splits are only used for layout. + const node = self.nodes[handle]; + switch (node) { + .leaf => {}, + .split => continue, + } + var x: usize = @intFromFloat(@ceil(slot.x)); var y: usize = @intFromFloat(@ceil(slot.y)); var width: usize = @intFromFloat(@ceil(slot.width)); @@ -429,13 +640,20 @@ pub fn SplitTree(comptime V: type) type { for (y + 1..y + height - 1) |y_cur| grid[y_cur][x] = '|'; for (y + 1..y + height - 1) |y_cur| grid[y_cur][x + width - 1] = '|'; + // Get our label text + var buf: [10]u8 = undefined; + const label: []const u8 = if (@hasDecl(View, "splitTreeLabel")) + node.leaf.splitTreeLabel() + else + try std.fmt.bufPrint(&buf, "{d}", .{handle}); + // Draw the handle in the center const x_mid = width / 2 + x; const y_mid = height / 2 + y; - const label_width = std.math.log10(handle + 1) + 1; + const label_width = label.len; const label_start = x_mid - label_width / 2; const row = grid[y_mid][label_start..]; - _ = try std.fmt.bufPrint(row, "{d}", .{handle}); + _ = try std.fmt.bufPrint(row, "{s}", .{label}); } // Output every row @@ -451,6 +669,8 @@ const TestTree = SplitTree(TestView); const TestView = struct { const Self = @This(); + label: []const u8, + pub fn ref(self: *Self, alloc: Allocator) Allocator.Error!*Self { const ptr = try alloc.create(Self); ptr.* = self.*; @@ -460,6 +680,10 @@ const TestView = struct { pub fn unref(self: *Self, alloc: Allocator) void { alloc.destroy(self); } + + pub fn splitTreeLabel(self: *const Self) []const u8 { + return self.label; + } }; test "SplitTree: empty tree" { @@ -478,7 +702,7 @@ test "SplitTree: empty tree" { test "SplitTree: single node" { const testing = std.testing; const alloc = testing.allocator; - var v: TestTree.View = .{}; + var v: TestTree.View = .{ .label = "A" }; var t: TestTree = try .init(alloc, &v); defer t.deinit(); @@ -486,7 +710,7 @@ test "SplitTree: single node" { defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ - \\| 0 | + \\| A | \\+---+ \\ ); @@ -495,11 +719,11 @@ test "SplitTree: single node" { test "SplitTree: split horizontal" { const testing = std.testing; const alloc = testing.allocator; - var v: TestTree.View = .{}; - - var t1: TestTree = try .init(alloc, &v); + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); defer t1.deinit(); - var t2: TestTree = try .init(alloc, &v); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); defer t2.deinit(); var t3 = try t1.split( @@ -514,7 +738,7 @@ test "SplitTree: split horizontal" { defer alloc.free(str); try testing.expectEqualStrings(str, \\+---++---+ - \\| 2 || 1 | + \\| A || B | \\+---++---+ \\ ); @@ -523,11 +747,12 @@ test "SplitTree: split horizontal" { test "SplitTree: split vertical" { const testing = std.testing; const alloc = testing.allocator; - var v: TestTree.View = .{}; - var t1: TestTree = try .init(alloc, &v); + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); defer t1.deinit(); - var t2: TestTree = try .init(alloc, &v); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); defer t2.deinit(); var t3 = try t1.split( @@ -542,11 +767,132 @@ test "SplitTree: split vertical" { defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ - \\| 2 | + \\| A | \\+---+ \\+---+ - \\| 1 | + \\| B | \\+---+ \\ ); } + +test "SplitTree: remove leaf" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer t3.deinit(); + + // Remove "A" + var it = t3.iterator(); + var t4 = try t3.remove( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound, + ); + defer t4.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t4}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); +} + +test "SplitTree: split twice, remove intermediary" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var v3: TestTree.View = .{ .label = "C" }; + var t3: TestTree = try .init(alloc, &v3); + defer t3.deinit(); + + // A | B horizontal. + var split1 = try t1.split( + alloc, + 0, // at root + .right, // split right + &t2, // insert t2 + ); + defer split1.deinit(); + + // Insert C below that. + var split2 = try split1.split( + alloc, + 0, // at root + .down, // split down + &t3, // insert t3 + ); + defer split2.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{split2}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\+--------+ + \\| C | + \\+--------+ + \\ + ); + } + + // Remove "B" + var it = split2.iterator(); + var split3 = try split2.remove( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound, + ); + defer split3.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{split3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\+---+ + \\| C | + \\+---+ + \\ + ); + } + + // Remove every node from split2 (our most complex one), which should + // never crash. We don't test the result is correct, this just verifies + // we don't hit any assertion failures. + for (0..split2.nodes.len) |i| { + var t = try split2.remove(alloc, @intCast(i)); + t.deinit(); + } +} From ad1cfe8347144575e65f590cdd6ca83badc4dd21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 09:19:32 -0700 Subject: [PATCH 25/83] remove outdated comment --- src/datastruct/split_tree.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 69cb5201a..9cd929faa 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -521,9 +521,6 @@ pub fn SplitTree(comptime V: type) type { } /// Format the tree in a human-readable format. - /// - /// NOTE: This is currently in node-order but we should change this - /// to spatial ASCII drawings once we have better support for that. pub fn format( self: *const Self, comptime fmt: []const u8, From fa08434b28580229c39082dc4f1de6731ed0ed3b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 09:52:41 -0700 Subject: [PATCH 26/83] apprt/gtk-ng: initial GhosttySplitTree widget --- src/apprt/gtk-ng/build/gresource.zig | 1 + src/apprt/gtk-ng/class/split_tree.zig | 157 +++++++++++++++++++++++++ src/apprt/gtk-ng/class/surface.zig | 4 + src/apprt/gtk-ng/class/tab.zig | 2 + src/apprt/gtk-ng/ui/1.5/split-tree.blp | 16 +++ src/apprt/gtk-ng/ui/1.5/tab.blp | 3 + src/datastruct/split_tree.zig | 37 +++++- 7 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/apprt/gtk-ng/class/split_tree.zig create mode 100644 src/apprt/gtk-ng/ui/1.5/split-tree.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f5b91ce48..0f7237331 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -40,6 +40,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "debug-warning" }, .{ .major = 1, .minor = 3, .name = "debug-warning" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig new file mode 100644 index 000000000..968dbaa88 --- /dev/null +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); +const assert = std.debug.assert; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const input = @import("../../../input.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const gtk_version = @import("../gtk_version.zig"); +const adw_version = @import("../adw_version.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; + +const log = std.log.scoped(.gtk_ghostty_split_tree); + +pub const SplitTree = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTree", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const @"is-empty" = struct { + pub const name = "is-empty"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Tree Is Empty", + .blurb = "True when the tree has no surfaces.", + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsEmpty, + }, + ), + }, + ); + }; + }; + + const Private = struct { + /// The tree datastructure containing all of our surface views. + tree: Surface.Tree, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Start with an empty split tree. + const priv = self.private(); + priv.tree = .empty; + } + + //--------------------------------------------------------------- + // Properties + + pub fn getIsEmpty(self: *Self) bool { + const priv = self.private(); + return priv.tree.isEmpty(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + priv.tree.deinit(); + priv.tree = .empty; + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal handlers + + //--------------------------------------------------------------- + // Class + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(Surface); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"is-empty".impl, + }); + + // Bindings + + // Template Callbacks + + // Signals + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 4251b56a8..f26783747 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -9,6 +9,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); +const datastruct = @import("../../../datastruct/main.zig"); const font = @import("../../../font/main.zig"); const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); @@ -42,6 +43,9 @@ pub const Surface = extern struct { .private = .{ .Type = Private, .offset = &Private.offset }, }); + /// A SplitTree implementation that stores surfaces. + pub const Tree = datastruct.SplitTree(Self); + pub const properties = struct { pub const config = struct { pub const name = "config"; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 3aa41c5ff..b343ba248 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -18,6 +18,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const log = std.log.scoped(.gtk_ghostty_window); @@ -251,6 +252,7 @@ pub const Tab = extern struct { pub const Instance = Self; fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(SplitTree); gobject.ext.ensureType(Surface); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp new file mode 100644 index 000000000..0eebff7a6 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTree: Adw.Bin { + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.is-empty; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 476244576..d1c7737f6 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,6 +5,7 @@ template $GhosttyTab: Box { "tab", ] + orientation: vertical; hexpand: true; vexpand: true; // A tab currently just contains a surface directly. When we introduce @@ -12,4 +13,6 @@ template $GhosttyTab: Box { $GhosttySurface surface { close-request => $surface_close_request(); } + + $GhosttySplitTree {} } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 9cd929faa..759387073 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -37,6 +37,9 @@ const Allocator = std.mem.Allocator; /// for the debug view. If this isn't specified then the node handle /// will be used. /// +/// Note: for both the ref and unref functions, the allocator is optional. +/// If the functions take less arguments, then the allocator will not be +/// passed. pub fn SplitTree(comptime V: type) type { return struct { const Self = @This(); @@ -88,8 +91,8 @@ pub fn SplitTree(comptime V: type) type { const alloc = arena.allocator(); const nodes = try alloc.alloc(Node, 1); - nodes[0] = .{ .leaf = try view.ref(gpa) }; - errdefer view.unref(gpa); + nodes[0] = .{ .leaf = try viewRef(view, gpa) }; + errdefer viewUnref(view, gpa); return .{ .arena = arena, @@ -104,7 +107,7 @@ pub fn SplitTree(comptime V: type) type { // Unref all our views const gpa: Allocator = self.arena.child_allocator; for (self.nodes) |node| switch (node) { - .leaf => |view| view.unref(gpa), + .leaf => |view| viewUnref(view, gpa), .split => {}, }; self.arena.deinit(); @@ -113,6 +116,12 @@ pub fn SplitTree(comptime V: type) type { self.* = undefined; } + /// Returns true if this is an empty tree. + pub fn isEmpty(self: *const Self) bool { + // An empty tree has no nodes. + return self.nodes.len == 0; + } + /// An iterator over all the views in the tree. pub fn iterator( self: *const Self, @@ -367,13 +376,13 @@ pub fn SplitTree(comptime V: type) type { errdefer for (0..reffed) |i| { switch (nodes[i]) { .split => {}, - .leaf => |view| view.unref(gpa), + .leaf => |view| viewUnref(view, gpa), } }; for (0..nodes.len) |i| { switch (nodes[i]) { .split => {}, - .leaf => |view| nodes[i] = .{ .leaf = try view.ref(gpa) }, + .leaf => |view| nodes[i] = .{ .leaf = try viewRef(view, gpa) }, } reffed = i; } @@ -658,6 +667,24 @@ pub fn SplitTree(comptime V: type) type { try writer.writeAll(row); } } + + fn viewRef(view: *View, gpa: Allocator) Allocator.Error!*View { + const func = @typeInfo(@TypeOf(View.ref)).@"fn"; + return switch (func.params.len) { + 1 => view.ref(), + 2 => try view.ref(gpa), + else => @compileError("invalid view ref function"), + }; + } + + fn viewUnref(view: *View, gpa: Allocator) void { + const func = @typeInfo(@TypeOf(View.unref)).@"fn"; + switch (func.params.len) { + 1 => view.unref(), + 2 => view.unref(gpa), + else => @compileError("invalid view unref function"), + } + } }; } From 70b050ebb469ea21d8b1c37bd5ffe68fceb87c35 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 10:34:25 -0700 Subject: [PATCH 27/83] apprt/gtk-ng: setup split tree property --- src/apprt/gtk-ng/class.zig | 6 ++- src/apprt/gtk-ng/class/split_tree.zig | 41 ++++++++++++---- src/apprt/gtk-ng/ui/1.5/split-tree.blp | 33 +++++++++---- src/datastruct/split_tree.zig | 65 ++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 170df1acb..a22b8771b 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -5,6 +5,7 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const ext = @import("ext.zig"); pub const Application = @import("class/application.zig").Application; pub const Window = @import("class/window.zig").Window; pub const Config = @import("class/config.zig").Config; @@ -79,7 +80,10 @@ pub fn Common( fn set(self: *Self, value: *const gobject.Value) void { const priv = private(self); if (@field(priv, name)) |v| { - glib.ext.destroy(v); + ext.boxedFree( + @typeInfo(@TypeOf(v)).pointer.child, + v, + ); } const T = @TypeOf(@field(priv, name)); diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 968dbaa88..38f3d3536 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -13,6 +13,7 @@ const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); const gtk_version = @import("../gtk_version.zig"); const adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; @@ -55,29 +56,39 @@ pub const SplitTree = extern struct { }, ); }; + + pub const tree = struct { + pub const name = "tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .nick = "Tree Model", + .blurb = "Underlying data model for the tree.", + .accessor = C.privateBoxedFieldAccessor("tree"), + }, + ); + }; }; const Private = struct { /// The tree datastructure containing all of our surface views. - tree: Surface.Tree, + tree: ?*Surface.Tree, pub var offset: c_int = 0; }; fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); - - // Start with an empty split tree. - const priv = self.private(); - priv.tree = .empty; } //--------------------------------------------------------------- // Properties pub fn getIsEmpty(self: *Self) bool { - const priv = self.private(); - return priv.tree.isEmpty(); + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return tree.isEmpty(); } //--------------------------------------------------------------- @@ -97,8 +108,10 @@ pub const SplitTree = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); - priv.tree.deinit(); - priv.tree = .empty; + if (priv.tree) |tree| { + ext.boxedFree(Surface.Tree, tree); + priv.tree = null; + } gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -109,6 +122,14 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Signal handlers + fn propTree( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"is-empty".impl.param_spec); + } + //--------------------------------------------------------------- // Class @@ -137,11 +158,13 @@ pub const SplitTree = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.@"is-empty".impl, + properties.tree.impl, }); // Bindings // Template Callbacks + class.bindTemplateCallback("notify_tree", &propTree); // Signals diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp index 0eebff7a6..66053fd3d 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -2,15 +2,28 @@ using Gtk 4.0; using Adw 1; template $GhosttySplitTree: Adw.Bin { - // This could be a lot more visually pleasing but in practice this doesn't - // ever happen at the time of writing this comment. A surface-less split - // tree always closes its parent. - Label { - visible: bind template.is-empty; - // Purposely not localized currently because this shouldn't really - // ever appear. When we have a situation it does appear, we may want - // to change the styling and text so I don't want to burden localizers - // to handle this yet. - label: "No surfaces."; + notify::tree => $notify_tree(); + + Box { + orientation: vertical; + + Box surface_box { + visible: bind template.is-empty inverted; + orientation: vertical; + hexpand: true; + vexpand: true; + } + + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.is-empty; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } } } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 759387073..23e9eae0c 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1,5 +1,6 @@ const std = @import("std"); const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); const ArenaAllocator = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; @@ -116,6 +117,25 @@ pub fn SplitTree(comptime V: type) type { self.* = undefined; } + /// Clone this tree, returning a new tree with the same nodes. + pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self { + // Create a new arena allocator for the clone. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate a new nodes array and copy the existing nodes into it. + const nodes = try alloc.dupe(Node, self.nodes); + + // Increase the reference count of all the views in the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + /// Returns true if this is an empty tree. pub fn isEmpty(self: *const Self) bool { // An empty tree has no nodes. @@ -685,6 +705,51 @@ pub fn SplitTree(comptime V: type) type { else => @compileError("invalid view unref function"), } } + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + Self, + .{ + // To get the type name we get the non-qualified type name + // of the view and append that to `GhosttySplitTree`. + .name = name: { + const type_name = @typeName(View); + const last = if (std.mem.lastIndexOfScalar( + u8, + type_name, + '.', + )) |idx| + type_name[idx + 1 ..] + else + type_name; + assert(last.len > 0); + break :name "GhosttySplitTree" ++ last; + }, + + .funcs = .{ + // The @ptrCast below is to workaround this bug: + // https://github.com/ianprime0509/zig-gobject/issues/115 + .copy = @ptrCast(&struct { + fn copy(self: *Self) callconv(.c) *Self { + const ptr = @import("glib").ext.create(Self); + const alloc = self.arena.child_allocator; + ptr.* = self.clone(alloc) catch @panic("oom"); + return ptr; + } + }.copy), + .free = @ptrCast(&struct { + fn free(self: *Self) callconv(.c) void { + self.deinit(); + @import("glib").ext.destroy(self); + } + }.free), + }, + }, + ), + + .none => void, + }; }; } From a7865d79ea2e8df1a2fd4e3bba2839a61cee41c4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 10:53:18 -0700 Subject: [PATCH 28/83] apprt/gtk-ng: render a single artificial split --- src/apprt/gtk-ng/class/split_tree.zig | 112 ++++++++++++++++++++++++- src/apprt/gtk-ng/class/surface.zig | 1 + src/apprt/gtk-ng/class/tab.zig | 12 +++ src/apprt/gtk-ng/ui/1.5/split-tree.blp | 3 +- src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 38f3d3536..3cd03e810 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -66,16 +66,39 @@ pub const SplitTree = extern struct { .{ .nick = "Tree Model", .blurb = "Underlying data model for the tree.", - .accessor = C.privateBoxedFieldAccessor("tree"), + .accessor = .{ + .getter = getTreeValue, + .setter = setTreeValue, + }, }, ); }; }; + pub const signals = struct { + /// Emitted whenever the tree property is about to change. + /// + /// The new value is given as the signal parameter. The old value + /// can still be retrieved from the tree property. + pub const @"tree-will-change" = struct { + pub const name = "tree-change"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{?*const Surface.Tree}, + void, + ); + }; + }; + const Private = struct { /// The tree datastructure containing all of our surface views. tree: ?*Surface.Tree, + // Template bindings + tree_bin: *adw.Bin, + pub var offset: c_int = 0; }; @@ -91,6 +114,52 @@ pub const SplitTree = extern struct { return tree.isEmpty(); } + /// Get the tree data model that we're showing in this widget. This + /// does not clone the tree. + pub fn getTree(self: *Self) ?*Surface.Tree { + return self.private().tree; + } + + /// Set the tree data model that we're showing in this widget. This + /// will clone the given tree. + pub fn setTree(self: *Self, tree: ?*const Surface.Tree) void { + const priv = self.private(); + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + signals.@"tree-will-change".impl.emit( + self, + null, + .{tree}, + null, + ); + + if (priv.tree) |old_tree| { + ext.boxedFree(Surface.Tree, old_tree); + priv.tree = null; + } + + if (tree) |new_tree| { + priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn getTreeValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().tree, + ); + } + + fn setTreeValue(self: *Self, value: *const gobject.Value) void { + self.setTree(gobject.ext.Value.get( + value, + ?*Surface.Tree, + )); + } + //--------------------------------------------------------------- // Virtual methods @@ -127,9 +196,48 @@ pub const SplitTree = extern struct { _: *gobject.ParamSpec, _: ?*anyopaque, ) callconv(.c) void { + const priv = self.private(); + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + + // Reset our widget tree. + priv.tree_bin.setChild(null); + if (!tree.isEmpty()) { + priv.tree_bin.setChild(buildTree(tree, 0)); + } + + // Dependent properties self.as(gobject.Object).notifyByPspec(properties.@"is-empty".impl.param_spec); } + /// Builds the widget tree associated with a surface split tree. + /// + /// The final returned widget is expected to be a floating reference, + /// ready to be attached to a parent widget. + fn buildTree( + tree: *const Surface.Tree, + current: Surface.Tree.Node.Handle, + ) *gtk.Widget { + switch (tree.nodes[current]) { + .leaf => |v| { + // We have to setup our signal handlers. + return v.as(gtk.Widget); + }, + + .split => |s| return gobject.ext.newInstance( + gtk.Paned, + .{ + .orientation = @as(gtk.Orientation, switch (s.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }), + .@"start-child" = buildTree(tree, s.left), + .@"end-child" = buildTree(tree, s.right), + // TODO: position/ratio + }, + ).as(gtk.Widget), + } + } + //--------------------------------------------------------------- // Class @@ -162,11 +270,13 @@ pub const SplitTree = extern struct { }); // Bindings + class.bindTemplateChildPrivate("tree_bin", .{}); // Template Callbacks class.bindTemplateCallback("notify_tree", &propTree); // Signals + signals.@"tree-will-change".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index f26783747..35b4eaf88 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -2302,6 +2302,7 @@ pub const Surface = extern struct { const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; + pub const refSink = C.refSink; pub const unref = C.unref; const private = C.private; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index b343ba248..0619b2de8 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -118,6 +118,7 @@ pub const Tab = extern struct { surface_bindings: *gobject.BindingGroup, // Template bindings + split_tree: *SplitTree, surface: *Surface, pub var offset: c_int = 0; @@ -161,6 +162,16 @@ pub const Tab = extern struct { // We need to do this so that the title initializes properly, // I think because its a dynamic getter. self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + + // Setup our initial split tree. + // TODO: Probably make this a property + const surface: *Surface = .new(); + defer surface.unref(); + _ = surface.refSink(); + const alloc = Application.default().allocator(); + var tree = Surface.Tree.init(alloc, surface) catch unreachable; + defer tree.deinit(); + priv.split_tree.setTree(&tree); } //--------------------------------------------------------------- @@ -271,6 +282,7 @@ pub const Tab = extern struct { }); // Bindings + class.bindTemplateChildPrivate("split_tree", .{}); class.bindTemplateChildPrivate("surface", .{}); // Template Callbacks diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp index 66053fd3d..2ce4b3f10 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -7,9 +7,8 @@ template $GhosttySplitTree: Adw.Bin { Box { orientation: vertical; - Box surface_box { + Adw.Bin tree_bin { visible: bind template.is-empty inverted; - orientation: vertical; hexpand: true; vexpand: true; } diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index d1c7737f6..fc20962e4 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -14,5 +14,5 @@ template $GhosttyTab: Box { close-request => $surface_close_request(); } - $GhosttySplitTree {} + $GhosttySplitTree split_tree {} } From 3b4c33afe08024870dcf521532451b0cadb5d321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 12:41:57 -0700 Subject: [PATCH 29/83] apprt/gtk-ng: connect surface signals --- src/apprt/gtk-ng/class/split_tree.zig | 2 +- src/apprt/gtk-ng/class/tab.zig | 42 +++++ src/apprt/gtk-ng/class/window.zig | 213 ++++++++++++++++---------- src/apprt/gtk-ng/ui/1.5/tab.blp | 4 +- 4 files changed, 181 insertions(+), 80 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 3cd03e810..7e3f7d92b 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -81,7 +81,7 @@ pub const SplitTree = extern struct { /// The new value is given as the signal parameter. The old value /// can still be retrieved from the tree property. pub const @"tree-will-change" = struct { - pub const name = "tree-change"; + pub const name = "tree-will-change"; pub const connect = impl.connect; const impl = gobject.ext.defineSignal( name, diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 0619b2de8..b8711873f 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -74,6 +74,26 @@ pub const Tab = extern struct { ); }; + pub const @"surface-tree" = struct { + pub const name = "surface-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .nick = "Surface Tree", + .blurb = "The surface tree that is contained in this tab.", + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface.Tree, + .{ + .getter = getSurfaceTree, + }, + ), + }, + ); + }; + pub const title = struct { pub const name = "title"; pub const get = impl.get; @@ -184,6 +204,18 @@ pub const Tab = extern struct { return priv.surface; } + /// Get the surface tree of this tab. + pub fn getSurfaceTree(self: *Self) ?*Surface.Tree { + const priv = self.private(); + return priv.split_tree.getTree(); + } + + /// Get the split tree widget that is in this tab. + pub fn getSplitTree(self: *Self) *SplitTree { + const priv = self.private(); + return priv.split_tree; + } + /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { @@ -251,6 +283,14 @@ pub const Tab = extern struct { } } + fn propSplitTree( + _: *SplitTree, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -278,6 +318,7 @@ pub const Tab = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"surface-tree".impl, properties.title.impl, }); @@ -287,6 +328,7 @@ pub const Tab = extern struct { // Template Callbacks class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + class.bindTemplateCallback("notify_tree", &propSplitTree); // Signals signals.@"close-request".impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 6881ee052..3d7143953 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -22,6 +22,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; @@ -408,6 +409,24 @@ pub const Window = extern struct { .{ .sync_create = true }, ); + // Bind signals + const split_tree = tab.getSplitTree(); + _ = SplitTree.signals.@"tree-will-change".connect( + split_tree, + *Self, + tabSplitTreeWillChange, + self, + .{}, + ); + + // Run an initial notification for the surface tree so we can setup + // initial state. + tabSplitTreeWillChange( + split_tree, + split_tree.getTree(), + self, + ); + return page; } @@ -637,6 +656,102 @@ pub const Window = extern struct { self.private().toast_overlay.addToast(toast); } + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + const priv = self.private(); + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = Surface.signals.@"present-request".connect( + surface, + *Self, + surfacePresentRequest, + self, + .{}, + ); + _ = Surface.signals.@"clipboard-write".connect( + surface, + *Self, + surfaceClipboardWrite, + self, + .{}, + ); + _ = Surface.signals.menu.connect( + surface, + *Self, + surfaceMenu, + self, + .{}, + ); + _ = Surface.signals.@"toggle-fullscreen".connect( + surface, + *Self, + surfaceToggleFullscreen, + self, + .{}, + ); + _ = Surface.signals.@"toggle-maximize".connect( + surface, + *Self, + surfaceToggleMaximize, + self, + .{}, + ); + _ = Surface.signals.@"toggle-command-palette".connect( + surface, + *Self, + surfaceToggleCommandPalette, + self, + .{}, + ); + + // If we've never had a surface initialize yet, then we register + // this signal. Its theoretically possible to launch multiple surfaces + // before init so we could register this on multiple and that is not + // a problem because we'll check the flag again in each handler. + if (!priv.surface_init) { + _ = Surface.signals.init.connect( + surface, + *Self, + surfaceInit, + self, + .{}, + ); + } + } + } + + /// Disconnect all the surface handlers for the given tree. This should + /// be called whenever a tree is no longer present in the window, e.g. + /// when a tab is detached or the tree changes. + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + //--------------------------------------------------------------- // Properties @@ -1134,8 +1249,6 @@ pub const Window = extern struct { _: c_int, self: *Self, ) callconv(.c) void { - const priv = self.private(); - // Get the attached page which must be a Tab object. const child = page.getChild(); const tab = gobject.ext.cast(Tab, child) orelse return; @@ -1168,71 +1281,8 @@ pub const Window = extern struct { // behavior is consistent with macOS and the previous GTK apprt, // but that behavior was all implicit and not documented, so here // I am. - // - // TODO: When we have a split tree we'll want to attach to that. - const surface = tab.getActiveSurface(); - _ = Surface.signals.@"close-request".connect( - surface, - *Self, - surfaceCloseRequest, - self, - .{}, - ); - _ = Surface.signals.@"present-request".connect( - surface, - *Self, - surfacePresentRequest, - self, - .{}, - ); - _ = Surface.signals.@"clipboard-write".connect( - surface, - *Self, - surfaceClipboardWrite, - self, - .{}, - ); - _ = Surface.signals.menu.connect( - surface, - *Self, - surfaceMenu, - self, - .{}, - ); - _ = Surface.signals.@"toggle-fullscreen".connect( - surface, - *Self, - surfaceToggleFullscreen, - self, - .{}, - ); - _ = Surface.signals.@"toggle-maximize".connect( - surface, - *Self, - surfaceToggleMaximize, - self, - .{}, - ); - _ = Surface.signals.@"toggle-command-palette".connect( - surface, - *Self, - surfaceToggleCommandPalette, - self, - .{}, - ); - - // If we've never had a surface initialize yet, then we register - // this signal. Its theoretically possible to launch multiple surfaces - // before init so we could register this on multiple and that is not - // a problem because we'll check the flag again in each handler. - if (!priv.surface_init) { - _ = Surface.signals.init.connect( - surface, - *Self, - surfaceInit, - self, - .{}, - ); + if (tab.getSurfaceTree()) |tree| { + self.connectSurfaceHandlers(tree); } } @@ -1255,17 +1305,10 @@ pub const Window = extern struct { self, ); - // Remove all the signals that have this window as the userdata. - const surface = tab.getActiveSurface(); - _ = gobject.signalHandlersDisconnectMatched( - surface.as(gobject.Object), - .{ .data = true }, - 0, - 0, - null, - null, - self, - ); + // Remove the tree handlers + if (tab.getSurfaceTree()) |tree| { + self.disconnectSurfaceHandlers(tree); + } } fn tabViewCreateWindow( @@ -1464,6 +1507,20 @@ pub const Window = extern struct { } } + fn tabSplitTreeWillChange( + split_tree: *SplitTree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (split_tree.getTree()) |old_tree| { + self.disconnectSurfaceHandlers(old_tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + fn actionAbout( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index fc20962e4..8e6aee6cf 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -14,5 +14,7 @@ template $GhosttyTab: Box { close-request => $surface_close_request(); } - $GhosttySplitTree split_tree {} + $GhosttySplitTree split_tree { + notify::tree => $notify_tree(); + } } From bc731c0ff68170480e167c7ef6c36f62c1532f37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 13:48:50 -0700 Subject: [PATCH 30/83] apprt/gtk-ng: hook up Tab signals to surface --- src/apprt/gtk-ng/class/surface.zig | 7 ++ src/apprt/gtk-ng/class/tab.zig | 110 ++++++++++++++++++++++++----- src/apprt/gtk-ng/ui/1.5/tab.blp | 7 +- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 35b4eaf88..383c3b084 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1318,6 +1318,11 @@ pub const Surface = extern struct { return self.private().pwd; } + /// Returns the focus state of this surface. + pub fn getFocused(self: *Self) bool { + return self.private().focused; + } + /// Change the configuration for this surface. pub fn setConfig(self: *Self, config: *Config) void { const priv = self.private(); @@ -1654,6 +1659,7 @@ pub const Surface = extern struct { priv.focused = true; priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1661,6 +1667,7 @@ pub const Surface = extern struct { priv.focused = false; priv.im_context.as(gtk.IMContext).focusOut(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } /// The focus callback must be triggered on an idle loop source because diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index b8711873f..034dd25f6 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -139,7 +139,6 @@ pub const Tab = extern struct { // Template bindings split_tree: *SplitTree, - surface: *Surface, pub var offset: c_int = 0; }; @@ -147,12 +146,10 @@ pub const Tab = extern struct { /// Set the parent of this tab page. This only affects the first surface /// ever created for a tab. If a surface was already created this does /// nothing. - pub fn setParent( - self: *Self, - parent: *CoreSurface, - ) void { - const priv = self.private(); - priv.surface.setParent(parent); + pub fn setParent(self: *Self, parent: *CoreSurface) void { + if (self.getActiveSurface()) |surface| { + surface.setParent(parent); + } } fn init(self: *Self, _: *Class) callconv(.c) void { @@ -175,10 +172,6 @@ pub const Tab = extern struct { .{}, ); - // TODO: Eventually this should be set dynamically based on the - // current active surface. - priv.surface_bindings.setSource(priv.surface.as(gobject.Object)); - // We need to do this so that the title initializes properly, // I think because its a dynamic getter. self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); @@ -194,14 +187,62 @@ pub const Tab = extern struct { priv.split_tree.setTree(&tree); } + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceFocused, + self, + .{ .detail = "focused" }, + ); + } + } + + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + //--------------------------------------------------------------- // Properties /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - pub fn getActiveSurface(self: *Self) *Surface { - const priv = self.private(); - return priv.surface; + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getSurfaceTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.view; + } + + return null; } /// Get the surface tree of this tab. @@ -219,7 +260,7 @@ pub const Tab = extern struct { /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { - const surface = self.getActiveSurface(); + const surface = self.getActiveSurface() orelse return false; const core_surface = surface.core() orelse return false; return core_surface.needsConfirmQuit(); } @@ -283,6 +324,20 @@ pub const Tab = extern struct { } } + fn splitTreeWillChange( + split_tree: *SplitTree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (split_tree.getTree()) |old_tree| { + self.disconnectSurfaceHandlers(old_tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + fn propSplitTree( _: *SplitTree, _: *gobject.ParamSpec, @@ -291,6 +346,27 @@ pub const Tab = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); } + fn propActiveSurface( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.surface_bindings.setSource(null); + if (self.getActiveSurface()) |surface| { + priv.surface_bindings.setSource(surface.as(gobject.Object)); + } + } + + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + if (!surface.getFocused()) return; + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -324,10 +400,10 @@ pub const Tab = extern struct { // Bindings class.bindTemplateChildPrivate("split_tree", .{}); - class.bindTemplateChildPrivate("surface", .{}); // Template Callbacks - class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + class.bindTemplateCallback("tree_will_change", &splitTreeWillChange); + class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); // Signals diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 8e6aee6cf..f0d7f5f68 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,16 +5,13 @@ template $GhosttyTab: Box { "tab", ] + notify::active-surface => $notify_active_surface(); orientation: vertical; hexpand: true; vexpand: true; - // A tab currently just contains a surface directly. When we introduce - // splits we probably want to replace this with the split widget type. - $GhosttySurface surface { - close-request => $surface_close_request(); - } $GhosttySplitTree split_tree { notify::tree => $notify_tree(); + tree-will-change => $tree_will_change(); } } From 4a4577cf8a61bd7aa2e4fe89d61fd0b86a781dae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 6 Aug 2025 13:57:14 -0700 Subject: [PATCH 31/83] apprt/gtk-ng: address some TODOs --- src/apprt/gtk-ng/class/tab.zig | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 034dd25f6..c60b01bfa 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -172,19 +172,23 @@ pub const Tab = extern struct { .{}, ); - // We need to do this so that the title initializes properly, - // I think because its a dynamic getter. - self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); - - // Setup our initial split tree. - // TODO: Probably make this a property + // A tab always starts with a single surface. const surface: *Surface = .new(); defer surface.unref(); _ = surface.refSink(); const alloc = Application.default().allocator(); - var tree = Surface.Tree.init(alloc, surface) catch unreachable; - defer tree.deinit(); - priv.split_tree.setTree(&tree); + if (Surface.Tree.init(alloc, surface)) |tree| { + priv.split_tree.setTree(&tree); + + // Hacky because we need a non-const result. + var mut = tree; + mut.deinit(); + } else |_| { + // TODO: We should make our "no surfaces" state more aesthetically + // pleasing and show something like an "Oops, something went wrong" + // message. For now, this is incredibly unlikely. + @panic("oom"); + } } fn connectSurfaceHandlers( From 326e55c8f8bd5bdf3d5883ecf19479668c65966a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Aug 2025 08:35:18 -0700 Subject: [PATCH 32/83] apprt/gtk-ng: PR feedback --- src/apprt/gtk-ng/class/split_tree.zig | 38 ++++++++++++-------------- src/apprt/gtk-ng/class/tab.zig | 11 ++++---- src/apprt/gtk-ng/class/window.zig | 16 ++++++----- src/apprt/gtk-ng/ui/1.5/split-tree.blp | 37 ++++++++++++------------- src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- src/datastruct/split_tree.zig | 10 +++---- 6 files changed, 55 insertions(+), 59 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 7e3f7d92b..750ba670e 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -26,7 +26,7 @@ const log = std.log.scoped(.gtk_ghostty_split_tree); pub const SplitTree = extern struct { const Self = @This(); parent_instance: Parent, - pub const Parent = adw.Bin; + pub const Parent = gtk.Box; pub const getGObjectType = gobject.ext.defineClass(Self, .{ .name = "GhosttySplitTree", .instanceInit = &init, @@ -36,21 +36,21 @@ pub const SplitTree = extern struct { }); pub const properties = struct { - pub const @"is-empty" = struct { - pub const name = "is-empty"; + pub const @"has-surfaces" = struct { + pub const name = "has-surfaces"; const impl = gobject.ext.defineProperty( name, Self, bool, .{ - .nick = "Tree Is Empty", - .blurb = "True when the tree has no surfaces.", + .nick = "Has Surfaces", + .blurb = "Tree has surfaces.", .default = false, .accessor = gobject.ext.typedAccessor( Self, bool, .{ - .getter = getIsEmpty, + .getter = getHasSurfaces, }, ), }, @@ -76,17 +76,15 @@ pub const SplitTree = extern struct { }; pub const signals = struct { - /// Emitted whenever the tree property is about to change. - /// - /// The new value is given as the signal parameter. The old value - /// can still be retrieved from the tree property. - pub const @"tree-will-change" = struct { - pub const name = "tree-will-change"; + /// Emitted whenever the tree property has changed, with access + /// to the previous and new values. + pub const changed = struct { + pub const name = "changed"; pub const connect = impl.connect; const impl = gobject.ext.defineSignal( name, Self, - &.{?*const Surface.Tree}, + &.{ ?*const Surface.Tree, ?*const Surface.Tree }, void, ); }; @@ -109,9 +107,9 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Properties - pub fn getIsEmpty(self: *Self) bool { + pub fn getHasSurfaces(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; - return tree.isEmpty(); + return !tree.isEmpty(); } /// Get the tree data model that we're showing in this widget. This @@ -127,10 +125,10 @@ pub const SplitTree = extern struct { // Emit the signal so that handlers can witness both the before and // after values of the tree. - signals.@"tree-will-change".impl.emit( + signals.changed.impl.emit( self, null, - .{tree}, + .{ priv.tree, tree }, null, ); @@ -206,7 +204,7 @@ pub const SplitTree = extern struct { } // Dependent properties - self.as(gobject.Object).notifyByPspec(properties.@"is-empty".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); } /// Builds the widget tree associated with a surface split tree. @@ -265,7 +263,7 @@ pub const SplitTree = extern struct { // Properties gobject.ext.registerProperties(class, &.{ - properties.@"is-empty".impl, + properties.@"has-surfaces".impl, properties.tree.impl, }); @@ -276,7 +274,7 @@ pub const SplitTree = extern struct { class.bindTemplateCallback("notify_tree", &propTree); // Signals - signals.@"tree-will-change".impl.register(.{}); + signals.changed.impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index c60b01bfa..5de4839ec 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -328,13 +328,14 @@ pub const Tab = extern struct { } } - fn splitTreeWillChange( - split_tree: *SplitTree, + fn splitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, new_tree: ?*const Surface.Tree, self: *Self, ) callconv(.c) void { - if (split_tree.getTree()) |old_tree| { - self.disconnectSurfaceHandlers(old_tree); + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); } if (new_tree) |tree| { @@ -406,7 +407,7 @@ pub const Tab = extern struct { class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks - class.bindTemplateCallback("tree_will_change", &splitTreeWillChange); + class.bindTemplateCallback("tree_changed", &splitTreeChanged); class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 3d7143953..bffa43bb1 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -411,18 +411,19 @@ pub const Window = extern struct { // Bind signals const split_tree = tab.getSplitTree(); - _ = SplitTree.signals.@"tree-will-change".connect( + _ = SplitTree.signals.changed.connect( split_tree, *Self, - tabSplitTreeWillChange, + tabSplitTreeChanged, self, .{}, ); // Run an initial notification for the surface tree so we can setup // initial state. - tabSplitTreeWillChange( + tabSplitTreeChanged( split_tree, + null, split_tree.getTree(), self, ); @@ -1507,13 +1508,14 @@ pub const Window = extern struct { } } - fn tabSplitTreeWillChange( - split_tree: *SplitTree, + fn tabSplitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, new_tree: ?*const Surface.Tree, self: *Self, ) callconv(.c) void { - if (split_tree.getTree()) |old_tree| { - self.disconnectSurfaceHandlers(old_tree); + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); } if (new_tree) |tree| { diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp index 2ce4b3f10..e8c53b607 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -1,28 +1,25 @@ using Gtk 4.0; using Adw 1; -template $GhosttySplitTree: Adw.Bin { +template $GhosttySplitTree: Box { notify::tree => $notify_tree(); + orientation: vertical; - Box { - orientation: vertical; + Adw.Bin tree_bin { + visible: bind template.has-surfaces; + hexpand: true; + vexpand: true; + } - Adw.Bin tree_bin { - visible: bind template.is-empty inverted; - hexpand: true; - vexpand: true; - } - - // This could be a lot more visually pleasing but in practice this doesn't - // ever happen at the time of writing this comment. A surface-less split - // tree always closes its parent. - Label { - visible: bind template.is-empty; - // Purposely not localized currently because this shouldn't really - // ever appear. When we have a situation it does appear, we may want - // to change the styling and text so I don't want to burden localizers - // to handle this yet. - label: "No surfaces."; - } + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.has-surfaces inverted; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; } } diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index f0d7f5f68..61f106ce1 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -12,6 +12,6 @@ template $GhosttyTab: Box { $GhosttySplitTree split_tree { notify::tree => $notify_tree(); - tree-will-change => $tree_will_change(); + changed => $tree_changed(); } } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 23e9eae0c..68a7c09e7 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -728,22 +728,20 @@ pub fn SplitTree(comptime V: type) type { }, .funcs = .{ - // The @ptrCast below is to workaround this bug: - // https://github.com/ianprime0509/zig-gobject/issues/115 - .copy = @ptrCast(&struct { + .copy = &struct { fn copy(self: *Self) callconv(.c) *Self { const ptr = @import("glib").ext.create(Self); const alloc = self.arena.child_allocator; ptr.* = self.clone(alloc) catch @panic("oom"); return ptr; } - }.copy), - .free = @ptrCast(&struct { + }.copy, + .free = &struct { fn free(self: *Self) callconv(.c) void { self.deinit(); @import("glib").ext.destroy(self); } - }.free), + }.free, }, }, ), From b5073b34ee184a953ef6bcff4e519c0442281432 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 7 Aug 2025 13:12:56 -0500 Subject: [PATCH 33/83] gtk-ng: nuke .nick and .blurb from property definitions --- src/apprt/gtk-ng/class/application.zig | 2 -- .../class/clipboard_confirmation_dialog.zig | 8 ------- .../class/close_confirmation_dialog.zig | 2 -- src/apprt/gtk-ng/class/command_palette.zig | 8 ------- src/apprt/gtk-ng/class/config.zig | 4 ---- .../gtk-ng/class/config_errors_dialog.zig | 2 -- src/apprt/gtk-ng/class/global_shortcuts.zig | 4 ---- src/apprt/gtk-ng/class/resize_overlay.zig | 8 ------- src/apprt/gtk-ng/class/split_tree.zig | 4 ---- src/apprt/gtk-ng/class/surface.zig | 24 ------------------- .../gtk-ng/class/surface_child_exited.zig | 2 -- src/apprt/gtk-ng/class/tab.zig | 8 ------- src/apprt/gtk-ng/class/window.zig | 18 -------------- 13 files changed, 94 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 2bbedeba8..6a0d86bd5 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -79,8 +79,6 @@ pub const Application = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The current active configuration for the application.", .accessor = gobject.ext.typedAccessor( Self, ?*Config, diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig index 5f17035a6..d3d1b30b1 100644 --- a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig @@ -37,8 +37,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, bool, .{ - .nick = "Can Remember", - .blurb = "Allow remembering the choice.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -57,8 +55,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, ?*apprt.ClipboardRequest, .{ - .nick = "Request", - .blurb = "The clipboard request.", .accessor = C.privateBoxedFieldAccessor("request"), }, ); @@ -71,8 +67,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, ?*gtk.TextBuffer, .{ - .nick = "Clipboard Contents", - .blurb = "The clipboard contents being read/written.", .accessor = C.privateObjFieldAccessor("clipboard_contents"), }, ); @@ -85,8 +79,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, bool, .{ - .nick = "Blur", - .blurb = "Blur the contents, allowing the user to reveal.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig index e64dfee98..210533c1c 100644 --- a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig @@ -32,8 +32,6 @@ pub const CloseConfirmationDialog = extern struct { Self, Target, .{ - .nick = "Target", - .blurb = "The target for this close confirmation.", .default = .app, .accessor = gobject.ext.privateFieldAccessor( Self, diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig index 2dc3e1a74..ee10989b7 100644 --- a/src/apprt/gtk-ng/class/command_palette.zig +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -37,8 +37,6 @@ pub const CommandPalette = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this command palette is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -327,8 +325,6 @@ const Command = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this command palette is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -341,7 +337,6 @@ const Command = extern struct { Self, ?[:0]const u8, .{ - .nick = "Action Key", .default = null, .accessor = gobject.ext.typedAccessor( Self, @@ -362,7 +357,6 @@ const Command = extern struct { Self, ?[:0]const u8, .{ - .nick = "Action", .default = null, .accessor = gobject.ext.typedAccessor( Self, @@ -383,7 +377,6 @@ const Command = extern struct { Self, ?[:0]const u8, .{ - .nick = "Title", .default = null, .accessor = gobject.ext.typedAccessor( Self, @@ -404,7 +397,6 @@ const Command = extern struct { Self, ?[:0]const u8, .{ - .nick = "Description", .default = null, .accessor = gobject.ext.typedAccessor( Self, diff --git a/src/apprt/gtk-ng/class/config.zig b/src/apprt/gtk-ng/class/config.zig index f1f058458..2b98c68b5 100644 --- a/src/apprt/gtk-ng/class/config.zig +++ b/src/apprt/gtk-ng/class/config.zig @@ -39,8 +39,6 @@ pub const Config = extern struct { Self, ?*gtk.TextBuffer, .{ - .nick = "Diagnostics Buffer", - .blurb = "A TextBuffer that contains the diagnostics.", .accessor = gobject.ext.typedAccessor( Self, ?*gtk.TextBuffer, @@ -57,8 +55,6 @@ pub const Config = extern struct { Self, bool, .{ - .nick = "has-diagnostics", - .blurb = "Whether the configuration has diagnostics.", .default = false, .accessor = gobject.ext.typedAccessor( Self, diff --git a/src/apprt/gtk-ng/class/config_errors_dialog.zig b/src/apprt/gtk-ng/class/config_errors_dialog.zig index 203c572fc..fc76bc268 100644 --- a/src/apprt/gtk-ng/class/config_errors_dialog.zig +++ b/src/apprt/gtk-ng/class/config_errors_dialog.zig @@ -29,8 +29,6 @@ pub const ConfigErrorsDialog = extern struct { Self, ?*Config, .{ - .nick = "config", - .blurb = "The configuration that this dialog is showing errors for.", .accessor = gobject.ext.typedAccessor( Self, ?*Config, diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk-ng/class/global_shortcuts.zig index 9088e6b02..18280cfe9 100644 --- a/src/apprt/gtk-ng/class/global_shortcuts.zig +++ b/src/apprt/gtk-ng/class/global_shortcuts.zig @@ -36,8 +36,6 @@ pub const GlobalShortcuts = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -50,8 +48,6 @@ pub const GlobalShortcuts = extern struct { Self, ?*gio.DBusConnection, .{ - .nick = "Dbus Connection", - .blurb = "The dbus connection to use.", .accessor = C.privateObjFieldAccessor("dbus_connection"), }, ); diff --git a/src/apprt/gtk-ng/class/resize_overlay.zig b/src/apprt/gtk-ng/class/resize_overlay.zig index 75e580127..a4109764c 100644 --- a/src/apprt/gtk-ng/class/resize_overlay.zig +++ b/src/apprt/gtk-ng/class/resize_overlay.zig @@ -42,8 +42,6 @@ pub const ResizeOverlay = extern struct { Self, c_uint, .{ - .nick = "Duration", - .blurb = "The duration this overlay appears in milliseconds.", .default = 750, .minimum = 250, .maximum = std.math.maxInt(c_uint), @@ -64,8 +62,6 @@ pub const ResizeOverlay = extern struct { Self, c_uint, .{ - .nick = "First Delay", - .blurb = "The delay in milliseconds before any overlay is shown for the first time.", .default = 250, .minimum = 250, .maximum = std.math.maxInt(c_uint), @@ -86,8 +82,6 @@ pub const ResizeOverlay = extern struct { Self, gtk.Align, .{ - .nick = "halign", - .blurb = "The alignment of the label.", .default = .center, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -106,8 +100,6 @@ pub const ResizeOverlay = extern struct { Self, gtk.Align, .{ - .nick = "valign", - .blurb = "The alignment of the label.", .default = .center, .accessor = gobject.ext.privateFieldAccessor( Self, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 750ba670e..2e2af118a 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -43,8 +43,6 @@ pub const SplitTree = extern struct { Self, bool, .{ - .nick = "Has Surfaces", - .blurb = "Tree has surfaces.", .default = false, .accessor = gobject.ext.typedAccessor( Self, @@ -64,8 +62,6 @@ pub const SplitTree = extern struct { Self, ?*Surface.Tree, .{ - .nick = "Tree Model", - .blurb = "Underlying data model for the tree.", .accessor = .{ .getter = getTreeValue, .setter = setTreeValue, diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 383c3b084..48388457b 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -54,8 +54,6 @@ pub const Surface = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this surface is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -68,8 +66,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Child Exited", - .blurb = "True when the child process has exited.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -88,8 +84,6 @@ pub const Surface = extern struct { Self, ?*Size, .{ - .nick = "Default Size", - .blurb = "The default size of the window for this surface.", .accessor = C.privateBoxedFieldAccessor("default_size"), }, ); @@ -102,8 +96,6 @@ pub const Surface = extern struct { Self, ?*font.face.DesiredSize, .{ - .nick = "Desired Font Size", - .blurb = "The desired font size, only affects initialization.", .accessor = C.privateBoxedFieldAccessor("font_size_request"), }, ); @@ -116,8 +108,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Focused", - .blurb = "The focused state of the surface.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -136,8 +126,6 @@ pub const Surface = extern struct { Self, ?*Size, .{ - .nick = "Minimum Size", - .blurb = "The minimum size of the surface.", .accessor = C.privateBoxedFieldAccessor("min_size"), }, ); @@ -150,8 +138,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Mouse Hidden", - .blurb = "Whether the mouse cursor should be hidden.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -170,8 +156,6 @@ pub const Surface = extern struct { Self, terminal.MouseShape, .{ - .nick = "Mouse Shape", - .blurb = "The current mouse shape to show for the surface.", .default = .text, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -192,8 +176,6 @@ pub const Surface = extern struct { Self, ?[:0]const u8, .{ - .nick = "Mouse Hover URL", - .blurb = "The URL the mouse is currently hovering over (if any).", .default = null, .accessor = C.privateStringFieldAccessor("mouse_hover_url"), }, @@ -209,8 +191,6 @@ pub const Surface = extern struct { Self, ?[:0]const u8, .{ - .nick = "Working Directory", - .blurb = "The current working directory as reported by core.", .default = null, .accessor = C.privateStringFieldAccessor("pwd"), }, @@ -226,8 +206,6 @@ pub const Surface = extern struct { Self, ?[:0]const u8, .{ - .nick = "Title", - .blurb = "The title of the surface.", .default = null, .accessor = C.privateStringFieldAccessor("title"), }, @@ -241,8 +219,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Zoom", - .blurb = "Whether the surface should be zoomed.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, diff --git a/src/apprt/gtk-ng/class/surface_child_exited.zig b/src/apprt/gtk-ng/class/surface_child_exited.zig index 69d1306d6..bdee81397 100644 --- a/src/apprt/gtk-ng/class/surface_child_exited.zig +++ b/src/apprt/gtk-ng/class/surface_child_exited.zig @@ -40,8 +40,6 @@ const SurfaceChildExitedBanner = extern struct { Self, ?*apprt.surface.Message.ChildExited, .{ - .nick = "Data", - .blurb = "The child exit data.", .accessor = C.privateBoxedFieldAccessor("data"), }, ); diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 5de4839ec..a5c088d15 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -47,8 +47,6 @@ pub const Tab = extern struct { Self, ?*Surface, .{ - .nick = "Active Surface", - .blurb = "The currently active surface.", .accessor = gobject.ext.typedAccessor( Self, ?*Surface, @@ -67,8 +65,6 @@ pub const Tab = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this surface is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -81,8 +77,6 @@ pub const Tab = extern struct { Self, ?*Surface.Tree, .{ - .nick = "Surface Tree", - .blurb = "The surface tree that is contained in this tab.", .accessor = gobject.ext.typedAccessor( Self, ?*Surface.Tree, @@ -103,8 +97,6 @@ pub const Tab = extern struct { Self, ?[:0]const u8, .{ - .nick = "Title", - .blurb = "The title of the active surface.", .default = null, .accessor = C.privateStringFieldAccessor("title"), }, diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index bffa43bb1..b74305acc 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -54,8 +54,6 @@ pub const Window = extern struct { Self, ?*Surface, .{ - .nick = "Active Surface", - .blurb = "The currently active surface.", .accessor = gobject.ext.typedAccessor( Self, ?*Surface, @@ -74,8 +72,6 @@ pub const Window = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this surface is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -88,8 +84,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Debug", - .blurb = "True if runtime safety checks are enabled.", .default = build_config.is_debug, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = struct { @@ -109,8 +103,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Headerbar Visible", - .blurb = "True if the headerbar is visible.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getHeaderbarVisible, @@ -126,8 +118,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Quick Terminal", - .blurb = "Whether this window behaves like a quick terminal.", .default = true, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -146,8 +136,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Autohide Tab Bar", - .blurb = "If true, tab bar should autohide.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getTabsAutohide, @@ -163,8 +151,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Wide Tabs", - .blurb = "If true, tabs will be in the wide expanded style.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getTabsWide, @@ -180,8 +166,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Tab Bar Visibility", - .blurb = "If true, tab bar should be visible.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getTabsVisible, @@ -197,8 +181,6 @@ pub const Window = extern struct { Self, adw.ToolbarStyle, .{ - .nick = "Toolbar Style", - .blurb = "The style for the toolbar top/bottom bars.", .default = .raised, .accessor = gobject.ext.typedAccessor( Self, From 23a6d4f2767176567c32125f55a82bbf78493635 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 00:17:36 +0000 Subject: [PATCH 34/83] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.15 to 1.2.16. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413...305bfa7ea980a858d511af4899414a84847c7991) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.16 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/test.yml | 42 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 8c141f8e8..2dd6a13e7 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index dc6dd7741..c0a051753 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index ba08a1fe3..616ee84fe 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db1cbeb55..75db53a4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -101,7 +101,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -166,7 +166,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -199,7 +199,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -243,7 +243,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -414,7 +414,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -509,7 +509,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -554,7 +554,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -603,7 +603,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -651,7 +651,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -706,7 +706,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -734,7 +734,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -761,7 +761,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -788,7 +788,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -815,7 +815,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -842,7 +842,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -876,7 +876,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -903,7 +903,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -938,7 +938,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -996,7 +996,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 9f0b02a14..d614814ad 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@f2d0a9e9ed4a256f08f9ae18c912b06bcf62a413 # v1.2.15 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix From 7663f7d92261b4904636f0318ad3b22335f6f60f Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 8 Aug 2025 00:43:14 +0800 Subject: [PATCH 35/83] inspector: fix display for fractional pixel sizes #4371 2: Electric Boogaloo Regression caused by #7953 --- src/inspector/Inspector.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index edc204886..d3e7fcaaa 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -756,7 +756,7 @@ fn renderSizeWindow(self: *Inspector) void { { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText( - "%d px", + "%.2f px", self.surface.font_size.pixels(), ); } From 1f378e6775f15db7b00442559324839c43ebdc29 Mon Sep 17 00:00:00 2001 From: Alexander Lais Date: Fri, 8 Aug 2025 16:53:11 +0200 Subject: [PATCH 36/83] fix: capture screenshot for app intents views as NSImage SwiftUI's ImageRenderer must not be called outside the main thread. The `@MainActor` annotation is only relevant for our own code, not for calls from frameworks. The machinations around Shortcuts end up calling the displayRepresentation method outside the main thread. By capturing the screenshot as NSImage, all data is retained and can be processed outside the main thread. --- .../Features/App Intents/Entities/TerminalEntity.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index cc3b9f63a..974f1b07f 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -14,7 +14,7 @@ struct TerminalEntity: AppEntity { @Property(title: "Kind") var kind: Kind - var screenshot: Image? + var screenshot: NSImage? static var typeDisplayRepresentation: TypeDisplayRepresentation { TypeDisplayRepresentation(name: "Terminal") @@ -24,8 +24,7 @@ struct TerminalEntity: AppEntity { var displayRepresentation: DisplayRepresentation { var rep = DisplayRepresentation(title: "\(title)") if let screenshot, - let nsImage = ImageRenderer(content: screenshot).nsImage, - let data = nsImage.tiffRepresentation { + let data = screenshot.tiffRepresentation { rep.image = .init(data: data) } @@ -45,11 +44,14 @@ struct TerminalEntity: AppEntity { static var defaultQuery = TerminalQuery() + @MainActor init(_ view: Ghostty.SurfaceView) { self.id = view.uuid self.title = view.title self.workingDirectory = view.pwd - self.screenshot = view.screenshot() + if let nsImage = ImageRenderer(content: view.screenshot()).nsImage { + self.screenshot = nsImage + } // Determine the kind based on the window controller type if view.window?.windowController is QuickTerminalController { From ef817cd267f59212bbe48262cbdddf1a10f31a90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 07:30:18 -0700 Subject: [PATCH 37/83] valgrind: GtkPopover suppressions We get a ton of leaks from GTK.PopOver when we run the steps given in the suppression file. I don't see how this could be us since we don't create or do anything with the popover manually; its simply defined in the Blueprint file. --- valgrind.supp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/valgrind.supp b/valgrind.supp index 2a0d2b9fa..0ce99e34a 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -13,6 +13,38 @@ # You must gracefully exit Ghostty (do not SIGINT) by closing all windows # and quitting. Otherwise, we leave a number of GTK resources around. + +# Reproduction: +# 1. Launch Ghostty (no config) +# 2. Right Click on the terminal +# 3. Hover over "Split" to get a submenu +# 4. Close menu by clicking away +# 5. Exit +# +# The menu model and popover are fully defined in the blueprint so I don't +# THINK we need to do any manual unrefing. But there's a lot of leaks here +# so if someone wants to take a closer look I'd appreciate it. +{ + GTK PopOver Menu Model Leak + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gtk_menu_section_box_insert_func + ... + fun:gtk_popover_menu_set_menu_model + ... +} +{ + GTK/Blueprint Popover GSK Transform + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gtk_popover_size_allocate + fun:gtk_widget_allocate + fun:gtk_popover_native_layout + ... +} + { GTK CSS Provider Leak Memcheck:Leak From 0f67282dfa24ccc7d494e5b4b9fd017e9ac48e92 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 6 Aug 2025 18:35:29 -0500 Subject: [PATCH 38/83] gtk-ng: add "title bar styles" This PR adds a "tabs" title bar style similar to the macOS title bar style. When `gtk-titlebar-style=tabs` the title bar and the tab bar will be merged together. The config entry for controlling this is kept separate from macOS as macOS has more styles defined that don't map to a GTK title bar style and it's likely that users that use both macOS and GTK would want different settings for each platform. --- src/apprt/gtk-ng/class/window.zig | 111 ++++++++++++++++++++++++----- src/apprt/gtk-ng/ui/1.5/window.blp | 58 +++++++++++++++ src/config/Config.zig | 30 ++++++++ 3 files changed, 180 insertions(+), 19 deletions(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index b74305acc..30a82d963 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -11,6 +11,7 @@ const gtk = @import("gtk"); const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const configpkg = @import("../../../config.zig"); +const TitlebarStyle = configpkg.Config.GtkTitlebarStyle; const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); const ext = @import("../ext.zig"); @@ -96,6 +97,25 @@ pub const Window = extern struct { ); }; + pub const @"titlebar-style" = struct { + pub const name = "titlebar-style"; + const impl = gobject.ext.defineProperty( + name, + Self, + TitlebarStyle, + .{ + .default = .native, + .accessor = gobject.ext.typedAccessor( + Self, + TitlebarStyle, + .{ + .getter = Self.getTitlebarStyle, + }, + ), + }, + ); + }; + pub const @"headerbar-visible" = struct { pub const name = "headerbar-visible"; const impl = gobject.ext.defineProperty( @@ -548,6 +568,7 @@ pub const Window = extern struct { "tabs-visible", "tabs-wide", "toolbar-style", + "titlebar-style", }) |key| { self.as(gobject.Object).notifyByPspec( @field(properties, key).impl.param_spec, @@ -814,6 +835,14 @@ pub const Window = extern struct { return false; } + fn isFullscreen(self: *Window) bool { + return self.as(gtk.Window).isFullscreen() != 0; + } + + fn isMaximized(self: *Window) bool { + return self.as(gtk.Window).isMaximized() != 0; + } + fn getHeaderbarVisible(self: *Self) bool { const priv = self.private(); @@ -825,46 +854,72 @@ pub const Window = extern struct { if (priv.quick_terminal) return false; // If we're fullscreen we never show the header bar. - if (self.as(gtk.Window).isFullscreen() != 0) return false; + if (self.isFullscreen()) return false; // The remainder needs a config const config_obj = self.private().config orelse return true; const config = config_obj.get(); - // *Conditionally* disable the header bar when maximized, - // and gtk-titlebar-hide-when-maximized is set - if (self.as(gtk.Window).isMaximized() != 0 and - config.@"gtk-titlebar-hide-when-maximized") - { + // *Conditionally* disable the header bar when maximized, and + // gtk-titlebar-hide-when-maximized is set + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") { return false; } - return config.@"gtk-titlebar"; + return switch (config.@"gtk-titlebar-style") { + // If the titlebar style is tabs never show the titlebar. + .tabs => false, + + // If the titlebar style is native show the titlebar if configured + // to do so. + .native => config.@"gtk-titlebar", + }; } fn getTabsAutohide(self: *Self) bool { const priv = self.private(); const config = if (priv.config) |v| v.get() else return true; - return switch (config.@"window-show-tab-bar") { - // Auto we always autohide... obviously. - .auto => true, - // Always we never autohide because we always show the tab bar. - .always => false, + return switch (config.@"gtk-titlebar-style") { + // If the titlebar style is tabs we cannot autohide. + .tabs => false, - // Never we autohide because it doesn't actually matter, - // since getTabsVisible will return false. - .never => true, + .native => switch (config.@"window-show-tab-bar") { + // Auto we always autohide... obviously. + .auto => true, + + // Always we never autohide because we always show the tab bar. + .always => false, + + // Never we autohide because it doesn't actually matter, + // since getTabsVisible will return false. + .never => true, + }, }; } fn getTabsVisible(self: *Self) bool { const priv = self.private(); const config = if (priv.config) |v| v.get() else return true; - return switch (config.@"window-show-tab-bar") { - .always, .auto => true, - .never => false, - }; + + switch (config.@"gtk-titlebar-style") { + .tabs => { + // *Conditionally* disable the tab bar when maximized, the titlebar + // style is tabs, and gtk-titlebar-hide-when-maximized is set. + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") { + return false; + } + + // If the titlebar style is tabs the tab bar must always be visible. + return true; + }, + .native => { + return switch (config.@"window-show-tab-bar") { + .always, .auto => true, + .never => false, + }; + }, + } } fn getTabsWide(self: *Self) bool { @@ -883,6 +938,12 @@ pub const Window = extern struct { }; } + fn getTitlebarStyle(self: *Self) TitlebarStyle { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return .native; + return config.@"gtk-titlebar-style"; + } + fn propConfig( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -992,6 +1053,16 @@ pub const Window = extern struct { }; } + fn closureTitlebarStyleIsTab( + _: *Self, + value: TitlebarStyle, + ) callconv(.c) bool { + return switch (value) { + .native => false, + .tabs => true, + }; + } + //--------------------------------------------------------------- // Virtual methods @@ -1703,6 +1774,7 @@ pub const Window = extern struct { properties.@"tabs-visible".impl, properties.@"tabs-wide".impl, properties.@"toolbar-style".impl, + properties.@"titlebar-style".impl, }); // Bindings @@ -1730,6 +1802,7 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_menu_active", &propMenuActive); class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); + class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index 121a1b45a..4ca90dfb5 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -79,6 +79,64 @@ template $GhosttyWindow: Adw.ApplicationWindow { expand-tabs: bind template.tabs-wide; view: tab_view; visible: bind template.tabs-visible; + + [start] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; + + Gtk.WindowControls { + side: start; + } + + Adw.SplitButton { + styles [ + "flat", + ] + + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + can-focus: false; + focus-on-click: false; + } + } + + [end] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; + + Gtk.ToggleButton { + styles [ + "flat", + ] + + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + styles [ + "flat", + ] + + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } + + Gtk.WindowControls { + side: end; + } + } } Box { diff --git a/src/config/Config.zig b/src/config/Config.zig index bca82ff79..2cf5a3e17 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2892,6 +2892,21 @@ else /// more subtle border. @"gtk-toolbar-style": GtkToolbarStyle = .raised, +/// The style of the GTK titlbar. Available values are `native` and `tabs`. +/// +/// The `native` titlebar style is a traditional titlebar with a title, a few +/// buttons and window controls. A separate tab bar will show up below the +/// titlebar if you have multiple tabs open in the window. +/// +/// The `tabs` titlebar merges the tab bar and the traditional titlebar. +/// This frees up vertical space on your screen if you use multiple tabs. One +/// limitation of the `tabs` titlebar is that you cannot drag the titlebar +/// by the titles any longer (as they are tab titles now). Other areas of the +/// `tabs` title bar can be used to drag the window around. +/// +/// The default style is `native`. +@"gtk-titlebar-style": GtkTitlebarStyle = .native, + /// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs /// are the new typical Gnome style where tabs fill their available space. /// If you set this to `false` then tabs will only take up space they need, @@ -6947,6 +6962,21 @@ pub const GtkToolbarStyle = enum { @"raised-border", }; +/// See gtk-titlebar-style +pub const GtkTitlebarStyle = enum(c_int) { + native, + tabs, + + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + GtkTitlebarStyle, + .{ .name = "GhosttyGtkTitlebarStyle" }, + ), + + .none => void, + }; +}; + /// See app-notifications pub const AppNotifications = packed struct { @"clipboard-copy": bool = true, From cae60f7c2922d5abeeaec6a211047736a502b829 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 8 Aug 2025 13:36:45 -0500 Subject: [PATCH 39/83] gtk-ng: use single if expression instead of block --- src/apprt/gtk-ng/class/window.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 30a82d963..79017fbc8 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -906,9 +906,7 @@ pub const Window = extern struct { .tabs => { // *Conditionally* disable the tab bar when maximized, the titlebar // style is tabs, and gtk-titlebar-hide-when-maximized is set. - if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") { - return false; - } + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") return false; // If the titlebar style is tabs the tab bar must always be visible. return true; From 16e15554da70a2dbdf630606529d3d9f00eebc1a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 12:30:18 -0700 Subject: [PATCH 40/83] apprt/gtk-ng: add proper setters for surface properties This also fixes a bug where we were setting custom cursors on the wrong gtk widget, this showed up most terribly with `mouse-hide-while-typing` where the mouse would never reappear. --- src/apprt/gtk-ng/class/application.zig | 59 ++++------------------ src/apprt/gtk-ng/class/surface.zig | 68 ++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 6a0d86bd5..9c0a821cd 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1693,16 +1693,9 @@ const Action = struct { ) void { switch (target) { .app => log.warn("mouse over link to app is unexpected", .{}), - .surface => |surface| { - var v = gobject.ext.Value.new([:0]const u8); - if (value.url.len > 0) gobject.ext.Value.set(&v, value.url); - defer v.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "mouse-hover-url", - &v, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setMouseHoverUrl( + if (value.url.len > 0) value.url else null, + ), } } @@ -1712,15 +1705,7 @@ const Action = struct { ) void { switch (target) { .app => log.warn("mouse shape to app is unexpected", .{}), - .surface => |surface| { - var value = gobject.ext.Value.newFrom(shape); - defer value.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "mouse-shape", - &value, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setMouseShape(shape), } } @@ -1730,18 +1715,10 @@ const Action = struct { ) void { switch (target) { .app => log.warn("mouse visibility to app is unexpected", .{}), - .surface => |surface| { - var value = gobject.ext.Value.newFrom(switch (visibility) { - .visible => false, - .hidden => true, - }); - defer value.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "mouse-hidden", - &value, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setMouseHidden(switch (visibility) { + .visible => false, + .hidden => true, + }), } } @@ -1862,15 +1839,7 @@ const Action = struct { ) void { switch (target) { .app => log.warn("pwd to app is unexpected", .{}), - .surface => |surface| { - var v = gobject.ext.Value.newFrom(value.pwd); - defer v.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "pwd", - &v, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setPwd(value.pwd), } } @@ -1970,15 +1939,7 @@ const Action = struct { ) void { switch (target) { .app => log.warn("set_title to app is unexpected", .{}), - .surface => |surface| { - var v = gobject.ext.Value.newFrom(value.title); - defer v.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "title", - &v, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setTitle(value.title), } } diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 48388457b..05393bd4f 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -139,11 +139,13 @@ pub const Surface = extern struct { bool, .{ .default = false, - .accessor = gobject.ext.privateFieldAccessor( + .accessor = gobject.ext.typedAccessor( Self, - Private, - &Private.offset, - "mouse_hidden", + bool, + .{ + .getter = getMouseHidden, + .setter = setMouseHidden, + }, ), }, ); @@ -157,11 +159,13 @@ pub const Surface = extern struct { terminal.MouseShape, .{ .default = .text, - .accessor = gobject.ext.privateFieldAccessor( + .accessor = gobject.ext.typedAccessor( Self, - Private, - &Private.offset, - "mouse_shape", + terminal.MouseShape, + .{ + .getter = getMouseShape, + .setter = setMouseShape, + }, ), }, ); @@ -1289,11 +1293,29 @@ pub const Surface = extern struct { return self.private().title; } + /// Set the title for this surface, copies the value. + pub fn setTitle(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title) |v| glib.free(@constCast(@ptrCast(v))); + priv.title = null; + if (title) |v| priv.title = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec); + } + /// Returns the pwd property without a copy. pub fn getPwd(self: *Self) ?[:0]const u8 { return self.private().pwd; } + /// Set the pwd for this surface, copies the value. + pub fn setPwd(self: *Self, pwd: ?[:0]const u8) void { + const priv = self.private(); + if (priv.pwd) |v| glib.free(@constCast(@ptrCast(v))); + priv.pwd = null; + if (pwd) |v| priv.pwd = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + } + /// Returns the focus state of this surface. pub fn getFocused(self: *Self) bool { return self.private().focused; @@ -1351,6 +1373,34 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"min-size".impl.param_spec); } + pub fn getMouseShape(self: *Self) terminal.MouseShape { + return self.private().mouse_shape; + } + + pub fn setMouseShape(self: *Self, shape: terminal.MouseShape) void { + const priv = self.private(); + priv.mouse_shape = shape; + self.as(gobject.Object).notifyByPspec(properties.@"mouse-shape".impl.param_spec); + } + + pub fn getMouseHidden(self: *Self) bool { + return self.private().mouse_hidden; + } + + pub fn setMouseHidden(self: *Self, hidden: bool) void { + const priv = self.private(); + priv.mouse_hidden = hidden; + self.as(gobject.Object).notifyByPspec(properties.@"mouse-hidden".impl.param_spec); + } + + pub fn setMouseHoverUrl(self: *Self, url: ?[:0]const u8) void { + const priv = self.private(); + if (priv.mouse_hover_url) |v| glib.free(@constCast(@ptrCast(v))); + priv.mouse_hover_url = null; + if (url) |v| priv.mouse_hover_url = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -1480,7 +1530,7 @@ pub const Surface = extern struct { }; // Set our new cursor. - self.as(gtk.Widget).setCursorFromName(name.ptr); + priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr); } //--------------------------------------------------------------- From 8e073505f74b86af6240db0c0bbd4800f3c0c0f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 12:58:14 -0700 Subject: [PATCH 41/83] apprt/gtk-ng: set resize overlay label in the idle callback This avoids jitter when resizing splits. I didn't see any jitter before splits but conceptually its possible. The issue is that since we're updating the overlay DURING A RESIZE, changing the dimensions of any part of the widget tree causes GTK warnings and a bunch of laggy updates. Instead, we copy the label text to a property and update it on the idle callback along with everything else. This also provides a natural debounce to the label. --- src/apprt/gtk-ng/class/resize_overlay.zig | 55 ++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk-ng/class/resize_overlay.zig b/src/apprt/gtk-ng/class/resize_overlay.zig index a4109764c..9bb9a0a7c 100644 --- a/src/apprt/gtk-ng/class/resize_overlay.zig +++ b/src/apprt/gtk-ng/class/resize_overlay.zig @@ -75,6 +75,19 @@ pub const ResizeOverlay = extern struct { ); }; + pub const label = struct { + pub const name = "label"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("label_text"), + }, + ); + }; + pub const @"overlay-halign" = struct { pub const name = "overlay-halign"; const impl = gobject.ext.defineProperty( @@ -116,6 +129,9 @@ pub const ResizeOverlay = extern struct { /// The label with the text label: *gtk.Label, + /// The text to set on the label when scheduled. + label_text: ?[:0]const u8, + /// The time that the overlay appears. duration: c_uint, @@ -154,9 +170,12 @@ pub const ResizeOverlay = extern struct { /// Set the label for the overlay. This will not show the /// overlay if it is currently hidden; you must call schedule. - pub fn setLabel(self: *Self, label: [:0]const u8) void { + pub fn setLabel(self: *Self, label: ?[:0]const u8) void { const priv = self.private(); - priv.label.setText(label.ptr); + if (priv.label_text) |v| glib.free(@constCast(@ptrCast(v))); + priv.label_text = null; + if (label) |v| priv.label_text = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.label.impl.param_spec); } /// Schedule the overlay to be shown. To avoid flickering during @@ -184,15 +203,26 @@ pub const ResizeOverlay = extern struct { // No matter what our idler is complete with this callback priv.idler = null; - // Show ourselves - self.as(gtk.Widget).setVisible(1); - + // Cancel our previous show timer no matter what. if (priv.timer) |timer| { if (glib.Source.remove(timer) == 0) { log.warn("unable to remove size overlay timer", .{}); } + priv.timer = null; } + // If we have a label to show, show ourselves. If we don't have + // label text, then hide our label. + const text = priv.label_text orelse { + self.as(gtk.Widget).setVisible(0); + return 0; + }; + + // Set our label and show it. + priv.label.setLabel(text); + self.as(gtk.Widget).setVisible(1); + + // Setup the new timer to hide ourselves after the delay. priv.timer = glib.timeoutAdd( priv.duration, onTimer, @@ -252,6 +282,19 @@ pub const ResizeOverlay = extern struct { ); } + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.label_text) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.label_text = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -279,6 +322,7 @@ pub const ResizeOverlay = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.duration.impl, + properties.label.impl, properties.@"first-delay".impl, properties.@"overlay-halign".impl, properties.@"overlay-valign".impl, @@ -286,6 +330,7 @@ pub const ResizeOverlay = extern struct { // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); } pub const as = C.Class.as; From 5bf8f12cf099705d1a1576cc17694886e2e8bd27 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 13:28:02 -0700 Subject: [PATCH 42/83] apprt/gtk-ng: template callbacks can't return bool, must be c_int This fixes the tab bar showing the window controls sometimes. --- src/apprt/gtk-ng/class.zig | 5 +++++ src/apprt/gtk-ng/class/window.zig | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index a22b8771b..82762b542 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -222,6 +222,11 @@ pub fn Common( if (func_ti != .@"fn") { @compileError("bound function must be a function pointer"); } + if (func_ti.@"fn".return_type == bool) { + // glib booleans are ints and returning a Zig bool type + // I think uses a byte and causes ABI issues. + @compileError("bound function must return c_int instead of bool"); + } } gtk.Widget.Class.bindTemplateCallbackFull( diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 79017fbc8..739405961 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1054,11 +1054,11 @@ pub const Window = extern struct { fn closureTitlebarStyleIsTab( _: *Self, value: TitlebarStyle, - ) callconv(.c) bool { - return switch (value) { + ) callconv(.c) c_int { + return @intFromBool(switch (value) { .native => false, .tabs => true, - }; + }); } //--------------------------------------------------------------- From ae5dc3a4fb67db8689a858ed6d020b1b43b8e41b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Aug 2025 10:32:34 -0700 Subject: [PATCH 43/83] apprt/gtk-ng: split tree new split actions --- src/apprt/gtk-ng/class/split_tree.zig | 160 ++++++++++++++++++++++++++ src/apprt/gtk-ng/class/tab.zig | 36 ++---- src/apprt/gtk-ng/ui/1.2/surface.blp | 8 +- 3 files changed, 175 insertions(+), 29 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 2e2af118a..4e7e55f00 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -1,6 +1,7 @@ const std = @import("std"); const build_config = @import("../../../build_config.zig"); const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const adw = @import("adw"); const gio = @import("gio"); const glib = @import("glib"); @@ -36,6 +37,30 @@ pub const SplitTree = extern struct { }); pub const properties = struct { + /// The active surface is the surface that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .nick = "Active Surface", + .blurb = "The currently active surface.", + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = getActiveSurface, + }, + ), + }, + ); + }; + pub const @"has-surfaces" = struct { pub const name = "has-surfaces"; const impl = gobject.ext.defineProperty( @@ -98,11 +123,132 @@ pub const SplitTree = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Initialize our actions + self.initActions(); + } + + fn initActions(self: *Self) void { + // The set of actions. Each action has (in order): + // [0] The action name + // [1] The callback function + // [2] The glib.VariantType of the parameter + // + // For action names: + // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html + const actions = .{ + // All of these will eventually take a target surface parameter. + // For now all our targets originate from the focused surface. + .{ "new-left", actionNew, null }, + .{ "new-right", actionNew, null }, + .{ "new-up", actionNew, null }, + .{ "new-down", actionNew, null }, + }; + + // We need to collect our actions into a group since we're just + // a plain widget that doesn't implement ActionGroup directly. + const group = gio.SimpleActionGroup.new(); + errdefer group.unref(); + const map = group.as(gio.ActionMap); + inline for (actions) |entry| { + const action = gio.SimpleAction.new( + entry[0], + entry[2], + ); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *Self, + entry[1], + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } + + self.as(gtk.Widget).insertActionGroup( + "split-tree", + group.as(gio.ActionGroup), + ); + } + + /// Create a new split in the given direction from the currently + /// active surface. + /// + /// If the tree is empty this will create a new tree with a new surface + /// and ignore the direction. + /// + /// The parent will be used as the parent of the surface regardless of + /// if that parent is in this split tree or not. This allows inheriting + /// surface properties from anywhere. + pub fn newSplit( + self: *Self, + direction: Surface.Tree.Split.Direction, + parent_: ?*Surface, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + + // Create our new surface. + const surface: *Surface = .new(); + defer surface.unref(); + _ = surface.refSink(); + + // Inherit properly if we were asked to. + if (parent_) |p| { + if (p.core()) |core| { + surface.setParent(core); + } + } + + // Create our tree + var single_tree = try Surface.Tree.init(alloc, surface); + defer single_tree.deinit(); + + // If we have no tree yet, then this becomes our tree and we're done. + const old_tree = self.getTree() orelse { + self.setTree(&single_tree); + return; + }; + + // The handle we create the split relative to. Today this is the active + // surface but this might be the handle of the given parent if we want. + const handle = self.getActiveSurfaceHandle() orelse 0; + + // Create our split! + var new_tree = try old_tree.split( + alloc, + handle, + direction, + &single_tree, + ); + defer new_tree.deinit(); + self.setTree(&new_tree); + + // Focus our new surface + surface.grabFocus(); } //--------------------------------------------------------------- // Properties + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getTree() orelse return null; + const handle = self.getActiveSurfaceHandle() orelse return null; + return tree.nodes[handle].leaf; + } + + fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle { + const tree = self.getTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.handle; + } + + return null; + } + pub fn getHasSurfaces(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; return !tree.isEmpty(); @@ -185,6 +331,20 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Signal handlers + pub fn actionNew( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .right, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + fn propTree( self: *Self, _: *gobject.ParamSpec, diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index a5c088d15..4b75701bf 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -36,7 +36,7 @@ pub const Tab = extern struct { }); pub const properties = struct { - /// The active surface is the focus that should be receiving all + /// The active surface is the surface that should be receiving all /// surface-targeted actions. This is usually the focused surface, /// but may also not be focused if the user has selected a non-surface /// widget. @@ -164,23 +164,15 @@ pub const Tab = extern struct { .{}, ); - // A tab always starts with a single surface. - const surface: *Surface = .new(); - defer surface.unref(); - _ = surface.refSink(); - const alloc = Application.default().allocator(); - if (Surface.Tree.init(alloc, surface)) |tree| { - priv.split_tree.setTree(&tree); - - // Hacky because we need a non-const result. - var mut = tree; - mut.deinit(); - } else |_| { - // TODO: We should make our "no surfaces" state more aesthetically - // pleasing and show something like an "Oops, something went wrong" - // message. For now, this is incredibly unlikely. - @panic("oom"); - } + // Create our initial surface in the split tree. + priv.split_tree.newSplit(.right, null) catch |err| switch (err) { + error.OutOfMemory => { + // TODO: We should make our "no surfaces" state more aesthetically + // pleasing and show something like an "Oops, something went wrong" + // message. For now, this is incredibly unlikely. + @panic("oom"); + }, + }; } fn connectSurfaceHandlers( @@ -232,13 +224,7 @@ pub const Tab = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. pub fn getActiveSurface(self: *Self) ?*Surface { - const tree = self.getSurfaceTree() orelse return null; - var it = tree.iterator(); - while (it.next()) |entry| { - if (entry.view.getFocused()) return entry.view; - } - - return null; + return self.getSplitTree().getActiveSurface(); } /// Get the surface tree of this tab. diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index e671a0d82..23499c7f3 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -172,22 +172,22 @@ menu context_menu_model { item { label: _("Split Up"); - action: "win.split-up"; + action: "split-tree.new-up"; } item { label: _("Split Down"); - action: "win.split-down"; + action: "split-tree.new-down"; } item { label: _("Split Left"); - action: "win.split-left"; + action: "split-tree.new-left"; } item { label: _("Split Right"); - action: "win.split-right"; + action: "split-tree.new-right"; } } From 75dd8e46b52bf0f9b3afd6475f37ecbcce6bfe97 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Aug 2025 10:51:07 -0700 Subject: [PATCH 44/83] datastruct: fix split tree debug log rounding --- src/apprt/gtk-ng/class/split_tree.zig | 6 +++ src/datastruct/split_tree.zig | 57 +++++++++++++++++++++------ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 4e7e55f00..3d28d9e18 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -222,6 +222,12 @@ pub const SplitTree = extern struct { &single_tree, ); defer new_tree.deinit(); + log.debug( + "new split at={} direction={} old_tree={} new_tree={}", + .{ handle, direction, old_tree, &new_tree }, + ); + + // Replace our tree self.setTree(&new_tree); // Focus our new surface diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 68a7c09e7..61e04e6f6 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -610,6 +610,8 @@ pub fn SplitTree(comptime V: type) type { // the width/height based on node 0. const grid = grid: { // Get our initial width/height. Each leaf is 1x1 in this. + // We round up for this because partial widths/heights should + // take up an extra cell. var width: usize = @intFromFloat(@ceil(sp.slots[0].width)); var height: usize = @intFromFloat(@ceil(sp.slots[0].height)); @@ -637,10 +639,10 @@ pub fn SplitTree(comptime V: type) type { .split => continue, } - var x: usize = @intFromFloat(@ceil(slot.x)); - var y: usize = @intFromFloat(@ceil(slot.y)); - var width: usize = @intFromFloat(@ceil(slot.width)); - var height: usize = @intFromFloat(@ceil(slot.height)); + var x: usize = @intFromFloat(@floor(slot.x)); + var y: usize = @intFromFloat(@floor(slot.y)); + var width: usize = @intFromFloat(@max(@floor(slot.width), 1)); + var height: usize = @intFromFloat(@max(@floor(slot.height), 1)); x *= cell_width; y *= cell_height; width *= cell_width; @@ -806,13 +808,13 @@ test "SplitTree: single node" { test "SplitTree: split horizontal" { const testing = std.testing; const alloc = testing.allocator; + var v1: TestTree.View = .{ .label = "A" }; var t1: TestTree = try .init(alloc, &v1); defer t1.deinit(); var v2: TestTree.View = .{ .label = "B" }; var t2: TestTree = try .init(alloc, &v2); defer t2.deinit(); - var t3 = try t1.split( alloc, 0, // at root @@ -821,14 +823,45 @@ test "SplitTree: split horizontal" { ); defer t3.deinit(); - const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); - defer alloc.free(str); - try testing.expectEqualStrings(str, - \\+---++---+ - \\| A || B | - \\+---++---+ - \\ + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\ + ); + } + + var vC: TestTree.View = .{ .label = "C" }; + var tC: TestTree = try .init(alloc, &vC); + defer tC.deinit(); + + // Split right at B + var it = t3.iterator(); + var t4 = try t3.split( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound, + .right, + &tC, ); + defer t4.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t4}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---++---+ + \\| A || B || C | + \\+---++---++---+ + \\ + ); + } } test "SplitTree: split vertical" { From fbe28477ff409e65ea367986d7daecd056106013 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 7 Aug 2025 11:41:15 -0700 Subject: [PATCH 45/83] datastruct: fix split tree ascii diagram --- src/apprt/gtk-ng/class/split_tree.zig | 2 +- src/datastruct/split_tree.zig | 173 +++++++++++++++++++++----- 2 files changed, 143 insertions(+), 32 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 3d28d9e18..7172c931b 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -379,7 +379,7 @@ pub const SplitTree = extern struct { ) *gtk.Widget { switch (tree.nodes[current]) { .leaf => |v| { - // We have to setup our signal handlers. + v.as(gtk.Widget).unparent(); return v.as(gtk.Widget); }, diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 61e04e6f6..cab296081 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -409,22 +409,7 @@ pub fn SplitTree(comptime V: type) type { assert(reffed == nodes.len - 1); } - /// Spatial representation of the split tree. This can be used to - /// better understand the layout of the tree in a 2D space. - /// - /// The bounds of the representation are always based on each split - /// being exactly 1 unit wide and high. The x and y coordinates - /// are offsets into that space. This means that the spatial - /// representation is a normalized representation of the actual - /// space. - /// - /// The top-left corner of the tree is always (0, 0). - /// - /// We use a normalized form because we can calculate it without - /// accessing to the actual rendered view sizes. These actual sizes - /// may not be available at various times because GUI toolkits often - /// only make them available once they're part of a widget tree and - /// a SplitTree can represent views that aren't currently visible. + /// Spatial representation of the split tree. See spatial. pub const Spatial = struct { /// The slots of the spatial representation in the same order /// as the tree it was created from. @@ -445,8 +430,22 @@ pub fn SplitTree(comptime V: type) type { } }; - /// Returns the spatial representation of this tree. See Spatial - /// for more details. + /// Spatial representation of the split tree. This can be used to + /// better understand the layout of the tree in a 2D space. + /// + /// The bounds of the representation are always based on each split + /// being exactly 1 unit wide and high. The x and y coordinates + /// are offsets into that space. This means that the spatial + /// representation is a normalized representation of the actual + /// space. + /// + /// The top-left corner of the tree is always (0, 0). + /// + /// We use a normalized form because we can calculate it without + /// accessing to the actual rendered view sizes. These actual sizes + /// may not be available at various times because GUI toolkits often + /// only make them available once they're part of a widget tree and + /// a SplitTree can represent views that aren't currently visible. pub fn spatial( self: *const Self, alloc: Allocator, @@ -549,14 +548,20 @@ pub fn SplitTree(comptime V: type) type { }; } - /// Format the tree in a human-readable format. + /// Format the tree in a human-readable format. By default this will + /// output a diagram followed by a textual representation. This can + /// be controlled via the formatting string: + /// + /// - `diagram` - Output a diagram of the split tree only. + /// - `text` - Output a textual representation of the split tree only. + /// - Empty - Output both a diagram and a textual representation. + /// pub fn format( self: *const Self, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype, ) !void { - _ = fmt; _ = options; if (self.nodes.len == 0) { @@ -564,6 +569,48 @@ pub fn SplitTree(comptime V: type) type { return; } + if (std.mem.eql(u8, fmt, "diagram")) { + self.formatDiagram(writer) catch + try writer.writeAll("failed to draw split tree diagram"); + } else if (std.mem.eql(u8, fmt, "text")) { + try self.formatText(writer, 0, 0); + } else if (fmt.len == 0) { + self.formatDiagram(writer) catch {}; + try self.formatText(writer, 0, 0); + } else { + return error.InvalidFormat; + } + } + + fn formatText( + self: *const Self, + writer: anytype, + current: Node.Handle, + depth: usize, + ) !void { + for (0..depth) |_| try writer.writeAll(" "); + + switch (self.nodes[current]) { + .leaf => |v| if (@hasDecl(View, "splitTreeLabel")) + try writer.print("leaf: {s}\n", .{v.splitTreeLabel()}) + else + try writer.print("leaf: {d}\n", .{current}), + + .split => |s| { + try writer.print("split (layout: {s}, ratio: {d:.2})\n", .{ + @tagName(s.layout), + s.ratio, + }); + try self.formatText(writer, s.left, depth + 1); + try self.formatText(writer, s.right, depth + 1); + }, + } + } + + fn formatDiagram( + self: *const Self, + writer: anytype, + ) !void { // Use our arena's GPA to allocate some intermediate memory. // Requiring allocation for formatting is nasty but this is really // only used for debugging and testing and shouldn't hit OOM @@ -573,7 +620,29 @@ pub fn SplitTree(comptime V: type) type { const alloc = arena.allocator(); // Get our spatial representation. - const sp = try self.spatial(alloc); + const sp = spatial: { + const sp = try self.spatial(alloc); + + // Scale our spatial representation to have minimum width/height 1. + var min_w: f16 = 1; + var min_h: f16 = 1; + for (sp.slots) |slot| { + min_w = @min(min_w, slot.width); + min_h = @min(min_h, slot.height); + } + + const ratio_w: f16 = 1 / min_w; + const ratio_h: f16 = 1 / min_h; + const slots = try alloc.dupe(Spatial.Slot, sp.slots); + for (slots) |*slot| { + slot.x *= ratio_w; + slot.y *= ratio_h; + slot.width *= ratio_w; + slot.height *= ratio_h; + } + + break :spatial .{ .slots = slots }; + }; // The width we need for the largest label. const max_label_width: usize = max_label_width: { @@ -795,7 +864,7 @@ test "SplitTree: single node" { var t: TestTree = try .init(alloc, &v); defer t.deinit(); - const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t}); defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ @@ -830,15 +899,17 @@ test "SplitTree: split horizontal" { \\+---++---+ \\| A || B | \\+---++---+ + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ leaf: B \\ ); } + // Split right at B var vC: TestTree.View = .{ .label = "C" }; var tC: TestTree = try .init(alloc, &vC); defer tC.deinit(); - - // Split right at B var it = t3.iterator(); var t4 = try t3.split( alloc, @@ -856,12 +927,52 @@ test "SplitTree: split horizontal" { const str = try std.fmt.allocPrint(alloc, "{}", .{t4}); defer alloc.free(str); try testing.expectEqualStrings(str, - \\+---++---++---+ - \\| A || B || C | - \\+---++---++---+ + \\+--------++---++---+ + \\| A || B || C | + \\+--------++---++---+ + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ split (layout: horizontal, ratio: 0.50) + \\ leaf: B + \\ leaf: C \\ ); } + + // Split right at C + var vD: TestTree.View = .{ .label = "D" }; + var tD: TestTree = try .init(alloc, &vD); + defer tD.deinit(); + it = t4.iterator(); + var t5 = try t4.split( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "C")) { + break entry.handle; + } + } else return error.NotFound, + .right, + &tD, + ); + defer t5.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t5}); + defer alloc.free(str); + try testing.expectEqualStrings( + \\+------------------++--------++---++---+ + \\| A || B || C || D | + \\+------------------++--------++---++---+ + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ split (layout: horizontal, ratio: 0.50) + \\ leaf: B + \\ split (layout: horizontal, ratio: 0.50) + \\ leaf: C + \\ leaf: D + \\ + , str); + } } test "SplitTree: split vertical" { @@ -883,7 +994,7 @@ test "SplitTree: split vertical" { ); defer t3.deinit(); - const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t3}); defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ @@ -926,7 +1037,7 @@ test "SplitTree: remove leaf" { ); defer t4.deinit(); - const str = try std.fmt.allocPrint(alloc, "{}", .{t4}); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t4}); defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ @@ -969,7 +1080,7 @@ test "SplitTree: split twice, remove intermediary" { defer split2.deinit(); { - const str = try std.fmt.allocPrint(alloc, "{}", .{split2}); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split2}); defer alloc.free(str); try testing.expectEqualStrings(str, \\+---++---+ @@ -995,7 +1106,7 @@ test "SplitTree: split twice, remove intermediary" { defer split3.deinit(); { - const str = try std.fmt.allocPrint(alloc, "{}", .{split3}); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split3}); defer alloc.free(str); try testing.expectEqualStrings(str, \\+---+ From 517f17995c7ab8447433216442f9bccc6e4e87e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 14:35:34 -0700 Subject: [PATCH 46/83] apprt/gtk-ng: rebuild the widget tree on an idle callback --- src/apprt/gtk-ng/class/split_tree.zig | 56 +++++++++++++++++++++------ 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 7172c931b..fb228cf17 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -118,6 +118,10 @@ pub const SplitTree = extern struct { // Template bindings tree_bin: *adw.Bin, + /// The source that we use to rebuild the tree. This is also + /// used to debounce updates. + rebuild_source: ?c_uint = null, + pub var offset: c_int = 0; }; @@ -310,6 +314,14 @@ pub const SplitTree = extern struct { // Virtual methods fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -357,16 +369,40 @@ pub const SplitTree = extern struct { _: ?*anyopaque, ) callconv(.c) void { const priv = self.private(); - const tree: *const Surface.Tree = self.private().tree orelse &.empty; - // Reset our widget tree. + // We need to reset our tree and create the new widget hierarchy + // on two separate event loop ticks to allow GTK to properly relayout + // our widgets. + // + // Doing this all at once will cause strange rendering glitches, + // the glarea to be gone forever (but not deallocated), etc. I think + // this is probably a bug in GTK we can minimize and report later. + // + // Using an idle callback also allows us to debounce updates. priv.tree_bin.setChild(null); + if (priv.rebuild_source == null) priv.rebuild_source = glib.idleAdd( + onRebuild, + self, + ); + + // Dependent properties + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + } + + fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our rebuild source as null since we're done. + const priv = self.private(); + priv.rebuild_source = null; + + // Rebuild our tree + const tree: *const Surface.Tree = self.private().tree orelse &.empty; if (!tree.isEmpty()) { priv.tree_bin.setChild(buildTree(tree, 0)); } - // Dependent properties - self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + return 0; } /// Builds the widget tree associated with a surface split tree. @@ -377,13 +413,9 @@ pub const SplitTree = extern struct { tree: *const Surface.Tree, current: Surface.Tree.Node.Handle, ) *gtk.Widget { - switch (tree.nodes[current]) { - .leaf => |v| { - v.as(gtk.Widget).unparent(); - return v.as(gtk.Widget); - }, - - .split => |s| return gobject.ext.newInstance( + return switch (tree.nodes[current]) { + .leaf => |v| v.as(gtk.Widget), + .split => |s| gobject.ext.newInstance( gtk.Paned, .{ .orientation = @as(gtk.Orientation, switch (s.layout) { @@ -395,7 +427,7 @@ pub const SplitTree = extern struct { // TODO: position/ratio }, ).as(gtk.Widget), - } + }; } //--------------------------------------------------------------- From a3c041bcb4de25fbbfdbba0ddcb2f49106ce6957 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 15:03:28 -0700 Subject: [PATCH 47/83] apprt/gtk-ng: keep track of last focused surface --- src/apprt/gtk-ng/class/application.zig | 1 + src/apprt/gtk-ng/class/split_tree.zig | 76 ++++++++++++++++++++++++++ valgrind.supp | 36 +++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 9c0a821cd..226d7c56b 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1257,6 +1257,7 @@ pub const Application = extern struct { diag.close(); diag.unref(); // strong ref from get() } + priv.config_errors_dialog.set(null); if (priv.signal_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove signal source", .{}); diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index fb228cf17..22cae7ee2 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -17,6 +17,7 @@ const adw_version = @import("../adw_version.zig"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; +const WeakRef = @import("../weak_ref.zig").WeakRef; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; @@ -118,6 +119,10 @@ pub const SplitTree = extern struct { // Template bindings tree_bin: *adw.Bin, + /// Last focused surface in the tree. We need this to handle various + /// tree change states. + last_focused: WeakRef(Surface) = .{}, + /// The source that we use to rebuild the tree. This is also /// used to debounce updates. rebuild_source: ?c_uint = null, @@ -208,6 +213,13 @@ pub const SplitTree = extern struct { var single_tree = try Surface.Tree.init(alloc, surface); defer single_tree.deinit(); + // We want to move our focus to the new surface no matter what. + // But we need to be careful to restore state if we fail. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + // If we have no tree yet, then this becomes our tree and we're done. const old_tree = self.getTree() orelse { self.setTree(&single_tree); @@ -238,6 +250,38 @@ pub const SplitTree = extern struct { surface.grabFocus(); } + fn disconnectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + + fn connectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceFocused, + self, + .{ .detail = "focused" }, + ); + } + } + //--------------------------------------------------------------- // Properties @@ -259,6 +303,15 @@ pub const SplitTree = extern struct { return null; } + /// Returns the last focused surface in the tree. + pub fn getLastFocusedSurface(self: *Self) ?*Surface { + const surface = self.private().last_focused.get() orelse return null; + // We unref because get() refs the surface. We don't use the weakref + // in a multi-threaded context so this is safe. + surface.unref(); + return surface; + } + pub fn getHasSurfaces(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; return !tree.isEmpty(); @@ -285,12 +338,14 @@ pub const SplitTree = extern struct { ); if (priv.tree) |old_tree| { + self.disconnectSurfaceHandlers(); ext.boxedFree(Surface.Tree, old_tree); priv.tree = null; } if (tree) |new_tree| { priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + self.connectSurfaceHandlers(); } self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); @@ -315,6 +370,7 @@ pub const SplitTree = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); + priv.last_focused.set(null); if (priv.rebuild_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove rebuild source", .{}); @@ -363,6 +419,18 @@ pub const SplitTree = extern struct { }; } + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // We never CLEAR our last_focused because the property is specifically + // the last focused surface. We let the weakref clear itself when + // the surface is destroyed. + if (!surface.getFocused()) return; + self.private().last_focused.set(surface); + } + fn propTree( self: *Self, _: *gobject.ParamSpec, @@ -402,6 +470,14 @@ pub const SplitTree = extern struct { priv.tree_bin.setChild(buildTree(tree, 0)); } + // If we have a last focused surface, we need to refocus it, because + // during the frame between setting the bin to null and rebuilding, + // GTK will reset our focus state (as it should!) + if (priv.last_focused.get()) |v| { + defer v.unref(); + v.grabFocus(); + } + return 0; } diff --git a/valgrind.supp b/valgrind.supp index 0ce99e34a..45a9c3ea7 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -45,6 +45,38 @@ ... } +# Reproduction: +# +# 1. Launch Ghostty +# 2. Split Right +# 3. Hit "X" to close +{ + GTK CSS Node State + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + fun:g_malloc + fun:g_memdup2 + fun:gtk_css_node_declaration_set_state + fun:gtk_css_node_set_state + fun:gtk_widget_propagate_state + fun:gtk_widget_update_state_flags + fun:gtk_main_do_event + fun:surface_event + fun:_gdk_marshal_BOOLEAN__POINTERv + fun:gdk_surface_event_marshallerv + fun:_g_closure_invoke_va + fun:signal_emit_valist_unlocked + fun:g_signal_emit_valist + fun:g_signal_emit + fun:gdk_surface_handle_event + fun:gdk_wayland_event_source_dispatch + fun:g_main_context_dispatch_unlocked + fun:g_main_context_iterate_unlocked.isra.0 + fun:g_main_context_iteration + ... +} + { GTK CSS Provider Leak Memcheck:Leak @@ -516,9 +548,7 @@ pango font map Memcheck:Leak match-leak-kinds: possible - fun:calloc - fun:g_malloc0 - fun:g_rc_box_alloc_full + ... fun:pango_fc_font_map_load_fontset ... } From e396d9d78d651a5dd1ad60fcb283c4e7adca8952 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 8 Aug 2025 15:43:05 -0700 Subject: [PATCH 48/83] apprt/gtk-ng: setup gtk paned listeners to set position --- src/apprt/gtk-ng/build/gresource.zig | 1 + src/apprt/gtk-ng/class/split_tree.zig | 283 ++++++++++++++++++- src/apprt/gtk-ng/ui/1.5/split-tree-split.blp | 16 ++ 3 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 src/apprt/gtk-ng/ui/1.5/split-tree-split.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 0f7237331..0818a98f6 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -41,6 +41,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 3, .name = "debug-warning" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, + .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 22cae7ee2..d30b8ce4d 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -467,7 +467,7 @@ pub const SplitTree = extern struct { // Rebuild our tree const tree: *const Surface.Tree = self.private().tree orelse &.empty; if (!tree.isEmpty()) { - priv.tree_bin.setChild(buildTree(tree, 0)); + priv.tree_bin.setChild(self.buildTree(tree, 0)); } // If we have a last focused surface, we need to refocus it, because @@ -486,22 +486,17 @@ pub const SplitTree = extern struct { /// The final returned widget is expected to be a floating reference, /// ready to be attached to a parent widget. fn buildTree( + self: *Self, tree: *const Surface.Tree, current: Surface.Tree.Node.Handle, ) *gtk.Widget { return switch (tree.nodes[current]) { .leaf => |v| v.as(gtk.Widget), - .split => |s| gobject.ext.newInstance( - gtk.Paned, - .{ - .orientation = @as(gtk.Orientation, switch (s.layout) { - .horizontal => .horizontal, - .vertical => .vertical, - }), - .@"start-child" = buildTree(tree, s.left), - .@"end-child" = buildTree(tree, s.right), - // TODO: position/ratio - }, + .split => |s| SplitTreeSplit.new( + current, + &s, + self.buildTree(tree, s.left), + self.buildTree(tree, s.right), ).as(gtk.Widget), }; } @@ -556,3 +551,267 @@ pub const SplitTree = extern struct { pub const bindTemplateCallback = C.Class.bindTemplateCallback; }; }; + +/// This is an internal-only widget that represents a split in the +/// split tree. This is a wrapper around gtk.Paned that allows us to handle +/// ratio (0 to 1) based positioning of the split, and also allows us to +/// write back the updated ratio to the split tree when the user manually +/// adjusts the split position. +/// +/// Since this is internal, it expects to be nested within a SplitTree and +/// will use `getAncestor` to find the SplitTree it belongs to. +/// +/// This is an _immutable_ widget. It isn't meant to be updated after +/// creation. As such, there are no properties or APIs to change the split, +/// access the paned, etc. +const SplitTreeSplit = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTreeSplit", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const Private = struct { + /// The handle of the node in the tree that this split represents. + /// Assumed to be correct. + handle: Surface.Tree.Node.Handle, + + /// Source to handle repositioning the split when properties change. + idle: ?c_uint = null, + + // Template bindings + paned: *gtk.Paned, + + pub var offset: c_int = 0; + }; + + /// Create a new split. + /// + /// The reason we don't use GObject properties here is because this is + /// an immutable widget and we don't want to deal with the overhead of + /// all the boilerplate for properties, signals, bindings, etc. + pub fn new( + handle: Surface.Tree.Node.Handle, + split: *const Surface.Tree.Split, + start_child: *gtk.Widget, + end_child: *gtk.Widget, + ) *Self { + const self = gobject.ext.newInstance(Self, .{}); + const priv = self.private(); + priv.handle = handle; + + // Setup our paned fields + const paned = priv.paned; + paned.setStartChild(start_child); + paned.setEndChild(end_child); + paned.as(gtk.Orientable).setOrientation(switch (split.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }); + + // Signals and so on are setup in the template. + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn refresh(self: *Self) void { + const priv = self.private(); + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, + self, + ); + } + + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + const paned = priv.paned; + + // Our idle source is always over + priv.idle = null; + + // Get our split. This is the most dangerous part of this entire + // widget. We assume that this widget is always a child of a + // SplitTree, we assume that our handle is valid, and we assume + // the handle is always a split node. + const split: *Surface.Tree.Split = split: { + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse return 0; + const tree = split_tree.getTree() orelse return 0; + // TODO: fix this constCast + break :split @constCast(&tree.nodes[priv.handle].split); + }; + + // Current, min, and max positions as pixels. + const pos = paned.getPosition(); + const min = min: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "min-position", + &val, + ); + break :min gobject.ext.Value.get(&val, c_int); + }; + const max = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "max-position", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int); + }; + + // We don't actually use min, but we don't expect this to ever + // be non-zero, so let's add an assert to ensure that. + assert(min == 0); + + // If our max is zero then we can't do any math. I don't know + // if this is possible but I suspect it can be if you make a nested + // split completely minimized. + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; + }; + const desired_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to our desired ratio, then + // we ignore the update. This is to avoid constant split updates + // for lossy floating point math. + if (std.math.approxEqAbs( + f64, + current_ratio, + desired_ratio, + 0.001, + )) { + return 0; + } + + // If we're out of bounds, then we need to either set the position + // to what we expect OR update our expected ratio. + + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + + log.warn("DESIRED={} CURRENT={}", .{ desired_ratio, current_ratio }); + + return 0; + } + + //--------------------------------------------------------------- + // Signal handlers + + fn propPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + fn propMaxPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + fn propMinPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.idle) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree-split", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("paned", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_max_position", &propMaxPosition); + class.bindTemplateCallback("notify_min_position", &propMinPosition); + class.bindTemplateCallback("notify_position", &propPosition); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp b/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp new file mode 100644 index 000000000..aa194e8e8 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTreeSplit: Adw.Bin { + // The double-nesting is required due to a GTK bug where you can't + // bind the first child of a builder layout. If you do, you get a double + // dispose. Easiest way to see that is simply remove this and see the + // GTK critical errors (and sometimes crashes). + Adw.Bin { + Paned paned { + notify::max-position => $notify_max_position(); + notify::min-position => $notify_min_position(); + notify::position => $notify_position(); + } + } +} From 34be4de018011daa946a5fd12833b30ede3fad0b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 12:19:03 -0700 Subject: [PATCH 49/83] apprt/gtk-ng: write back split ratio to tree --- src/apprt/gtk-ng/class/split_tree.zig | 43 +++++++++++++++++---------- src/datastruct/split_tree.zig | 21 +++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index d30b8ce4d..cd03297b4 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -643,15 +643,12 @@ const SplitTreeSplit = extern struct { // widget. We assume that this widget is always a child of a // SplitTree, we assume that our handle is valid, and we assume // the handle is always a split node. - const split: *Surface.Tree.Split = split: { - const split_tree = ext.getAncestor( - SplitTree, - self.as(gtk.Widget), - ) orelse return 0; - const tree = split_tree.getTree() orelse return 0; - // TODO: fix this constCast - break :split @constCast(&tree.nodes[priv.handle].split); - }; + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse return 0; + const tree = split_tree.getTree() orelse return 0; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle].split; // Current, min, and max positions as pixels. const pos = paned.getPosition(); @@ -675,6 +672,16 @@ const SplitTreeSplit = extern struct { ); break :max gobject.ext.Value.get(&val, c_int); }; + const pos_set: bool = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "position-set", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int) != 0; + }; // We don't actually use min, but we don't expect this to ever // be non-zero, so let's add an assert to ensure that. @@ -708,13 +715,19 @@ const SplitTreeSplit = extern struct { // If we're out of bounds, then we need to either set the position // to what we expect OR update our expected ratio. - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); + // If we've never set the position, then we set it to the desired. + if (!pos_set) { + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + return 0; + } - log.warn("DESIRED={} CURRENT={}", .{ desired_ratio, current_ratio }); + // If we've set the position, then this is a manual human update + // and we need to write our update back to the tree. + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); return 0; } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index cab296081..d0233986a 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -174,6 +174,27 @@ pub fn SplitTree(comptime V: type) type { } }; + /// Resize the given node in place. The node MUST be a split (asserted). + /// + /// In general, this is an immutable data structure so this is + /// heavily discouraged. However, this is provided for convenience + /// and performance reasons where its very important for GUIs to + /// update the ratio during a live resize than to redraw the entire + /// widget tree. + pub fn resizeInPlace( + self: *Self, + at: Node.Handle, + ratio: f16, + ) void { + // Let's talk about this constCast. Our member are const but + // we actually always own their memory. We don't want consumers + // who directly access the nodes to be able to modify them + // (without nasty stuff like this), but given this is internal + // usage its perfectly fine to modify the node in-place. + const s: *Split = @constCast(&self.nodes[at].split); + s.ratio = ratio; + } + /// Insert another tree into this tree at the given node in the /// specified direction. The other tree will be inserted in the /// new direction. For example, if the direction is "right" then From 9ad92d2c3d6c3a80b7abc2dbe9c6d7d478f38d06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 12:21:24 -0700 Subject: [PATCH 50/83] apprt/gtk-ng: proper split operations --- src/apprt/gtk-ng/class/split_tree.zig | 52 ++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index cd03297b4..bc6bc6c3c 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -148,10 +148,10 @@ pub const SplitTree = extern struct { const actions = .{ // All of these will eventually take a target surface parameter. // For now all our targets originate from the focused surface. - .{ "new-left", actionNew, null }, - .{ "new-right", actionNew, null }, - .{ "new-up", actionNew, null }, - .{ "new-down", actionNew, null }, + .{ "new-left", actionNewLeft, null }, + .{ "new-right", actionNewRight, null }, + .{ "new-up", actionNewUp, null }, + .{ "new-down", actionNewDown, null }, }; // We need to collect our actions into a group since we're just @@ -405,7 +405,21 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Signal handlers - pub fn actionNew( + pub fn actionNewLeft( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .left, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionNewRight( _: *gio.SimpleAction, parameter_: ?*glib.Variant, self: *Self, @@ -419,6 +433,34 @@ pub const SplitTree = extern struct { }; } + pub fn actionNewUp( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .up, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionNewDown( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .down, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + fn propSurfaceFocused( surface: *Surface, _: *gobject.ParamSpec, From a28d6734679286ce99a037c765512eb69de086f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 12:24:40 -0700 Subject: [PATCH 51/83] update supps --- valgrind.supp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/valgrind.supp b/valgrind.supp index 45a9c3ea7..cf82b7c2a 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -70,10 +70,6 @@ fun:g_signal_emit_valist fun:g_signal_emit fun:gdk_surface_handle_event - fun:gdk_wayland_event_source_dispatch - fun:g_main_context_dispatch_unlocked - fun:g_main_context_iterate_unlocked.isra.0 - fun:g_main_context_iteration ... } From 8232cf33b4420b9ec8a4d066723ae1a5059e3ccd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 12:36:24 -0700 Subject: [PATCH 52/83] apprt/gtk-ng: surface close in split tree --- src/apprt/gtk-ng/class/split_tree.zig | 46 +++++++++++++++++++++++++++ src/apprt/gtk-ng/class/tab.zig | 43 ++++++++----------------- src/datastruct/split_tree.zig | 3 ++ 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index bc6bc6c3c..ae945027e 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -272,6 +272,13 @@ pub const SplitTree = extern struct { var it = tree.iterator(); while (it.next()) |entry| { const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); _ = gobject.Object.signals.notify.connect( surface, *Self, @@ -461,6 +468,45 @@ pub const SplitTree = extern struct { }; } + fn surfaceCloseRequest( + surface: *Surface, + scope: *const Surface.CloseScope, + self: *Self, + ) callconv(.c) void { + switch (scope.*) { + // Handled upstream... this will probably go away for widget + // actions eventually. + .window, .tab => return, + + // Remove the surface from the tree. + .surface => { + // TODO: close confirmation + + // Find the surface in the tree. + const tree = self.getTree() orelse return; + const handle: Surface.Tree.Node.Handle = handle: { + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == surface) break :handle entry.handle; + } + + return; + }; + + // Remove it from the tree. + var new_tree = tree.remove( + Application.default().allocator(), + handle, + ) catch |err| { + log.warn("unable to remove surface from tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + }, + } + } + fn propSurfaceFocused( surface: *Surface, _: *gobject.ParamSpec, diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 4b75701bf..964f6fc3e 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -182,13 +182,6 @@ pub const Tab = extern struct { var it = tree.iterator(); while (it.next()) |entry| { const surface = entry.view; - _ = Surface.signals.@"close-request".connect( - surface, - *Self, - surfaceCloseRequest, - self, - .{}, - ); _ = gobject.Object.signals.notify.connect( surface, *Self, @@ -285,27 +278,6 @@ pub const Tab = extern struct { //--------------------------------------------------------------- // Signal handlers - fn surfaceCloseRequest( - _: *Surface, - scope: *const Surface.CloseScope, - self: *Self, - ) callconv(.c) void { - switch (scope.*) { - // Handled upstream... we don't control our window close. - .window => return, - - // Presently both the same, results in the tab closing. - .surface, .tab => { - signals.@"close-request".impl.emit( - self, - null, - .{}, - null, - ); - }, - } - } - fn splitTreeChanged( _: *SplitTree, old_tree: ?*const Surface.Tree, @@ -316,9 +288,20 @@ pub const Tab = extern struct { self.disconnectSurfaceHandlers(tree); } - if (new_tree) |tree| { - self.connectSurfaceHandlers(tree); + // If our tree is empty we close the tab. + const tree: *const Surface.Tree = new_tree orelse &.empty; + if (tree.isEmpty()) { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + return; } + + // Non-empty tree, connect handlers we care about. + self.connectSurfaceHandlers(tree); } fn propSplitTree( diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index d0233986a..a1f2de035 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -119,6 +119,9 @@ pub fn SplitTree(comptime V: type) type { /// Clone this tree, returning a new tree with the same nodes. pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self { + // If we're empty then return an empty tree. + if (self.isEmpty()) return .empty; + // Create a new arena allocator for the clone. var arena = ArenaAllocator.init(gpa); errdefer arena.deinit(); From ec293c1fd0eeebe7ce72e62c088199937f375877 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 13:49:25 -0700 Subject: [PATCH 53/83] apprt/gtk-ng: active surface hookups --- src/apprt/gtk-ng/class/split_tree.zig | 10 ++++- src/apprt/gtk-ng/class/tab.zig | 54 +-------------------------- src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- src/datastruct/split_tree.zig | 18 +++++++++ 4 files changed, 29 insertions(+), 55 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index ae945027e..b0c1a34a3 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -49,8 +49,6 @@ pub const SplitTree = extern struct { Self, ?*Surface, .{ - .nick = "Active Surface", - .blurb = "The currently active surface.", .accessor = gobject.ext.typedAccessor( Self, ?*Surface, @@ -481,6 +479,7 @@ pub const SplitTree = extern struct { // Remove the surface from the tree. .surface => { // TODO: close confirmation + // TODO: invalid free on final close // Find the surface in the tree. const tree = self.getTree() orelse return; @@ -517,6 +516,9 @@ pub const SplitTree = extern struct { // the surface is destroyed. if (!surface.getFocused()) return; self.private().last_focused.set(surface); + + // Our active surface probably changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } fn propTree( @@ -566,6 +568,9 @@ pub const SplitTree = extern struct { v.grabFocus(); } + // Our active surface may have changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + return 0; } @@ -616,6 +621,7 @@ pub const SplitTree = extern struct { // Properties gobject.ext.registerProperties(class, &.{ + properties.@"active-surface".impl, properties.@"has-surfaces".impl, properties.tree.impl, }); diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 964f6fc3e..051807071 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -175,42 +175,6 @@ pub const Tab = extern struct { }; } - fn connectSurfaceHandlers( - self: *Self, - tree: *const Surface.Tree, - ) void { - var it = tree.iterator(); - while (it.next()) |entry| { - const surface = entry.view; - _ = gobject.Object.signals.notify.connect( - surface, - *Self, - propSurfaceFocused, - self, - .{ .detail = "focused" }, - ); - } - } - - fn disconnectSurfaceHandlers( - self: *Self, - tree: *const Surface.Tree, - ) void { - var it = tree.iterator(); - while (it.next()) |entry| { - const surface = entry.view; - _ = gobject.signalHandlersDisconnectMatched( - surface.as(gobject.Object), - .{ .data = true }, - 0, - 0, - null, - null, - self, - ); - } - } - //--------------------------------------------------------------- // Properties @@ -280,14 +244,10 @@ pub const Tab = extern struct { fn splitTreeChanged( _: *SplitTree, - old_tree: ?*const Surface.Tree, + _: ?*const Surface.Tree, new_tree: ?*const Surface.Tree, self: *Self, ) callconv(.c) void { - if (old_tree) |tree| { - self.disconnectSurfaceHandlers(tree); - } - // If our tree is empty we close the tab. const tree: *const Surface.Tree = new_tree orelse &.empty; if (tree.isEmpty()) { @@ -299,9 +259,6 @@ pub const Tab = extern struct { ); return; } - - // Non-empty tree, connect handlers we care about. - self.connectSurfaceHandlers(tree); } fn propSplitTree( @@ -313,7 +270,7 @@ pub const Tab = extern struct { } fn propActiveSurface( - _: *Self, + _: *SplitTree, _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { @@ -322,14 +279,7 @@ pub const Tab = extern struct { if (self.getActiveSurface()) |surface| { priv.surface_bindings.setSource(surface.as(gobject.Object)); } - } - fn propSurfaceFocused( - surface: *Surface, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - if (!surface.getFocused()) return; self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 61f106ce1..70e4f709b 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,12 +5,12 @@ template $GhosttyTab: Box { "tab", ] - notify::active-surface => $notify_active_surface(); orientation: vertical; hexpand: true; vexpand: true; $GhosttySplitTree split_tree { + notify::active-surface => $notify_active_surface(); notify::tree => $notify_tree(); changed => $tree_changed(); } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index a1f2de035..48b887707 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1151,3 +1151,21 @@ test "SplitTree: split twice, remove intermediary" { t.deinit(); } } + +test "SplitTree: clone empty tree" { + const testing = std.testing; + const alloc = testing.allocator; + var t: TestTree = .empty; + defer t.deinit(); + + var t2 = try t.clone(alloc); + defer t2.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t2}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\empty + ); + } +} From aed6a3a343fc9621c74a10fceee530740ec8b183 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 14:19:42 -0700 Subject: [PATCH 54/83] apprt/gtk-ng: clean up some changed handlers --- src/apprt/gtk-ng/class/split_tree.zig | 13 ++++++++++++- src/apprt/gtk-ng/class/tab.zig | 18 +++++------------- src/apprt/gtk-ng/ui/1.5/tab.blp | 1 - src/datastruct/split_tree.zig | 6 ++++-- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index b0c1a34a3..1cfaeb669 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -330,9 +330,18 @@ pub const SplitTree = extern struct { /// Set the tree data model that we're showing in this widget. This /// will clone the given tree. - pub fn setTree(self: *Self, tree: ?*const Surface.Tree) void { + pub fn setTree(self: *Self, tree_: ?*const Surface.Tree) void { const priv = self.private(); + // We always normalize our tree parameter so that empty trees + // become null so that we don't have to deal with callers being + // confused about that. + const tree: ?*const Surface.Tree = tree: { + const tree = tree_ orelse break :tree null; + if (tree.isEmpty()) break :tree null; + break :tree tree; + }; + // Emit the signal so that handlers can witness both the before and // after values of the tree. signals.changed.impl.emit( @@ -349,6 +358,8 @@ pub const SplitTree = extern struct { } if (tree) |new_tree| { + assert(priv.tree == null); + assert(!new_tree.isEmpty()); priv.tree = ext.boxedCopy(Surface.Tree, new_tree); self.connectSurfaceHandlers(); } diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 051807071..428ce72d6 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -242,14 +242,15 @@ pub const Tab = extern struct { //--------------------------------------------------------------- // Signal handlers - fn splitTreeChanged( + fn propSplitTree( _: *SplitTree, - _: ?*const Surface.Tree, - new_tree: ?*const Surface.Tree, + _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); + // If our tree is empty we close the tab. - const tree: *const Surface.Tree = new_tree orelse &.empty; + const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty; if (tree.isEmpty()) { signals.@"close-request".impl.emit( self, @@ -261,14 +262,6 @@ pub const Tab = extern struct { } } - fn propSplitTree( - _: *SplitTree, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); - } - fn propActiveSurface( _: *SplitTree, _: *gobject.ParamSpec, @@ -318,7 +311,6 @@ pub const Tab = extern struct { class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks - class.bindTemplateCallback("tree_changed", &splitTreeChanged); class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 70e4f709b..4cb47487d 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -12,6 +12,5 @@ template $GhosttyTab: Box { $GhosttySplitTree split_tree { notify::active-surface => $notify_active_surface(); notify::tree => $notify_tree(); - changed => $tree_changed(); } } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 48b887707..14ef6370e 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -826,8 +826,10 @@ pub fn SplitTree(comptime V: type) type { .copy = &struct { fn copy(self: *Self) callconv(.c) *Self { const ptr = @import("glib").ext.create(Self); - const alloc = self.arena.child_allocator; - ptr.* = self.clone(alloc) catch @panic("oom"); + ptr.* = if (self.nodes.len == 0) + .empty + else + self.clone(self.arena.child_allocator) catch @panic("oom"); return ptr; } }.copy, From e682e99bf5934d5f1031966a07a21245e066fbc4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 14:26:57 -0700 Subject: [PATCH 55/83] apprt/gtk-ng: hook up win split actions --- src/apprt/gtk-ng/class/application.zig | 25 +++++++++++++++++- src/apprt/gtk-ng/class/split_tree.zig | 1 - src/apprt/gtk-ng/class/window.zig | 36 ++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 226d7c56b..523a98033 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -562,6 +562,8 @@ pub const Application = extern struct { .move_tab => return Action.moveTab(target, value), + .new_split => return Action.newSplit(target, value), + .new_tab => return Action.newTab(target), .new_window => try Action.newWindow( @@ -611,7 +613,6 @@ pub const Application = extern struct { .prompt_title, .inspector, // TODO: splits - .new_split, .resize_split, .equalize_splits, .goto_split, @@ -1747,6 +1748,28 @@ const Action = struct { } } + pub fn newSplit( + target: apprt.Target, + direction: apprt.action.SplitDirection, + ) bool { + switch (target) { + .app => { + log.warn("new split to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction(switch (direction) { + .right => "split-tree.new-right", + .left => "split-tree.new-left", + .down => "split-tree.new-down", + .up => "split-tree.new-up", + }, null) != 0; + }, + } + } + pub fn newTab(target: apprt.Target) bool { switch (target) { .app => { diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 1cfaeb669..62caf7108 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -490,7 +490,6 @@ pub const SplitTree = extern struct { // Remove the surface from the tree. .surface => { // TODO: close confirmation - // TODO: invalid free on final close // Find the surface in the tree. const tree = self.getTree() orelse return; diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 739405961..f3e8ee129 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -335,6 +335,10 @@ pub const Window = extern struct { .{ "close-tab", actionCloseTab, null }, .{ "new-tab", actionNewTab, null }, .{ "new-window", actionNewWindow, null }, + .{ "split-right", actionSplitRight, null }, + .{ "split-left", actionSplitLeft, null }, + .{ "split-up", actionSplitUp, null }, + .{ "split-down", actionSplitDown, null }, .{ "copy", actionCopy, null }, .{ "paste", actionPaste, null }, .{ "reset", actionReset, null }, @@ -1650,6 +1654,38 @@ pub const Window = extern struct { self.performBindingAction(.new_tab); } + fn actionSplitRight( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .right }); + } + + fn actionSplitLeft( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .left }); + } + + fn actionSplitUp( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .up }); + } + + fn actionSplitDown( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .down }); + } + fn actionCopy( _: *gio.SimpleAction, _: ?*glib.Variant, From b1da644b62a780bb0f2624f57cb3344473eb6220 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 9 Aug 2025 14:43:34 -0700 Subject: [PATCH 56/83] apprt/gtk-ng: unnecessary grab focus --- src/apprt/gtk-ng/class/split_tree.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 62caf7108..145730989 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -243,9 +243,6 @@ pub const SplitTree = extern struct { // Replace our tree self.setTree(&new_tree); - - // Focus our new surface - surface.grabFocus(); } fn disconnectSurfaceHandlers(self: *Self) void { From c64701e744ac91e676261aad9e9ecae5d4ec2b10 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:15:37 +0000 Subject: [PATCH 57/83] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 0fa9476cb..ee283870b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -112,8 +112,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz", - .hash = "N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz", + .hash = "N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 3bdda9b00..190fc1cd2 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv": { + "N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz", - "hash": "sha256-w/biUQZ+AJv0atXypwQxJlKkHRUaFS0AlE/VlBJXlVU=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz", + "hash": "sha256-gl42NOZ59ok+umHCHbdBQhWCgFVpj5PAZDVGhJRpbiA=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 25822ebed..3708d61ed 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -162,11 +162,11 @@ in }; } { - name = "N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv"; + name = "N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz"; - hash = "sha256-w/biUQZ+AJv0atXypwQxJlKkHRUaFS0AlE/VlBJXlVU="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz"; + hash = "sha256-gl42NOZ59ok+umHCHbdBQhWCgFVpj5PAZDVGhJRpbiA="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index accc9fca6..2f3a9cab9 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -28,7 +28,7 @@ https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21a https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index a5e0783f0..56f4dc6a4 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz", - "dest": "vendor/p/N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv", - "sha256": "c3f6e251067e009bf46ad5f2a704312652a41d151a152d00944fd59412579555" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz", + "dest": "vendor/p/N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu", + "sha256": "825e3634e679f6893eba61c21db7414215828055698f93c06435468494696e20" }, { "type": "archive", From 46560d00182c174002ce608563a5a163e9cb7f9d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Aug 2025 07:30:06 -0700 Subject: [PATCH 58/83] apprt/gtk-ng: wait for unparent to rebuild split tree --- src/apprt/gtk-ng/class/split_tree.zig | 96 ++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 145730989..e547fa6d7 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -125,6 +125,11 @@ pub const SplitTree = extern struct { /// used to debounce updates. rebuild_source: ?c_uint = null, + /// Tracks whether we want a rebuild to happen at the next tick + /// that our surface tree has no surfaces with parents. See the + /// propTree function for a lot more details. + rebuild_pending: bool, + pub var offset: c_int = 0; }; @@ -281,6 +286,13 @@ pub const SplitTree = extern struct { self, .{ .detail = "focused" }, ); + _ = gobject.Object.signals.notify.connect( + surface.as(gtk.Widget), + *Self, + propSurfaceParent, + self, + .{ .detail = "parent" }, + ); } } @@ -314,6 +326,20 @@ pub const SplitTree = extern struct { return surface; } + /// Returns whether any of the surfaces in the tree have a parent. + /// This is important because we can only rebuild the widget tree + /// when every surface has no parent. + fn getTreeHasParents(self: *Self) bool { + const tree: *const Surface.Tree = self.getTree() orelse &.empty; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + if (surface.as(gtk.Widget).getParent() != null) return true; + } + + return false; + } + pub fn getHasSurfaces(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; return !tree.isEmpty(); @@ -528,6 +554,27 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } + fn propSurfaceParent( + _: *gtk.Widget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // If we're not waiting to rebuild then ignore this. + if (!priv.rebuild_pending) return; + + // If any parents still exist in our tree then don't do anything. + if (self.getTreeHasParents()) return; + + // Schedule the rebuild. Note, I tried to do this immediately (not + // on an idle tick) and it didn't work and had obvious rendering + // glitches. Something to look into in the future. + assert(priv.rebuild_source == null); + priv.rebuild_pending = false; + priv.rebuild_source = glib.idleAdd(onRebuild, self); + } + fn propTree( self: *Self, _: *gobject.ParamSpec, @@ -535,20 +582,43 @@ pub const SplitTree = extern struct { ) callconv(.c) void { const priv = self.private(); - // We need to reset our tree and create the new widget hierarchy - // on two separate event loop ticks to allow GTK to properly relayout - // our widgets. + // If we were planning a rebuild, always remove that so we can + // start from a clean slate. + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + + // We need to wait for all our previous surfaces to lose their + // parent before adding them to a new one. I'm not sure if its a GTK + // bug, but manually forcing an unparent of all prior surfaces AND + // adding them to a new parent in the same tick causes the GLArea + // to break (it seems). I didn't investigate too deeply. // - // Doing this all at once will cause strange rendering glitches, - // the glarea to be gone forever (but not deallocated), etc. I think - // this is probably a bug in GTK we can minimize and report later. + // Note, we also can't just defer to an idle tick (via idleAdd) because + // sometimes it takes more than one tick for all our surfaces to + // lose their parent. // - // Using an idle callback also allows us to debounce updates. + // To work around this issue, if we have any surfaces that have + // a parent, we set the build pending flag and wait for the tree + // to be fully parent-free before building. + priv.rebuild_pending = self.getTreeHasParents(); + + // Reset our prior bin. This will force all prior surfaces to + // unparent... eventually. priv.tree_bin.setChild(null); - if (priv.rebuild_source == null) priv.rebuild_source = glib.idleAdd( - onRebuild, - self, - ); + + // If none of the surfaces we plan on drawing require an unparent + // then we can setup our tree immediately. Otherwise, it'll happen + // via the `propSurfaceParent` callback. + if (!priv.rebuild_pending and priv.rebuild_source == null) { + priv.rebuild_source = glib.idleAdd( + onRebuild, + self, + ); + } // Dependent properties self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); @@ -561,6 +631,10 @@ pub const SplitTree = extern struct { const priv = self.private(); priv.rebuild_source = null; + // Prior to rebuilding the tree, our surface tree must be + // comprised of fully orphaned surfaces. + assert(!self.getTreeHasParents()); + // Rebuild our tree const tree: *const Surface.Tree = self.private().tree orelse &.empty; if (!tree.isEmpty()) { From 441af8389b9227eb74fd75e89dd7f14ebce6a69c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Aug 2025 13:05:17 -0700 Subject: [PATCH 59/83] apprt/gtk-ng: split separator styling --- src/apprt/gtk-ng/css/style-dark.css | 5 +++++ src/apprt/gtk-ng/css/style.css | 23 ++++++++++++++++++++ src/apprt/gtk-ng/ui/1.5/split-tree-split.blp | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/src/apprt/gtk-ng/css/style-dark.css b/src/apprt/gtk-ng/css/style-dark.css index a9aa2dcc0..f13b4f4f0 100644 --- a/src/apprt/gtk-ng/css/style-dark.css +++ b/src/apprt/gtk-ng/css/style-dark.css @@ -1,3 +1,8 @@ .transparent { background-color: transparent; } + +.window .split paned > separator { + background-color: rgba(36, 36, 36, 1); + background-clip: content-box; +} diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index 970c91b03..a1a425f66 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -114,3 +114,26 @@ label.resize-overlay { margin-left: 4px; margin-right: 8px; } + +/* + * Splits + */ + +.window .split paned > separator { + background-color: rgba(250, 250, 250, 1); + background-clip: content-box; + + /* This works around the oversized drag area for the right side of GtkPaned. + * + * Upstream Gtk issue: + * https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002 + * + * Ghostty issue: + * https://github.com/ghostty-org/ghostty/issues/3020 + * + * Without this, it's not possible to select the first character on the + * right-hand side of a split. + */ + margin: 0; + padding: 0; +} diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp b/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp index aa194e8e8..182919f4e 100644 --- a/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp +++ b/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp @@ -2,6 +2,10 @@ using Gtk 4.0; using Adw 1; template $GhosttySplitTreeSplit: Adw.Bin { + styles [ + "split", + ] + // The double-nesting is required due to a GTK bug where you can't // bind the first child of a builder layout. If you do, you get a double // dispose. Easiest way to see that is simply remove this and see the From ca4e38ff03b8e45edce0b3817c678ead264a40d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 10 Aug 2025 13:35:26 -0700 Subject: [PATCH 60/83] apprt/gtk-ng: split close confirmation --- .../class/close_confirmation_dialog.zig | 4 + src/apprt/gtk-ng/class/split_tree.zig | 96 ++++++++++++++----- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig index 210533c1c..3debafbb5 100644 --- a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig @@ -133,6 +133,7 @@ pub const CloseConfirmationDialog = extern struct { const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; + pub const refSink = C.refSink; pub const unref = C.unref; const private = C.private; @@ -179,12 +180,14 @@ pub const Target = enum(c_int) { app, tab, window, + surface, pub fn title(self: Target) [*:0]const u8 { return switch (self) { .app => i18n._("Quit Ghostty?"), .tab => i18n._("Close Tab?"), .window => i18n._("Close Window?"), + .surface => i18n._("Close Split?"), }; } @@ -193,6 +196,7 @@ pub const Target = enum(c_int) { .app => i18n._("All terminal sessions will be terminated."), .tab => i18n._("All terminal sessions in this tab will be terminated."), .window => i18n._("All terminal sessions in this window will be terminated."), + .surface => i18n._("The currently running process in this split will be terminated."), }; } diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index e547fa6d7..b70f0e2f8 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -130,6 +130,10 @@ pub const SplitTree = extern struct { /// propTree function for a lot more details. rebuild_pending: bool, + /// Used to store state about a pending surface close for the + /// close dialog. + pending_close: ?Surface.Tree.Node.Handle, + pub var offset: c_int = 0; }; @@ -138,6 +142,10 @@ pub const SplitTree = extern struct { // Initialize our actions self.initActions(); + + // Initialize some basic state + const priv = self.private(); + priv.pending_close = null; } fn initActions(self: *Self) void { @@ -511,32 +519,70 @@ pub const SplitTree = extern struct { .window, .tab => return, // Remove the surface from the tree. - .surface => { - // TODO: close confirmation - - // Find the surface in the tree. - const tree = self.getTree() orelse return; - const handle: Surface.Tree.Node.Handle = handle: { - var it = tree.iterator(); - while (it.next()) |entry| { - if (entry.view == surface) break :handle entry.handle; - } - - return; - }; - - // Remove it from the tree. - var new_tree = tree.remove( - Application.default().allocator(), - handle, - ) catch |err| { - log.warn("unable to remove surface from tree: {}", .{err}); - return; - }; - defer new_tree.deinit(); - self.setTree(&new_tree); - }, + .surface => {}, } + + const core = surface.core() orelse return; + + // Reset our pending close state + const priv = self.private(); + priv.pending_close = null; + + // Find the surface in the tree to verify this is valid and + // set our pending close handle. + priv.pending_close = handle: { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == surface) { + break :handle entry.handle; + } + } + + return; + }; + + // If we don't need to confirm then just close immediately. + if (!core.needsConfirmQuit()) { + closeConfirmationClose( + null, + self, + ); + return; + } + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.surface); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *Self, + closeConfirmationClose, + self, + .{}, + ); + dialog.present(self.as(gtk.Widget)); + } + + fn closeConfirmationClose( + _: ?*CloseConfirmationDialog, + self: *Self, + ) callconv(.c) void { + // Get the handle we're closing + const priv = self.private(); + const handle = priv.pending_close orelse return; + priv.pending_close = null; + + // Remove it from the tree. + const old_tree = self.getTree() orelse return; + var new_tree = old_tree.remove( + Application.default().allocator(), + handle, + ) catch |err| { + log.warn("unable to remove surface from tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); } fn propSurfaceFocused( From a8b9dd8dfc7a2cd6bb3f19969a450497654a47b0 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 27 Jul 2025 11:34:36 -0600 Subject: [PATCH 61/83] renderer: clean up, improve constraintWidth function --- src/renderer/cell.zig | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index b1ce4523c..e1cd6153f 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -229,23 +229,39 @@ pub fn isCovering(cp: u21) bool { }; } +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area and anything +/// in the "dingbats" unicode block. +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn isSymbol(cp: u21) bool { + return ziglyph.general_category.isPrivateUse(cp) or + ziglyph.blocks.isDingbats(cp); +} + /// Returns the appropriate `constraint_width` for /// the provided cell when rendering its glyph(s). pub fn constraintWidth(cell_pin: terminal.Pin) u2 { const cell = cell_pin.rowAndCell().cell; const cp = cell.codepoint(); - if (!ziglyph.general_category.isPrivateUse(cp) and - !ziglyph.blocks.isDingbats(cp)) - { - return cell.gridWidth(); - } + const grid_width = cell.gridWidth(); + + // If the grid width of the cell is 2, the constraint + // width will always be 2, so we can just return early. + if (grid_width > 1) return grid_width; + + // We allow "symbol-like" glyphs to extend to 2 cells wide if there's + // space, and if the previous glyph wasn't also a symbol. So if this + // codepoint isn't a symbol then we can return the grid width. + if (!isSymbol(cp)) return grid_width; // If we are at the end of the screen it must be constrained to one cell. if (cell_pin.x == cell_pin.node.data.size.cols - 1) return 1; - // If we have a previous cell and it was PUA then we need to - // also constrain. This is so that multiple PUA glyphs align. + // If we have a previous cell and it was a symbol then we need + // to also constrain. This is so that multiple PUA glyphs align. // As an exception, we ignore powerline glyphs since they are // used for box drawing and we consider them whitespace. if (cell_pin.x > 0) prev: { @@ -259,13 +275,13 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { // We consider powerline glyphs whitespace. if (isPowerline(prev_cp)) break :prev; - if (ziglyph.general_category.isPrivateUse(prev_cp)) { + if (isSymbol(prev_cp)) { return 1; } } - // If the next cell is whitespace, then - // we allow it to be up to two cells wide. + // If the next cell is whitespace, then we + // allow the glyph to be up to two cells wide. const next_cp = next_cp: { var copy = cell_pin; copy.x += 1; @@ -279,7 +295,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { return 2; } - // Must be constrained + // Otherwise, this has to be 1 cell wide. return 1; } From ee445d2915133b17d2586666973334ed6d679376 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 28 Jul 2025 18:08:48 -0600 Subject: [PATCH 62/83] font: compare font Index packed structs directly Packed structs can be compared directly now, no need to convert them to an int anymore. --- src/font/shaper/run.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 92e629e19..90917f657 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -247,7 +247,7 @@ pub const RunIterator = struct { if (j == self.i) current_font = font_info.idx; // If our fonts are not equal, then we're done with our run. - if (font_info.idx.int() != current_font.int()) break; + if (font_info.idx != current_font) break; // If we're a fallback character, add that and continue; we // don't want to add the entire grapheme. From f56219be951c2812f32371fafe47574591910cfb Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 28 Jul 2025 12:25:19 -0600 Subject: [PATCH 63/83] font/coretext: fix glyph position/scale code Apologies to Apple, the previous comments in this section of the code were not correct-- `shouldSubpixelQuantizeFonts` does pretty much what the minimal documentation for it says it does, it simply quantizes the position of the glyph and nothing more. Various bugs when testing while writing the old code that led me to include those comments made me not realize that the positioning is actually a lot simpler than it seems. With this version of the positioning there are never any cut-off rows or columns of pixels on the edges of anything and everything scales as it should... I hope. I checked pretty thoroughly this time and I'm like 99% sure this is correct in all cases. --- src/font/face/coretext.zig | 140 +++++++++++-------------------------- 1 file changed, 39 insertions(+), 101 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 2a3696d3f..72db6f86b 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -346,89 +346,60 @@ pub const Face = struct { const metrics = opts.grid_metrics; const cell_width: f64 = @floatFromInt(metrics.cell_width); - // const cell_height: f64 = @floatFromInt(metrics.cell_height); + const cell_height: f64 = @floatFromInt(metrics.cell_height); + + // Next we apply any constraints to get the final size of the glyph. + var constraint = opts.constraint; // We eliminate any negative vertical padding since these overlap // values aren't needed under CoreText with how precisely we apply // constraints, and they can lead to extra height that looks bad // for things like powerline glyphs. - var constraint = opts.constraint; constraint.pad_top = @max(0.0, constraint.pad_top); constraint.pad_bottom = @max(0.0, constraint.pad_bottom); + + // We need to add the baseline position before passing to the constrain + // function since it operates on cell-relative positions, not baseline. + const cell_baseline: f64 = @floatFromInt(metrics.cell_baseline); + const glyph_size = constraint.constrain( .{ .width = rect.size.width, .height = rect.size.height, .x = rect.origin.x, - .y = rect.origin.y + @as(f64, @floatFromInt(metrics.cell_baseline)), + .y = rect.origin.y + cell_baseline, }, metrics, opts.constraint_width, ); - // These calculations are an attempt to mostly imitate the effect of - // `shouldSubpixelQuantizeFonts`[^1], which helps maximize legibility - // at small pixel sizes (low DPI). We do this math ourselves instead - // of letting CoreText do it because it's not entirely clear how the - // math in CoreText works and we've run in to edge cases where glyphs - // have their bottom or left row cut off due to bad rounding. - // - // This math seems to have a mostly comparable result to whatever it - // is that CoreText does, and is even (in my opinion) better in some - // cases. - // - // I'm not entirely certain but I suspect that when you enable the - // CoreText option it also does some sort of rudimentary hinting, - // but it doesn't seem to make that big of a difference in terms - // of legibility in the end. - // - // [^1]: https://developer.apple.com/documentation/coregraphics/cgcontext/setshouldsubpixelquantizefonts(_:)?language=objc + var x = glyph_size.x; + var y = glyph_size.y; + var width = glyph_size.width; + var height = glyph_size.height; - // We only want to apply quantization if we don't have any - // constraints and this isn't a bitmap glyph, since CoreText - // doesn't seem to apply its quantization to bitmap glyphs. - // - // TODO: Maybe gate this so it only applies at small font sizes, - // or else offer a user config option that can disable it. - const should_quantize = !sbix and std.meta.eql(opts.constraint, .none); + // If this is a bitmap glyph, it will always render as full pixels, + // not fractional pixels, so we need to quantize its position and + // size accordingly to align to full pixels so we get good results. + if (sbix) { + width = cell_width - @round(cell_width - width - x) - @round(x); + height = cell_height - @round(cell_height - height - y) - @round(y); + x = @round(x); + y = @round(y); + } - // We offset our glyph by its bearings when we draw it, using `@floor` - // here rounds it *up* since we negate it right outside. Moving it by - // whole pixels ensures that we don't disturb the pixel alignment of - // the glyph, fractional pixels will still be drawn on all sides as - // necessary. - const draw_x = -@floor(rect.origin.x); - const draw_y = -@floor(rect.origin.y); + // Our pixel bearings for the final glyph. + const px_x: i32 = @intFromFloat(@floor(x)); + const px_y: i32 = @intFromFloat(@floor(y)); - // We use `x` and `y` for our full pixel bearings post-raster. - // We need to subtract the fractional pixel of difference from - // the edge of the draw area to the edge of the actual glyph. - const frac_x = rect.origin.x + draw_x; - const frac_y = rect.origin.y + draw_y; - const x = glyph_size.x - frac_x; - const y = glyph_size.y - frac_y; - - // We never modify the width. - // - // When using the CoreText option the widths do seem to be - // modified extremely subtly, but even at very small font - // sizes it's hardly a noticeable difference. - const width = glyph_size.width; - - // If the top of the glyph (taking in to account the y position) - // is within half a pixel of an exact pixel edge, we round up the - // height, otherwise leave it alone. - // - // This seems to match what CoreText does. - const frac_top = (glyph_size.height + frac_y) - @floor(glyph_size.height + frac_y); - const height = - if (should_quantize) - if (frac_top >= 0.5) - glyph_size.height + 1 - frac_top - else - glyph_size.height - else - glyph_size.height; + // We offset our glyph by its bearings when we draw it, so that it's + // rendered fully inside our canvas area, but we make sure to keep the + // fractional pixel offset so that we rasterize with the appropriate + // sub-pixel position. + const frac_x = x - @floor(x); + const frac_y = y - @floor(y); + const draw_x = -rect.origin.x + frac_x; + const draw_y = -rect.origin.y + frac_y; // Add the fractional pixel to the width and height and take // the ceiling to get a canvas size that will definitely fit @@ -511,7 +482,9 @@ pub const Face = struct { context.setAllowsFontSubpixelPositioning(ctx, true); context.setShouldSubpixelPositionFonts(ctx, true); - // See comments about quantization earlier in the function. + // We don't want subpixel quantization, since we very carefully + // manage the position of our glyphs ourselves, and dont want to + // mess that up. context.setAllowsFontSubpixelQuantization(ctx, false); context.setShouldSubpixelQuantizeFonts(ctx, false); @@ -553,46 +526,11 @@ pub const Face = struct { // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - const offset_y: i32 = @as(i32, @intFromFloat(@round(y))) + @as(i32, @intCast(px_height)); + const offset_y: i32 = px_y + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. - const offset_x: i32 = offset_x: { - // If the glyph's advance is narrower than the cell width then we - // center the advance of the glyph within the cell width. At first - // I implemented this to proportionally scale the center position - // of the glyph but that messes up glyphs that are meant to align - // vertically with others, so this is a compromise. - // - // This makes it so that when the `adjust-cell-width` config is - // used, or when a fallback font with a different advance width - // is used, we don't get weirdly aligned glyphs. - // - // We don't do this if the constraint has a horizontal alignment, - // since in that case the position was already calculated with the - // new cell width in mind. - if (opts.constraint.align_horizontal == .none) { - const advance = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, null); - const new_advance = - cell_width * @as(f64, @floatFromInt(opts.cell_width orelse 1)); - // If the original advance is greater than the cell width then - // it's possible that this is a ligature or other glyph that is - // intended to overflow the cell to one side or the other, and - // adjusting the bearings could mess that up, so we just leave - // it alone if that's the case. - // - // We also don't want to do anything if the advance is zero or - // less, since this is used for stuff like combining characters. - if (advance > new_advance or advance <= 0.0) { - break :offset_x @intFromFloat(@round(x)); - } - break :offset_x @intFromFloat( - @round(x + (new_advance - advance) / 2), - ); - } else { - break :offset_x @intFromFloat(@round(x)); - } - }; + const offset_x: i32 = px_x; return .{ .width = px_width, From 195cbb6a1c329f2f42df4d8500eeb94bdb805901 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 10 Aug 2025 15:20:29 -0600 Subject: [PATCH 64/83] Revert "font/Metrics: remove original_cell_width, no longer needed" This reverts commit 23cc50b12c1b670ff3f96c63437f61742d3b4d3c. --- src/font/Metrics.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 09c996290..59ea48e18 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -38,6 +38,10 @@ cursor_height: u32, /// The constraint height for nerd fonts icons. icon_height: u32, +/// Original cell width in pixels. This is used to keep +/// glyphs centered if the cell width is adjusted wider. +original_cell_width: ?u32 = null, + /// Minimum acceptable values for some fields to prevent modifiers /// from being able to, for example, cause 0-thickness underlines. const Minimums = struct { @@ -263,6 +267,11 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { const new = @max(entry.value_ptr.apply(original), 1); if (new == original) continue; + // Preserve the original cell width if not set. + if (self.original_cell_width == null) { + self.original_cell_width = self.cell_width; + } + // Set the new value @field(self, @tagName(tag)) = new; From 20a9a3a8c2adca49650ed6f4140e1de422b7ccb1 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 10 Aug 2025 15:49:54 -0600 Subject: [PATCH 65/83] font: use adjusted cell width for recentering again The old method was nice, but had an issue that's intractible without significant reworking in how we do shaping: combining glyphs need to position relative to the glyph they're combining with, but if we re- center that glyph, it will be off by some amount. --- src/font/face/coretext.zig | 18 +++++++++++- src/font/face/freetype.zig | 58 +++++++++++++++----------------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 72db6f86b..ae5bf38e8 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -388,7 +388,23 @@ pub const Face = struct { y = @round(y); } - // Our pixel bearings for the final glyph. + // If the cell width was adjusted wider, we re-center all glyphs + // in the new width, so that they aren't weirdly off to the left. + if (metrics.original_cell_width) |original| recenter: { + // We don't do this if the constraint has a horizontal alignment, + // since in that case the position was already calculated with the + // new cell width in mind. + if (opts.constraint.align_horizontal != .none) break :recenter; + + // If the original width was wider then we don't do anything. + if (original >= metrics.cell_width) break :recenter; + + // We add half the difference to re-center. + x += (cell_width - @as(f64, @floatFromInt(original))) / 2; + } + + // Our whole-pixel bearings for the final glyph. + // The fractional portion will be included in the rasterized position. const px_x: i32 = @intFromFloat(@floor(x)); const px_y: i32 = @intFromFloat(@floor(y)); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 4e7100396..2ceac42ab 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -417,6 +417,27 @@ pub const Face = struct { var x = glyph_size.x; const y = glyph_size.y; + // If the cell width was adjusted wider, we re-center all glyphs + // in the new width, so that they aren't weirdly off to the left. + if (metrics.original_cell_width) |original| recenter: { + // We don't do this if the constraint has a horizontal alignment, + // since in that case the position was already calculated with the + // new cell width in mind. + if (opts.constraint.align_horizontal != .none) break :recenter; + + // If the original width was wider then we don't do anything. + if (original >= metrics.cell_width) break :recenter; + + // We add half the difference to re-center. + // + // NOTE: We round this to a whole-pixel amount because under + // FreeType, the outlines will be hinted, which isn't + // the case under CoreText. If we move the outlines by + // a non-whole-pixel amount, it completely ruins the + // hinting. + x += @round((cell_width - @as(f64, @floatFromInt(original))) / 2); + } + // Now we can render the glyph. var bitmap: freetype.c.FT_Bitmap = undefined; _ = freetype.c.FT_Bitmap_Init(&bitmap); @@ -641,42 +662,7 @@ pub const Face = struct { // This should be the distance from the left of // the cell to the left of the glyph's bounding box. - const offset_x: i32 = offset_x: { - // If the glyph's advance is narrower than the cell width then we - // center the advance of the glyph within the cell width. At first - // I implemented this to proportionally scale the center position - // of the glyph but that messes up glyphs that are meant to align - // vertically with others, so this is a compromise. - // - // This makes it so that when the `adjust-cell-width` config is - // used, or when a fallback font with a different advance width - // is used, we don't get weirdly aligned glyphs. - // - // We don't do this if the constraint has a horizontal alignment, - // since in that case the position was already calculated with the - // new cell width in mind. - if (opts.constraint.align_horizontal == .none) { - const advance = f26dot6ToFloat(glyph.*.advance.x); - const new_advance = - cell_width * @as(f64, @floatFromInt(opts.cell_width orelse 1)); - // If the original advance is greater than the cell width then - // it's possible that this is a ligature or other glyph that is - // intended to overflow the cell to one side or the other, and - // adjusting the bearings could mess that up, so we just leave - // it alone if that's the case. - // - // We also don't want to do anything if the advance is zero or - // less, since this is used for stuff like combining characters. - if (advance > new_advance or advance <= 0.0) { - break :offset_x @intFromFloat(@floor(x)); - } - break :offset_x @intFromFloat( - @floor(x + (new_advance - advance) / 2), - ); - } else { - break :offset_x @intFromFloat(@floor(x)); - } - }; + const offset_x: i32 = @intFromFloat(@floor(x)); return Glyph{ .width = px_width, From 5383cd9c9c202dc828684c227b3a3df2ec67d580 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 10 Aug 2025 16:04:54 -0600 Subject: [PATCH 66/83] font/freetype: pass monochrome load flag when needed The monochrome hinter is very aggressive but makes text actually look tolerable when rendered in monochrome. If for some god forsaken reason we get complaints about this, that someone wanted improperly hinted mono glyphs, we can introduce additonal configuration; but for now, this is just a straight improvement. --- src/font/face/freetype.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 2ceac42ab..dbaa6ab59 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -356,6 +356,11 @@ pub const Face = struct { .force_autohint = self.load_flags.@"force-autohint", .no_autohint = !self.load_flags.autohint, + // If we're gonna be rendering this glyph in monochrome, + // then we should use the monochrome hinter as well, or + // else it won't look very good at all. + .target_mono = self.load_flags.monochrome, + // NO_SVG set to true because we don't currently support rendering // SVG glyphs under FreeType, since that requires bundling another // dependency to handle rendering the SVG. From 897d70982e21dd68edd4553e71bf6f4b8239d49c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 10 Aug 2025 17:25:13 -0500 Subject: [PATCH 67/83] font/freetype: convert encoding of font names Freetype encodes some font names internally in formats other than UTF-8. This only affects debug logs but it was annoying me so I fixed it. There may be other encodings that might need to be dealt with but I took care of the one that I ran across. --- src/font/face/freetype.zig | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 4e7100396..4ca06cb58 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -156,17 +156,25 @@ pub const Face = struct { /// but sometimes allocation isn't required and a static string is /// returned. pub fn name(self: *const Face, buf: []u8) Allocator.Error![]const u8 { - // We don't use this today but its possible the table below - // returns UTF-16 in which case we'd want to use this for conversion. - _ = buf; - const count = self.face.getSfntNameCount(); // We look for the font family entry. for (0..count) |i| { const entry = self.face.getSfntName(i) catch continue; if (entry.name_id == freetype.c.TT_NAME_ID_FONT_FAMILY) { - return entry.string[0..entry.string_len]; + const string = entry.string[0..entry.string_len]; + // There are other encodings that are something other than UTF-8 + // but this is one we've seen "in the wild" so far. + if (entry.platform_id == freetype.c.TT_PLATFORM_MICROSOFT and entry.encoding_id == freetype.c.TT_MS_ID_UNICODE_CS) skip: { + if (string.len % 2 != 0) break :skip; + if (string.len > 1024) break :skip; + var tmp: [512]u16 = undefined; + const max = string.len / 2; + for (@as([]const u16, @alignCast(@ptrCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c); + const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string; + return buf[0..len]; + } + return string; } } From e9e32d71e41c7d7c7a846b2406b449e28d44e3e9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 10 Aug 2025 20:10:24 -0500 Subject: [PATCH 68/83] font/freetype: add a test for face name decoding using embedded fonts --- src/font/face/freetype.zig | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 4ca06cb58..61ec735c3 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -181,6 +181,33 @@ pub const Face = struct { return ""; } + test "face name" { + const embedded = @import("../embedded.zig"); + + var lib: Library = try .init(testing.allocator); + defer lib.deinit(); + + { + var face: Face = try .init(lib, embedded.variable, .{ .size = .{ .points = 14 } }); + defer face.deinit(); + + var buf: [1024]u8 = undefined; + const actual = try face.name(&buf); + + try testing.expectEqualStrings("JetBrains Mono", actual); + } + + { + var face: Face = try .init(lib, embedded.inconsolata, .{ .size = .{ .points = 14 } }); + defer face.deinit(); + + var buf: [1024]u8 = undefined; + const actual = try face.name(&buf); + + try testing.expectEqualStrings("Inconsolata", actual); + } + } + /// Return a new face that is the same as this but also has synthetic /// bold applied. pub fn syntheticBold(self: *const Face, opts: font.face.Options) !Face { From 8c7538e996904fc4c10e78fc3617f42c11ad7884 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 10 Aug 2025 19:37:19 -0600 Subject: [PATCH 69/83] font/freetype: port improved raster logic from CoreText We now also have absolute perfect control over the raster position under FreeType as well. This means that, for example, powerline extended chars are appropriately clamped to the cell edges at all sizes. This should be purely an improvement over what we had before, and now it also matches what we do for CoreText. --- src/font/face.zig | 10 +++ src/font/face/coretext.zig | 6 +- src/font/face/freetype.zig | 150 +++++++++++++++++++++++++------------ 3 files changed, 114 insertions(+), 52 deletions(-) diff --git a/src/font/face.zig b/src/font/face.zig index a20df8c11..2902f97ae 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -222,6 +222,16 @@ pub const RenderOptions = struct { y: f64, }; + /// Returns true if the constraint does anything. If it doesn't, + /// because it neither sizes nor positions the glyph, then this + /// returns false. + pub inline fn doesAnything(self: Constraint) bool { + return self.size_horizontal != .none or + self.align_horizontal != .none or + self.size_vertical != .none or + self.align_vertical != .none; + } + /// Apply this constraint to the provided glyph /// size, given the available width and height. pub fn constrain( diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index ae5bf38e8..1b1c559fb 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -352,9 +352,9 @@ pub const Face = struct { var constraint = opts.constraint; // We eliminate any negative vertical padding since these overlap - // values aren't needed under CoreText with how precisely we apply - // constraints, and they can lead to extra height that looks bad - // for things like powerline glyphs. + // values aren't needed with how precisely we apply constraints, + // and they can lead to extra height that looks bad for things like + // powerline glyphs. constraint.pad_top = @max(0.0, constraint.pad_top); constraint.pad_bottom = @max(0.0, constraint.pad_bottom); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index dbaa6ab59..6bb20e503 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -328,19 +328,11 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); - // We enable hinting by default, and disable it if either of the - // constraint alignments are not center or none, since this means - // that the glyph needs to be aligned flush to the cell edge, and - // hinting can mess that up. - const do_hinting = self.load_flags.hinting and - switch (opts.constraint.align_horizontal) { - .start, .end => false, - .center, .none => true, - } and - switch (opts.constraint.align_vertical) { - .start, .end => false, - .center, .none => true, - }; + // Hinting should only be enabled if the configured load flags specify + // it and the provided constraint doesn't actually do anything, since + // if it does, then it'll mess up the hinting anyway when it moves or + // resizes the glyph. + const do_hinting = self.load_flags.hinting and !opts.constraint.doesAnything(); // Load the glyph. try self.face.loadGlyph(glyph_index, .{ @@ -368,14 +360,45 @@ pub const Face = struct { }); const glyph = self.face.handle.*.glyph; - const glyph_width: f64 = f26dot6ToF64(glyph.*.metrics.width); - const glyph_height: f64 = f26dot6ToF64(glyph.*.metrics.height); + // We get a rect that represents the position + // and size of the glyph before any changes. + const rect: struct { + x: f64, + y: f64, + width: f64, + height: f64, + } = metrics: { + // If we're dealing with an outline glyph then we get the + // outline's bounding box instead of using the built-in + // metrics, since that's more precise and allows better + // cell-fitting. + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + // Get the glyph's bounding box before we transform it at all. + // We use this rather than the metrics, since it's more precise. + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); + + break :metrics .{ + .x = f26dot6ToF64(bbox.xMin), + .y = f26dot6ToF64(bbox.yMin), + .width = f26dot6ToF64(bbox.xMax - bbox.xMin), + .height = f26dot6ToF64(bbox.yMax - bbox.yMin), + }; + } + + break :metrics .{ + .x = f26dot6ToF64(glyph.*.metrics.horiBearingX), + .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height), + .width = f26dot6ToF64(glyph.*.metrics.width), + .height = f26dot6ToF64(glyph.*.metrics.height), + }; + }; // If our glyph is smaller than a quarter pixel in either axis // then it has no outlines or they're too small to render. // // In this case we just return 0-sized glyph struct. - if (glyph_width < 0.25 or glyph_height < 0.25) + if (rect.width < 0.25 or rect.height < 0.25) return font.Glyph{ .width = 0, .height = 0, @@ -396,31 +419,49 @@ pub const Face = struct { _ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount)); } - // Next we need to apply any constraints. const metrics = opts.grid_metrics; - const cell_width: f64 = @floatFromInt(metrics.cell_width); - // const cell_height: f64 = @floatFromInt(metrics.cell_height); + const cell_height: f64 = @floatFromInt(metrics.cell_height); - const glyph_x: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingX); - const glyph_y: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingY) - glyph_height; + // Next we apply any constraints to get the final size of the glyph. + var constraint = opts.constraint; - const glyph_size = opts.constraint.constrain( + // We eliminate any negative vertical padding since these overlap + // values aren't needed with how precisely we apply constraints, + // and they can lead to extra height that looks bad for things like + // powerline glyphs. + constraint.pad_top = @max(0.0, constraint.pad_top); + constraint.pad_bottom = @max(0.0, constraint.pad_bottom); + + // We need to add the baseline position before passing to the constrain + // function since it operates on cell-relative positions, not baseline. + const cell_baseline: f64 = @floatFromInt(metrics.cell_baseline); + + const glyph_size = constraint.constrain( .{ - .width = glyph_width, - .height = glyph_height, - .x = glyph_x, - .y = glyph_y + @as(f64, @floatFromInt(metrics.cell_baseline)), + .width = rect.width, + .height = rect.height, + .x = rect.x, + .y = rect.y + cell_baseline, }, metrics, opts.constraint_width, ); - const width = glyph_size.width; - const height = glyph_size.height; - // This may need to be adjusted later on. + var width = glyph_size.width; + var height = glyph_size.height; var x = glyph_size.x; - const y = glyph_size.y; + var y = glyph_size.y; + + // If this is a bitmap glyph, it will always render as full pixels, + // not fractional pixels, so we need to quantize its position and + // size accordingly to align to full pixels so we get good results. + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_BITMAP) { + width = cell_width - @round(cell_width - width - x) - @round(x); + height = cell_height - @round(cell_height - height - y) - @round(y); + x = @round(x); + y = @round(y); + } // If the cell width was adjusted wider, we re-center all glyphs // in the new width, so that they aren't weirdly off to the left. @@ -455,8 +496,8 @@ pub const Face = struct { // matrix, since that has 16.16 coefficients, and also I was having // weird issues that I can only assume where due to freetype doing // some bad caching or something when I did this using the matrix. - const scale_x = width / glyph_width; - const scale_y = height / glyph_height; + const scale_x = width / rect.width; + const scale_y = height / rect.height; const skew: f64 = if (self.synthetic.italic) // We skew by 12 degrees to synthesize italics. @@ -464,19 +505,24 @@ pub const Face = struct { else 0.0; - var bbox_before: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_before); - const outline = &glyph.*.outline; for (outline.points[0..@intCast(outline.n_points)]) |*p| { // Convert to f64 for processing var px = f26dot6ToF64(p.x); var py = f26dot6ToF64(p.y); + // Subtract original bearings + px -= rect.x; + py -= rect.y; + // Scale px *= scale_x; py *= scale_y; + // Add new bearings + px += x; + py += y - cell_baseline; + // Skew px += py * skew; @@ -485,16 +531,6 @@ pub const Face = struct { p.y = @as(i32, @bitCast(F26Dot6.from(py))); } - var bbox_after: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_after); - - // If our bounding box changed, account for the lsb difference. - // - // This can happen when we skew glyphs that have a bit sticking - // out to the left higher up, like the top of the T or the serif - // on the lower case l in many monospace fonts. - x += f26dot6ToF64(bbox_after.xMin) - f26dot6ToF64(bbox_before.xMin); - try self.face.renderGlyph( if (self.load_flags.monochrome) .mono @@ -592,6 +628,10 @@ pub const Face = struct { ) != 0) { return error.BitmapHandlingError; } + + // Update the bearings to account for the new positioning. + glyph.*.bitmap_top = @intFromFloat(@floor(y - cell_baseline + height)); + glyph.*.bitmap_left = @intFromFloat(@floor(x)); }, else => |f| { @@ -626,6 +666,20 @@ pub const Face = struct { }, } + // Our whole-pixel bearings for the final glyph. + // The fractional portion will be included in the rasterized position. + // + // For the Y position, FreeType's `bitmap_top` is the distance from the + // baseline to the top of the glyph, but we need the distance from the + // bottom of the cell to the bottom of the glyph, so first we add the + // baseline to get the distance from the bottom of the cell to the top + // of the glyph, then we subtract the height of the glyph to get the + // bottom. + const px_x: i32 = glyph.*.bitmap_left; + const px_y: i32 = glyph.*.bitmap_top + + @as(i32, @intCast(metrics.cell_baseline)) - + @as(i32, @intCast(bitmap.rows)); + const px_width = bitmap.width; const px_height = bitmap.rows; const len: usize = @intCast( @@ -661,13 +715,11 @@ pub const Face = struct { // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - const offset_y: i32 = - @as(i32, @intFromFloat(@floor(y))) + - @as(i32, @intCast(px_height)); + const offset_y: i32 = px_y + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. - const offset_x: i32 = @intFromFloat(@floor(x)); + const offset_x: i32 = px_x; return Glyph{ .width = px_width, From aba5a34335406e620645035b9eb7cbb21126e751 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 10 Aug 2025 21:29:46 -0500 Subject: [PATCH 70/83] gtk-ng: sync action accelerators for split-tree --- src/apprt/gtk-ng/class/application.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 523a98033..7dde0fa93 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -882,6 +882,10 @@ pub const Application = extern struct { self.syncActionAccelerator("win.reset", .{ .reset = {} }); self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); + self.syncActionAccelerator("split-tree.new-left", .{ .new_split = .left }); + self.syncActionAccelerator("split-tree.new-right", .{ .new_split = .right }); + self.syncActionAccelerator("split-tree.new-up", .{ .new_split = .up }); + self.syncActionAccelerator("split-tree.new-down", .{ .new_split = .down }); } fn syncActionAccelerator( From b726183981a9d4aac2fe586cfe83ad3de15986c7 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 11 Aug 2025 10:27:39 -0500 Subject: [PATCH 71/83] gtk-ng: fix split-divider-color config --- src/apprt/gtk-ng/class/application.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 7dde0fa93..dd03485c7 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -743,7 +743,7 @@ pub const Application = extern struct { if (config.@"split-divider-color") |color| { try writer.print( - \\.terminal-window .notebook separator {{ + \\.window .split paned > separator {{ \\ color: rgb({[r]d},{[g]d},{[b]d}); \\ background: rgb({[r]d},{[g]d},{[b]d}); \\}} From 984435d7eabf079f3852b75b08ec888d2ab4d361 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 09:20:45 -0700 Subject: [PATCH 72/83] split_tree: deepest, previous, next traversals --- src/datastruct/split_tree.zig | 217 +++++++++++++++++++++++++++++++++- 1 file changed, 211 insertions(+), 6 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 14ef6370e..26da9d89c 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -152,16 +152,16 @@ pub fn SplitTree(comptime V: type) type { return .{ .nodes = self.nodes }; } + pub const ViewEntry = struct { + handle: Node.Handle, + view: *View, + }; + pub const Iterator = struct { i: Node.Handle = 0, nodes: []const Node, - pub const Entry = struct { - handle: Node.Handle, - view: *View, - }; - - pub fn next(self: *Iterator) ?Entry { + pub fn next(self: *Iterator) ?ViewEntry { // If we have no nodes, return null. if (self.i >= self.nodes.len) return null; @@ -177,6 +177,151 @@ pub fn SplitTree(comptime V: type) type { } }; + pub const Goto = enum { + /// Previous view, null if we're the first view. + previous, + + /// Next view, null if we're the last view. + next, + + /// Previous view, but wrapped around to the last view. May + /// return the same view if this is the first view. + previous_wrapped, + + /// Next view, but wrapped around to the first view. May return + /// the same view if this is the last view. + next_wrapped, + }; + + /// Goto a view from a certain point in the split tree. Returns null + /// if the direction results in no visitable view. + pub fn goto( + self: *const Self, + from: Node.Handle, + to: Goto, + ) ?Node.Handle { + return switch (to) { + .previous => self.previous(from), + .next => self.next(from), + .previous_wrapped => self.previous(from) orelse self.deepest(.right, 0), + .next_wrapped => self.next(from) orelse self.deepest(.left, 0), + }; + } + + pub const Side = enum { left, right }; + + /// Returns the deepest view in the tree in the given direction. + /// This can be used to find the leftmost/rightmost surface within + /// a given split structure. + pub fn deepest( + self: *const Self, + side: Side, + from: Node.Handle, + ) Node.Handle { + var current: Node.Handle = from; + while (true) { + switch (self.nodes[current]) { + .leaf => return current, + .split => |s| current = switch (side) { + .left => s.left, + .right => s.right, + }, + } + } + } + + /// Returns the previous view from the given node handle (which itself + /// doesn't need to be a view). If there is no previous (this is the + /// most previous view) then this will return null. + /// + /// "Previous" is defined as the previous node in an in-order + /// traversal of the tree. This isn't a perfect definition and we + /// may want to change this to something that better matches a + /// spatial view of the tree later. + fn previous(self: *const Self, from: Node.Handle) ?Node.Handle { + return switch (self.previousBacktrack(from, 0)) { + .result => |v| v, + .backtrack, .deadend => null, + }; + } + + /// Same as `previous`, but returns the next view instead. + fn next(self: *const Self, from: Node.Handle) ?Node.Handle { + return switch (self.nextBacktrack(from, 0)) { + .result => |v| v, + .backtrack, .deadend => null, + }; + } + + // Design note: we use a recursive backtracking search because + // split trees are never that deep, so we can abuse the stack as + // a safe allocator (stack overflow unlikely unless the kernel is + // tuned in some really weird way). + const Backtrack = union(enum) { + deadend, + backtrack, + result: Node.Handle, + }; + + fn previousBacktrack( + self: *const Self, + from: Node.Handle, + current: Node.Handle, + ) Backtrack { + // If we reached the point that we're trying to find the previous + // value of, then we need to backtrack from here. + if (from == current) return .backtrack; + + return switch (self.nodes[current]) { + // If we hit a leaf that isn't our target, then deadend. + .leaf => .deadend, + + .split => |s| switch (self.previousBacktrack(from, s.left)) { + .result => |v| .{ .result = v }, + + // Backtrack from the left means we have to continue + // backtracking because we can't see what's before the left. + .backtrack => .backtrack, + + // If we hit a deadend on the left then let's move right. + .deadend => switch (self.previousBacktrack(from, s.right)) { + .result => |v| .{ .result = v }, + + // Deadend means its not in this split at all since + // we already tracked the left. + .deadend => .deadend, + + // Backtrack means that its in our left view because + // we can see the immediate previous and there MUST + // be leaves (we can't have split-only leaves). + .backtrack => .{ .result = self.deepest(.right, s.left) }, + }, + }, + }; + } + + // See previousBacktrack for detailed comments. This is a mirror + // of that. + fn nextBacktrack( + self: *const Self, + from: Node.Handle, + current: Node.Handle, + ) Backtrack { + if (from == current) return .backtrack; + return switch (self.nodes[current]) { + .leaf => .deadend, + .split => |s| switch (self.nextBacktrack(from, s.right)) { + .result => |v| .{ .result = v }, + .backtrack => .backtrack, + .deadend => switch (self.nextBacktrack(from, s.left)) { + .result => |v| .{ .result = v }, + .deadend => .deadend, + .backtrack => .{ .result = self.deepest(.left, s.right) }, + }, + }, + }; + } + /// Resize the given node in place. The node MUST be a split (asserted). /// /// In general, this is an immutable data structure so this is @@ -999,6 +1144,66 @@ test "SplitTree: split horizontal" { \\ , str); } + + // Find "previous" from D back. + { + var current: u8 = 'D'; + while (current != 'A') : (current -= 1) { + it = t5.iterator(); + const handle = t5.previous( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ).?; + + const entry = t5.nodes[handle].leaf; + try testing.expectEqualStrings( + entry.label, + &.{current - 1}, + ); + } + + it = t5.iterator(); + try testing.expect(t5.previous( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ) == null); + } + + // Find "next" from A forward. + { + var current: u8 = 'A'; + while (current != 'D') : (current += 1) { + it = t5.iterator(); + const handle = t5.next( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ).?; + + const entry = t5.nodes[handle].leaf; + try testing.expectEqualStrings( + entry.label, + &.{current + 1}, + ); + } + + it = t5.iterator(); + try testing.expect(t5.next( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ) == null); + } } test "SplitTree: split vertical" { From 5903d7d10f8098146ab83c448f970d9eae993bab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 09:43:39 -0700 Subject: [PATCH 73/83] apprt/gtk-ng: hook up goto_split --- src/apprt/gtk-ng/class/application.zig | 33 +++++++++++++++++++++++++- src/apprt/gtk-ng/class/split_tree.zig | 17 +++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 7dde0fa93..129147ece 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -34,6 +34,7 @@ const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; const Config = @import("config.zig").Config; const Surface = @import("surface.zig").Surface; +const SplitTree = @import("split_tree.zig").SplitTree; const Window = @import("window.zig").Window; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; @@ -552,6 +553,8 @@ pub const Application = extern struct { .desktop_notification => Action.desktopNotification(self, target, value), + .goto_split => return Action.gotoSplit(target, value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -615,7 +618,6 @@ pub const Application = extern struct { // TODO: splits .resize_split, .equalize_splits, - .goto_split, .toggle_split_zoom, => { log.warn("unimplemented action={}", .{action}); @@ -1650,6 +1652,35 @@ const Action = struct { gio_app.sendNotification(n.body, notification); } + pub fn gotoSplit( + target: apprt.Target, + to: apprt.action.GotoSplit, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + // Design note: we can't use widget actions here because + // we need to know whether there is a goto target for returning + // the proper perform result (boolean). + + const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring goto_split", .{}); + return false; + }; + + return tree.goto(switch (to) { + .previous => .previous_wrapped, + .next => .next_wrapped, + else => @panic("TODO"), + }); + }, + } + } + pub fn gotoTab( target: apprt.Target, tab: apprt.action.GotoTab, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index b70f0e2f8..cbaef54ae 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -258,6 +258,23 @@ pub const SplitTree = extern struct { self.setTree(&new_tree); } + /// Move focus from the currently focused surface to the given + /// direction. Returns true if focus switched to a new surface. + pub fn goto(self: *Self, to: Surface.Tree.Goto) bool { + const tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + const target = tree.goto(active, to) orelse return false; + + // If we aren't changing targets then we did nothing. + if (active == target) return false; + + // Get the surface at the target location and grab focus. + const surface = tree.nodes[target].leaf; + surface.grabFocus(); + + return true; + } + fn disconnectSurfaceHandlers(self: *Self) void { const tree = self.getTree() orelse return; var it = tree.iterator(); From 70d48d03a5990abbdf8d37df44b13bf63ea06a00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 09:51:54 -0700 Subject: [PATCH 74/83] apprt/gtk-ng: go to right focus when split closes --- src/apprt/gtk-ng/class/split_tree.zig | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index cbaef54ae..f951d3d0a 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -589,8 +589,23 @@ pub const SplitTree = extern struct { const handle = priv.pending_close orelse return; priv.pending_close = null; - // Remove it from the tree. + // Figure out our next focus target. The next focus target is + // always the "previous" surface unless we're the leftmost then + // its the next. const old_tree = self.getTree() orelse return; + const next_focus: ?*Surface = next_focus: { + const next_handle = old_tree.goto(handle, .previous) orelse + old_tree.goto(handle, .next) orelse + break :next_focus null; + if (next_handle == handle) break :next_focus null; + + // Note: we don't need to ref this or anything because its + // guaranteed to remain in the new tree since its not part + // of the handle we're removing. + break :next_focus old_tree.nodes[next_handle].leaf; + }; + + // Remove it from the tree. var new_tree = old_tree.remove( Application.default().allocator(), handle, @@ -600,6 +615,10 @@ pub const SplitTree = extern struct { }; defer new_tree.deinit(); self.setTree(&new_tree); + + // Grab focus. We have to set this on the "last focused" because our + // focus will be set when the tree is redrawn. + if (next_focus) |v| priv.last_focused.set(v); } fn propSurfaceFocused( From 5a01877c77ce5f4c91d35e6d0d8863da549a5f7c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 10:17:08 -0700 Subject: [PATCH 75/83] apprt/gtk-ng: spatial navigation --- src/apprt/gtk-ng/class/application.zig | 5 +- src/apprt/gtk-ng/class/split_tree.zig | 19 +++++-- src/datastruct/split_tree.zig | 72 ++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 129147ece..3483fd279 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1675,7 +1675,10 @@ const Action = struct { return tree.goto(switch (to) { .previous => .previous_wrapped, .next => .next_wrapped, - else => @panic("TODO"), + .up => .{ .spatial = .up }, + .down => .{ .spatial = .down }, + .left => .{ .spatial = .left }, + .right => .{ .spatial = .right }, }); }, } diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index f951d3d0a..7364c0ade 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -263,7 +263,18 @@ pub const SplitTree = extern struct { pub fn goto(self: *Self, to: Surface.Tree.Goto) bool { const tree = self.getTree() orelse return false; const active = self.getActiveSurfaceHandle() orelse return false; - const target = tree.goto(active, to) orelse return false; + const target = if (tree.goto( + Application.default().allocator(), + active, + to, + )) |handle_| + handle_ orelse return false + else |err| switch (err) { + // Nothing we can do in this scenario. This is highly unlikely + // since split trees don't use that much memory. The application + // is probably about to crash in other ways. + error.OutOfMemory => return false, + }; // If we aren't changing targets then we did nothing. if (active == target) return false; @@ -594,8 +605,10 @@ pub const SplitTree = extern struct { // its the next. const old_tree = self.getTree() orelse return; const next_focus: ?*Surface = next_focus: { - const next_handle = old_tree.goto(handle, .previous) orelse - old_tree.goto(handle, .next) orelse + const alloc = Application.default().allocator(); + const next_handle: Surface.Tree.Node.Handle = + (old_tree.goto(alloc, handle, .previous) catch null) orelse + (old_tree.goto(alloc, handle, .next) catch null) orelse break :next_focus null; if (next_handle == handle) break :next_focus null; diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 26da9d89c..6af7e45d1 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -177,7 +177,7 @@ pub fn SplitTree(comptime V: type) type { } }; - pub const Goto = enum { + pub const Goto = union(enum) { /// Previous view, null if we're the first view. previous, @@ -191,20 +191,34 @@ pub fn SplitTree(comptime V: type) type { /// Next view, but wrapped around to the first view. May return /// the same view if this is the last view. next_wrapped, + + /// A spatial direction. "Spatial" means that the direction is + /// based on the nearest surface in the given direction visually + /// as the surfaces are laid out on a 2D grid. + spatial: Spatial.Direction, }; /// Goto a view from a certain point in the split tree. Returns null /// if the direction results in no visitable view. + /// + /// Allocator is only used for temporary state for spatial navigation. pub fn goto( self: *const Self, + alloc: Allocator, from: Node.Handle, to: Goto, - ) ?Node.Handle { + ) Allocator.Error!?Node.Handle { return switch (to) { .previous => self.previous(from), .next => self.next(from), .previous_wrapped => self.previous(from) orelse self.deepest(.right, 0), .next_wrapped => self.next(from) orelse self.deepest(.left, 0), + .spatial => |d| spatial: { + // Get our spatial representation. + var sp = try self.spatial(alloc); + defer sp.deinit(alloc); + break :spatial sp.nearestLeaf(from, d); + }, }; } @@ -586,17 +600,69 @@ pub fn SplitTree(comptime V: type) type { pub const empty: Spatial = .{ .slots = &.{} }; + pub const Direction = enum { left, right, down, up }; + const Slot = struct { x: f16, y: f16, width: f16, height: f16, + + fn maxX(self: *const Slot) f16 { + return self.x + self.width; + } + + fn maxY(self: *const Slot) f16 { + return self.y + self.height; + } }; - pub fn deinit(self: *const Spatial, alloc: Allocator) void { + pub fn deinit(self: *Spatial, alloc: Allocator) void { alloc.free(self.slots); self.* = undefined; } + + /// Returns the nearest leaf node (view) in the given direction. + pub fn nearestLeaf( + self: *const Spatial, + from: Node.Handle, + direction: Direction, + ) ?Node.Handle { + const target = self.slots[from]; + + var nearest: ?struct { + handle: Node.Handle, + distance: f16, + } = null; + for (self.slots, 0..) |slot, handle| { + // Never match ourself + if (handle == from) continue; + + // Ensure it is in the proper direction + if (!switch (direction) { + .left => slot.maxX() <= target.maxX(), + .right => slot.x >= target.maxX(), + .up => slot.maxY() <= target.y, + .down => slot.y >= target.maxY(), + }) continue; + + // Track our distance + const dx = slot.x - target.x; + const dy = slot.y - target.y; + const distance = @sqrt(dx * dx + dy * dy); + + // If we have a nearest it must be closer. + if (nearest) |n| { + if (distance >= n.distance) continue; + } + nearest = .{ + .handle = @intCast(handle), + .distance = distance, + }; + } + + return if (nearest) |n| n.handle else null; + } }; /// Spatial representation of the split tree. This can be used to From 43c3150e81f27dcc39f3688065e89ad825cbeed1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 10:59:02 -0700 Subject: [PATCH 76/83] split_tree: unit tests for spatial nav, fix a bug --- src/apprt/gtk-ng/class/split_tree.zig | 1 + src/datastruct/split_tree.zig | 228 +++++++++++++++++++++----- 2 files changed, 185 insertions(+), 44 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 7364c0ade..567166329 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -246,6 +246,7 @@ pub const SplitTree = extern struct { alloc, handle, direction, + 0.5, // Always split equally for new splits &single_tree, ); defer new_tree.deinit(); diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 6af7e45d1..3003b73c7 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -217,7 +217,7 @@ pub fn SplitTree(comptime V: type) type { // Get our spatial representation. var sp = try self.spatial(alloc); defer sp.deinit(alloc); - break :spatial sp.nearestLeaf(from, d); + break :spatial self.nearest(sp, from, d); }, }; } @@ -336,6 +336,55 @@ pub fn SplitTree(comptime V: type) type { }; } + /// Returns the nearest leaf node (view) in the given direction. + fn nearest( + self: *const Self, + sp: Spatial, + from: Node.Handle, + direction: Spatial.Direction, + ) ?Node.Handle { + const target = sp.slots[from]; + + var result: ?struct { + handle: Node.Handle, + distance: f16, + } = null; + for (sp.slots, 0..) |slot, handle| { + // Never match ourself + if (handle == from) continue; + + // Only match leaves + switch (self.nodes[handle]) { + .leaf => {}, + .split => continue, + } + + // Ensure it is in the proper direction + if (!switch (direction) { + .left => slot.maxX() <= target.x, + .right => slot.x >= target.maxX(), + .up => slot.maxY() <= target.y, + .down => slot.y >= target.maxY(), + }) continue; + + // Track our distance + const dx = slot.x - target.x; + const dy = slot.y - target.y; + const distance = @sqrt(dx * dx + dy * dy); + + // If we have a nearest it must be closer. + if (result) |n| { + if (distance >= n.distance) continue; + } + result = .{ + .handle = @intCast(handle), + .distance = distance, + }; + } + + return if (result) |n| n.handle else null; + } + /// Resize the given node in place. The node MUST be a split (asserted). /// /// In general, this is an immutable data structure so this is @@ -370,6 +419,7 @@ pub fn SplitTree(comptime V: type) type { gpa: Allocator, at: Node.Handle, direction: Split.Direction, + ratio: f16, insert: *const Self, ) Allocator.Error!Self { // The new arena for our new tree. @@ -414,7 +464,7 @@ pub fn SplitTree(comptime V: type) type { nodes[nodes.len - 1] = nodes[at]; nodes[at] = .{ .split = .{ .layout = layout, - .ratio = 0.5, + .ratio = ratio, .left = @intCast(if (left) self.nodes.len else nodes.len - 1), .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), } }; @@ -621,48 +671,6 @@ pub fn SplitTree(comptime V: type) type { alloc.free(self.slots); self.* = undefined; } - - /// Returns the nearest leaf node (view) in the given direction. - pub fn nearestLeaf( - self: *const Spatial, - from: Node.Handle, - direction: Direction, - ) ?Node.Handle { - const target = self.slots[from]; - - var nearest: ?struct { - handle: Node.Handle, - distance: f16, - } = null; - for (self.slots, 0..) |slot, handle| { - // Never match ourself - if (handle == from) continue; - - // Ensure it is in the proper direction - if (!switch (direction) { - .left => slot.maxX() <= target.maxX(), - .right => slot.x >= target.maxX(), - .up => slot.maxY() <= target.y, - .down => slot.y >= target.maxY(), - }) continue; - - // Track our distance - const dx = slot.x - target.x; - const dy = slot.y - target.y; - const distance = @sqrt(dx * dx + dy * dy); - - // If we have a nearest it must be closer. - if (nearest) |n| { - if (distance >= n.distance) continue; - } - nearest = .{ - .handle = @intCast(handle), - .distance = distance, - }; - } - - return if (nearest) |n| n.handle else null; - } }; /// Spatial representation of the split tree. This can be used to @@ -990,6 +998,12 @@ pub fn SplitTree(comptime V: type) type { // Output every row for (grid) |row| { + // We currently have a bug in our height calculation that + // results in trailing blank lines. Ignore those. We should + // really fix our height calculation instead. If someone wants + // to do that just remove this line and see the tests that fail + // and go from there. + if (row[0] == ' ') break; try writer.writeAll(row); } } @@ -1125,6 +1139,7 @@ test "SplitTree: split horizontal" { alloc, 0, // at root .right, // split right + 0.5, &t2, // insert t2 ); defer t3.deinit(); @@ -1156,6 +1171,7 @@ test "SplitTree: split horizontal" { } } else return error.NotFound, .right, + 0.5, &tC, ); defer t4.deinit(); @@ -1189,6 +1205,7 @@ test "SplitTree: split horizontal" { } } else return error.NotFound, .right, + 0.5, &tD, ); defer t5.deinit(); @@ -1287,6 +1304,7 @@ test "SplitTree: split vertical" { alloc, 0, // at root .down, // split down + 0.5, &t2, // insert t2 ); defer t3.deinit(); @@ -1318,6 +1336,7 @@ test "SplitTree: remove leaf" { alloc, 0, // at root .right, // split right + 0.5, &t2, // insert t2 ); defer t3.deinit(); @@ -1363,6 +1382,7 @@ test "SplitTree: split twice, remove intermediary" { alloc, 0, // at root .right, // split right + 0.5, &t2, // insert t2 ); defer split1.deinit(); @@ -1372,6 +1392,7 @@ test "SplitTree: split twice, remove intermediary" { alloc, 0, // at root .down, // split down + 0.5, &t3, // insert t3 ); defer split2.deinit(); @@ -1425,6 +1446,125 @@ test "SplitTree: split twice, remove intermediary" { } } +test "SplitTree: spatial goto" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var v3: TestTree.View = .{ .label = "C" }; + var t3: TestTree = try .init(alloc, &v3); + defer t3.deinit(); + var v4: TestTree.View = .{ .label = "D" }; + var t4: TestTree = try .init(alloc, &v4); + defer t4.deinit(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer splitAB.deinit(); + + // A | C vertical + var splitAC = try splitAB.split( + alloc, + at: { + var it = splitAB.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }, + .down, // split down + 0.8, + &t3, // insert t3 + ); + defer splitAC.deinit(); + + // B | D vertical + var splitBD = try splitAC.split( + alloc, + at: { + var it = splitAB.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .down, // split down + 0.3, + &t4, // insert t4 + ); + defer splitBD.deinit(); + const split = splitBD; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| || B | + \\| |+---+ + \\| |+---+ + \\| A || | + \\| || | + \\| || | + \\| || D | + \\+---+| | + \\+---+| | + \\| C || | + \\+---++---+ + \\ + ); + } + + // Spatial C => right + { + const target = (try split.goto( + alloc, + from: { + var it = split.iterator(); + break :from while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "C")) { + break entry.handle; + } + } else return error.NotFound; + }, + .{ .spatial = .right }, + )).?; + const view = split.nodes[target].leaf; + try testing.expectEqualStrings(view.label, "D"); + } + + // Spatial D => left + { + const target = (try split.goto( + alloc, + from: { + var it = split.iterator(); + break :from while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "D")) { + break entry.handle; + } + } else return error.NotFound; + }, + .{ .spatial = .left }, + )).?; + const view = split.nodes[target].leaf; + try testing.expectEqualStrings("A", view.label); + } +} + test "SplitTree: clone empty tree" { const testing = std.testing; const alloc = testing.allocator; From 9f037a7c237944ce7fb3eba834a94080706b2291 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 11:25:23 -0700 Subject: [PATCH 77/83] apprt/gtk-ng: equalize splits --- src/apprt/gtk-ng/class/application.zig | 17 +++++- src/apprt/gtk-ng/class/split_tree.zig | 18 +++++++ src/datastruct/split_tree.zig | 74 ++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index a09ce4e64..eda2a49eb 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -553,6 +553,8 @@ pub const Application = extern struct { .desktop_notification => Action.desktopNotification(self, target, value), + .equalize_splits => return Action.equalizeSplits(target), + .goto_split => return Action.gotoSplit(target, value), .goto_tab => return Action.gotoTab(target, value), @@ -617,7 +619,6 @@ pub const Application = extern struct { .inspector, // TODO: splits .resize_split, - .equalize_splits, .toggle_split_zoom, => { log.warn("unimplemented action={}", .{action}); @@ -1652,6 +1653,20 @@ const Action = struct { gio_app.sendNotification(n.body, notification); } + pub fn equalizeSplits(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("equalize splits to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("split-tree.equalize", null) != 0; + }, + } + } + pub fn gotoSplit( target: apprt.Target, to: apprt.action.GotoSplit, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 567166329..3018afdb4 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -163,6 +163,8 @@ pub const SplitTree = extern struct { .{ "new-right", actionNewRight, null }, .{ "new-up", actionNewUp, null }, .{ "new-down", actionNewDown, null }, + + .{ "equalize", actionEqualize, null }, }; // We need to collect our actions into a group since we're just @@ -537,6 +539,22 @@ pub const SplitTree = extern struct { }; } + pub fn actionEqualize( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + + const old_tree = self.getTree() orelse return; + var new_tree = old_tree.equalize(Application.default().allocator()) catch |err| { + log.warn("unable to equalize tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + } + fn surfaceCloseRequest( surface: *Surface, scope: *const Surface.CloseScope, diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 3003b73c7..6d224757b 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -642,6 +642,62 @@ pub fn SplitTree(comptime V: type) type { assert(reffed == nodes.len - 1); } + /// Equalize this node and all its children, returning a new node with splits + /// adjusted so that each split's ratio is based on the relative weight + /// (number of leaves) of its children. + pub fn equalize( + self: *const Self, + gpa: Allocator, + ) Allocator.Error!Self { + if (self.isEmpty()) return .empty; + + // Create a new arena allocator for the clone. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate a new nodes array and copy the existing nodes into it. + const nodes = try alloc.dupe(Node, self.nodes); + + // Go through and equalize our ratios based on weights. + for (nodes) |*node| switch (node.*) { + .leaf => {}, + .split => |*s| { + const weight_left = self.weight(s.left, s.layout, 0); + const weight_right = self.weight(s.right, s.layout, 0); + assert(weight_left > 0); + assert(weight_right > 0); + const total_f16: f16 = @floatFromInt(weight_left + weight_right); + const weight_left_f16: f16 = @floatFromInt(weight_left); + s.ratio = weight_left_f16 / total_f16; + }, + }; + + // Increase the reference count of all the views in the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + fn weight( + self: *const Self, + from: Node.Handle, + layout: Split.Layout, + acc: usize, + ) usize { + return switch (self.nodes[from]) { + .leaf => acc + 1, + .split => |s| if (s.layout == layout) + self.weight(s.left, layout, acc) + + self.weight(s.right, layout, acc) + else + 1, + }; + } + /// Spatial representation of the split tree. See spatial. pub const Spatial = struct { /// The slots of the spatial representation in the same order @@ -1563,6 +1619,24 @@ test "SplitTree: spatial goto" { const view = split.nodes[target].leaf; try testing.expectEqualStrings("A", view.label); } + + // Equalize + var equal = try split.equalize(alloc); + defer equal.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{equal}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\+---++---+ + \\| C || D | + \\+---++---+ + \\ + ); + } } test "SplitTree: clone empty tree" { From 2a5b7aab86e4c0b5327f53a6aa7affde45be05d5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 8 Aug 2025 12:57:39 -0500 Subject: [PATCH 78/83] gtk-ng: don't use signals to toggle command palette --- src/apprt/gtk-ng/class/command_palette.zig | 18 +++++++------- src/apprt/gtk-ng/class/surface.zig | 22 +---------------- src/apprt/gtk-ng/class/window.zig | 26 ++++----------------- src/apprt/gtk-ng/ui/1.5/command-palette.blp | 1 + 4 files changed, 17 insertions(+), 50 deletions(-) diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig index ee10989b7..eb00f6df4 100644 --- a/src/apprt/gtk-ng/class/command_palette.zig +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -174,11 +174,14 @@ pub const CommandPalette = extern struct { } } + fn dialogClosed(_: adw.Dialog, self: *CommandPalette) callconv(.c) void { + self.unref(); + } + fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { // ESC was pressed - close the palette const priv = self.private(); _ = priv.dialog.close(); - self.unref(); } fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { @@ -198,11 +201,9 @@ pub const CommandPalette = extern struct { pub fn toggle(self: *CommandPalette, window: *Window) void { const priv = self.private(); - // If the dialog has been shown, close it and unref ourselves so all of - // our memory is reclaimed. + // If the dialog has been shown, close it. if (priv.dialog.as(gtk.Widget).getRealized() != 0) { _ = priv.dialog.close(); - self.unref(); return; } @@ -216,6 +217,10 @@ pub const CommandPalette = extern struct { /// Helper function to send a signal containing the action that should be /// performed. fn activated(self: *CommandPalette, pos: c_uint) void { + // add a reference to keep ourselves around until we're done + _ = self.ref(); + defer self.unref(); + const priv = self.private(); // Close before running the action in order to avoid being replaced by @@ -224,10 +229,6 @@ pub const CommandPalette = extern struct { // and cannot receive focus when reopened. _ = priv.dialog.close(); - // We are always done with the command palette when this finishes, even - // if there were errors. - defer self.unref(); - // Use priv.model and not priv.source here to use the list of *visible* results const object = priv.model.as(gio.ListModel).getObject(pos) orelse return; defer object.unref(); @@ -277,6 +278,7 @@ pub const CommandPalette = extern struct { class.bindTemplateChildPrivate("source", .{}); // Template Callbacks + class.bindTemplateCallback("closed", &dialogClosed); class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("search_stopped", &searchStopped); class.bindTemplateCallback("search_activated", &searchActivated); diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 05393bd4f..2891840a0 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -361,19 +361,6 @@ pub const Surface = extern struct { void, ); }; - - /// Emitted when this surface requests that the command palette be - /// toggled. - pub const @"toggle-command-palette" = struct { - pub const name = "toggle-command-palette"; - pub const connect = impl.connect; - const impl = gobject.ext.defineSignal( - name, - Self, - &.{}, - void, - ); - }; }; const Private = struct { @@ -564,13 +551,7 @@ pub const Surface = extern struct { } pub fn toggleCommandPalette(self: *Self) bool { - signals.@"toggle-command-palette".impl.emit( - self, - null, - .{}, - null, - ); - return true; + return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } /// Set the current progress report state. @@ -2423,7 +2404,6 @@ pub const Surface = extern struct { signals.@"present-request".impl.register(.{}); signals.@"toggle-fullscreen".impl.register(.{}); signals.@"toggle-maximize".impl.register(.{}); - signals.@"toggle-command-palette".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index f3e8ee129..ee5753560 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -714,13 +714,6 @@ pub const Window = extern struct { self, .{}, ); - _ = Surface.signals.@"toggle-command-palette".connect( - surface, - *Self, - surfaceToggleCommandPalette, - self, - .{}, - ); // If we've never had a surface initialize yet, then we register // this signal. Its theoretically possible to launch multiple surfaces @@ -1529,15 +1522,6 @@ pub const Window = extern struct { // We react to the changes in the propMaximized callback } - /// React to a signal from a surface requesting that the command palette - /// be toggled. - fn surfaceToggleCommandPalette( - _: *Surface, - self: *Self, - ) callconv(.c) void { - self.toggleCommandPalette(); - } - fn surfaceInit( surface: *Surface, self: *Self, @@ -1722,7 +1706,7 @@ pub const Window = extern struct { fn toggleCommandPalette(self: *Window) void { const priv = self.private(); // Get a reference to a command palette. First check the weak reference - // that we save to see if we already have stored. If we don't then + // that we save to see if we already have one stored. If we don't then // create a new one. const command_palette = gobject.ext.cast(CommandPalette, priv.command_palette.get()) orelse command_palette: { // Create a fresh command palette. @@ -1747,14 +1731,14 @@ pub const Window = extern struct { .{}, ); + // Save a weak reference to the command palette. We use a weak reference to avoid + // reference counting cycles that might cause problems later. + priv.command_palette.set(command_palette.as(gobject.Object)); + break :command_palette command_palette; }; defer command_palette.unref(); - // Save a weak reference to the command palette. We use a weak reference to avoid - // reference counting cycles that might cause problems later. - priv.command_palette.set(command_palette.as(gobject.Object)); - // Tell the command palette to toggle itself. If the dialog gets // presented (instead of hidden) it will be modal over our window. command_palette.toggle(self); diff --git a/src/apprt/gtk-ng/ui/1.5/command-palette.blp b/src/apprt/gtk-ng/ui/1.5/command-palette.blp index 0ccae1f0a..473fb1f06 100644 --- a/src/apprt/gtk-ng/ui/1.5/command-palette.blp +++ b/src/apprt/gtk-ng/ui/1.5/command-palette.blp @@ -4,6 +4,7 @@ using Adw 1; Adw.Dialog dialog { content-width: 700; + closed => $closed(); Adw.ToolbarView { top-bar-style: flat; From 8af1230228ef4d3f592cfc21a662e18a0e6c97a1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 8 Aug 2025 13:49:13 -0500 Subject: [PATCH 79/83] gtk-ng: don't add extra refs when activating a command in the palette --- src/apprt/gtk-ng/class/command_palette.zig | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig index eb00f6df4..5d00cf446 100644 --- a/src/apprt/gtk-ng/class/command_palette.zig +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -217,24 +217,19 @@ pub const CommandPalette = extern struct { /// Helper function to send a signal containing the action that should be /// performed. fn activated(self: *CommandPalette, pos: c_uint) void { - // add a reference to keep ourselves around until we're done - _ = self.ref(); - defer self.unref(); - const priv = self.private(); + // Use priv.model and not priv.source here to use the list of *visible* results + const object_ = priv.model.as(gio.ListModel).getObject(pos); + defer if (object_) |object| object.unref(); + // Close before running the action in order to avoid being replaced by // another dialog (such as the change title dialog). If that occurs then // the command palette dialog won't be counted as having closed properly // and cannot receive focus when reopened. _ = priv.dialog.close(); - // Use priv.model and not priv.source here to use the list of *visible* results - const object = priv.model.as(gio.ListModel).getObject(pos) orelse return; - defer object.unref(); - - const cmd = gobject.ext.cast(Command, object) orelse return; - + const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return; const action = cmd.getAction() orelse return; // Signal that an an action has been selected. Signals are synchronous From 3221421a74dbc1d4fb432b45110b595ce6ee1081 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 8 Aug 2025 13:51:51 -0500 Subject: [PATCH 80/83] gtk-ng: add TODOs about passing surface that toggled command palette --- src/apprt/gtk-ng/class/surface.zig | 1 + src/apprt/gtk-ng/class/window.zig | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 2891840a0..8487b24b0 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -551,6 +551,7 @@ pub const Surface = extern struct { } pub fn toggleCommandPalette(self: *Self) bool { + // TODO: pass the surface with the action return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index ee5753560..fe9a2d7b6 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -343,6 +343,7 @@ pub const Window = extern struct { .{ "paste", actionPaste, null }, .{ "reset", actionReset, null }, .{ "clear", actionClear, null }, + // TODO: accept the surface that toggled the command palette .{ "toggle-command-palette", actionToggleCommandPalette, null }, }; @@ -1703,6 +1704,8 @@ pub const Window = extern struct { } /// Toggle the command palette. + /// + /// TODO: accept the surface that toggled the command palette as a parameter fn toggleCommandPalette(self: *Window) void { const priv = self.private(); // Get a reference to a command palette. First check the weak reference @@ -1756,6 +1759,8 @@ pub const Window = extern struct { _: ?*glib.Variant, self: *Window, ) callconv(.c) void { + // TODO: accept the surface that toggled the command palette as a + // parameter self.toggleCommandPalette(); } From 5c088d10a495cb04ec1b83204c62fda8e86b64af Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 8 Aug 2025 21:07:54 -0500 Subject: [PATCH 81/83] gtk-ng: fix memory leaks in command palette --- src/apprt/gtk-ng/class/command_palette.zig | 14 +++++++++----- src/apprt/gtk-ng/class/window.zig | 11 +++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig index 5d00cf446..8b7bb328c 100644 --- a/src/apprt/gtk-ng/class/command_palette.zig +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -174,14 +174,18 @@ pub const CommandPalette = extern struct { } } - fn dialogClosed(_: adw.Dialog, self: *CommandPalette) callconv(.c) void { + fn close(self: *CommandPalette) void { + const priv = self.private(); + _ = priv.dialog.close(); + } + + fn dialogClosed(_: *adw.Dialog, self: *CommandPalette) callconv(.c) void { self.unref(); } fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { // ESC was pressed - close the palette - const priv = self.private(); - _ = priv.dialog.close(); + self.close(); } fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { @@ -203,7 +207,7 @@ pub const CommandPalette = extern struct { // If the dialog has been shown, close it. if (priv.dialog.as(gtk.Widget).getRealized() != 0) { - _ = priv.dialog.close(); + self.close(); return; } @@ -227,7 +231,7 @@ pub const CommandPalette = extern struct { // another dialog (such as the change title dialog). If that occurs then // the command palette dialog won't be counted as having closed properly // and cannot receive focus when reopened. - _ = priv.dialog.close(); + self.close(); const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return; const action = cmd.getAction() orelse return; diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index fe9a2d7b6..ae953eee0 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1064,10 +1064,18 @@ pub const Window = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); + + command_palette: { + // TODO: this can be simplified once WeakRef.get() can return a null. + const command_palette = gobject.ext.cast(CommandPalette, priv.command_palette.get()) orelse break :command_palette; + command_palette.unref(); + } + if (priv.config) |v| { v.unref(); priv.config = null; } + priv.tab_bindings.setSource(null); gtk.Widget.disposeTemplate( @@ -1708,9 +1716,12 @@ pub const Window = extern struct { /// TODO: accept the surface that toggled the command palette as a parameter fn toggleCommandPalette(self: *Window) void { const priv = self.private(); + // Get a reference to a command palette. First check the weak reference // that we save to see if we already have one stored. If we don't then // create a new one. + // + // TODO: once WeakRef.get() can return a null this will need to be fixed up. const command_palette = gobject.ext.cast(CommandPalette, priv.command_palette.get()) orelse command_palette: { // Create a fresh command palette. const command_palette = CommandPalette.new(); From 2de0c108ba0448b391eb65b80533bd323ddd2fd3 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 8 Aug 2025 21:45:41 -0500 Subject: [PATCH 82/83] gtk-ng: better handling of weak references The upstream GIR for g_weak_ref_get is incorrect - it does not allow the returned value to be NULL. This PR pulls in a new version of our GObject bindings with that patched and improves the safety of dealing with the command palette weak reference held by the window. See ianprime0509/zig-gobject#117 --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- src/apprt/gtk-ng/class/window.zig | 14 ++++++-------- 6 files changed, 18 insertions(+), 20 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index ee283870b..55a693496 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,8 +55,8 @@ .gobject = .{ // https://github.com/jcollie/ghostty-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst", - .hash = "gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma", + .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", + .hash = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 190fc1cd2..24f1053ba 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma": { + "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM": { "name": "gobject", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst", - "hash": "sha256-43IIiHR5J7PfgG9JXSlGgC6WztC10fXyIhGZfY9xceQ=" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", + "hash": "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 3708d61ed..380bafaeb 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -122,11 +122,11 @@ in }; } { - name = "gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma"; + name = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst"; - hash = "sha256-43IIiHR5J7PfgG9JXSlGgC6WztC10fXyIhGZfY9xceQ="; + url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst"; + hash = "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 2f3a9cab9..14bb0e8df 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d6 https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst +https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 56f4dc6a4..d50371f5f 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-07-34-1/ghostty-gobject-0.14.1-2025-08-07-34-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7F_XnABQYabYdzLoVbO3bCcJIwxE3NCPs1_fG2ma", - "sha256": "e3720888747927b3df806f495d2946802e96ced0b5d1f5f22211997d8f7171e4" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", + "sha256": "074ce22f32ae77e91d2aee53d414c4f46321f043ccfca861868349972b3940f2" }, { "type": "archive", diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index ae953eee0..47f95aece 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1065,11 +1065,7 @@ pub const Window = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - command_palette: { - // TODO: this can be simplified once WeakRef.get() can return a null. - const command_palette = gobject.ext.cast(CommandPalette, priv.command_palette.get()) orelse break :command_palette; - command_palette.unref(); - } + if (priv.command_palette.get()) |object| object.unref(); if (priv.config) |v| { v.unref(); @@ -1720,9 +1716,11 @@ pub const Window = extern struct { // Get a reference to a command palette. First check the weak reference // that we save to see if we already have one stored. If we don't then // create a new one. - // - // TODO: once WeakRef.get() can return a null this will need to be fixed up. - const command_palette = gobject.ext.cast(CommandPalette, priv.command_palette.get()) orelse command_palette: { + const command_palette = command_palette: { + if (priv.command_palette.get()) |object| not_command_palette: { + break :command_palette gobject.ext.cast(CommandPalette, object) orelse break :not_command_palette; + } + // Create a fresh command palette. const command_palette = CommandPalette.new(); From 5bb88d259c11160973e1bd7dd0643c13e6c0ec47 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 9 Aug 2025 11:12:06 -0500 Subject: [PATCH 83/83] gtk-ng: use WeakRef helper for type safety --- src/apprt/gtk-ng/class/application.zig | 2 +- src/apprt/gtk-ng/class/split_tree.zig | 2 +- src/apprt/gtk-ng/class/window.zig | 13 +++++-------- src/apprt/gtk-ng/weak_ref.zig | 11 ++++------- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index eda2a49eb..4a14434fa 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -133,7 +133,7 @@ pub const Application = extern struct { /// If non-null, we're currently showing a config errors dialog. /// This is a WeakRef because the dialog can close on its own /// outside of our own lifecycle and that's okay. - config_errors_dialog: WeakRef(ConfigErrorsDialog) = .{}, + config_errors_dialog: WeakRef(ConfigErrorsDialog) = .empty, /// glib source for our signal handler. signal_source: ?c_uint = null, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 3018afdb4..5eb0a5472 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -119,7 +119,7 @@ pub const SplitTree = extern struct { /// Last focused surface in the tree. We need this to handle various /// tree change states. - last_focused: WeakRef(Surface) = .{}, + last_focused: WeakRef(Surface) = .empty, /// The source that we use to rebuild the tree. This is also /// used to debounce updates. diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 47f95aece..eb41b61d0 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -28,6 +28,7 @@ const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const CommandPalette = @import("command_palette.zig").CommandPalette; +const WeakRef = @import("../weak_ref.zig").WeakRef; const log = std.log.scoped(.gtk_ghostty_window); @@ -249,7 +250,7 @@ pub const Window = extern struct { tab_overview_focus_timer: ?c_uint = null, /// A weak reference to a command palette. - command_palette: gobject.WeakRef = std.mem.zeroes(gobject.WeakRef), + command_palette: WeakRef(CommandPalette) = .empty, // Template bindings tab_overview: *adw.TabOverview, @@ -1065,7 +1066,7 @@ pub const Window = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.command_palette.get()) |object| object.unref(); + priv.command_palette.set(null); if (priv.config) |v| { v.unref(); @@ -1716,11 +1717,7 @@ pub const Window = extern struct { // Get a reference to a command palette. First check the weak reference // that we save to see if we already have one stored. If we don't then // create a new one. - const command_palette = command_palette: { - if (priv.command_palette.get()) |object| not_command_palette: { - break :command_palette gobject.ext.cast(CommandPalette, object) orelse break :not_command_palette; - } - + const command_palette = priv.command_palette.get() orelse command_palette: { // Create a fresh command palette. const command_palette = CommandPalette.new(); @@ -1745,7 +1742,7 @@ pub const Window = extern struct { // Save a weak reference to the command palette. We use a weak reference to avoid // reference counting cycles that might cause problems later. - priv.command_palette.set(command_palette.as(gobject.Object)); + priv.command_palette.set(command_palette); break :command_palette command_palette; }; diff --git a/src/apprt/gtk-ng/weak_ref.zig b/src/apprt/gtk-ng/weak_ref.zig index 7ee5cf730..f689e45fa 100644 --- a/src/apprt/gtk-ng/weak_ref.zig +++ b/src/apprt/gtk-ng/weak_ref.zig @@ -10,6 +10,8 @@ pub fn WeakRef(comptime T: type) type { ref: gobject.WeakRef = std.mem.zeroes(gobject.WeakRef), + pub const empty: Self = .{}; + /// Set the weak reference to the given object. This will not /// increase the reference count of the object. pub fn set(self: *Self, v_: ?*T) void { @@ -23,14 +25,9 @@ pub fn WeakRef(comptime T: type) type { /// Get a strong reference to the object, or null if the object /// has been finalized. This increases the reference count by one. pub fn get(self: *Self) ?*T { - // The GIR of g_weak_ref_get has a bug where the optional - // is not encoded. Or, it may be a bug in zig-gobject. - const obj_: ?*gobject.Object = @ptrCast(self.ref.get()); - const obj = obj_ orelse return null; - // We can't use `as` because `as` guarantees conversion and // that can't be statically guaranteed. - return gobject.ext.cast(T, obj); + return gobject.ext.cast(T, self.ref.get() orelse return null); } }; } @@ -38,7 +35,7 @@ pub fn WeakRef(comptime T: type) type { test WeakRef { const testing = std.testing; - var ref: WeakRef(gtk.TextBuffer) = .{}; + var ref: WeakRef(gtk.TextBuffer) = .empty; const obj: *gtk.TextBuffer = .new(null); ref.set(obj); ref.get().?.unref(); // The "?" asserts non-null