apprt/gtk-ng: tab tooltips, window-subtitle, split zoom title prefix (#8218)

This brings together all our title-related functionality (so far).

To make this all work, I heavily use (abuse?) blueprint bindings with
closures. Blueprint sets up property subscription for all closure
parameters and all properties in a chain (`a.b.c`). This makes for a
really long, deeply nested property access but it saves us literally
hundreds (at least 100) lines of `notify` signal subscription
boilerplate.

This also lets some of these properties be truly dynamic and avoid
simply copying around intermediate values up the widget tree.

Unfortunately Blueprint's auto-formatter won't let us split property
access or function parameters onto separate lines so we're going to have
some very, very long lines.
This commit is contained in:
Mitchell Hashimoto
2025-08-13 06:18:52 -07:00
committed by GitHub
4 changed files with 103 additions and 22 deletions

View File

@@ -70,6 +70,24 @@ pub const Tab = extern struct {
);
};
pub const @"split-tree" = struct {
pub const name = "split-tree";
const impl = gobject.ext.defineProperty(
name,
Self,
?*SplitTree,
.{
.accessor = gobject.ext.typedAccessor(
Self,
?*SplitTree,
.{
.getter = getSplitTree,
},
),
},
);
};
pub const @"surface-tree" = struct {
pub const name = "surface-tree";
const impl = gobject.ext.defineProperty(
@@ -88,10 +106,21 @@ pub const Tab = extern struct {
);
};
pub const tooltip = struct {
pub const name = "tooltip";
const impl = gobject.ext.defineProperty(
name,
Self,
?[:0]const u8,
.{
.default = null,
.accessor = C.privateStringFieldAccessor("tooltip"),
},
);
};
pub const title = struct {
pub const name = "title";
pub const get = impl.get;
pub const set = impl.set;
const impl = gobject.ext.defineProperty(
name,
Self,
@@ -122,12 +151,11 @@ pub const Tab = extern struct {
/// The configuration that this surface is using.
config: ?*Config = null,
/// The title to show for this tab. This is usually set to a binding
/// with the active surface but can be manually set to anything.
/// The title of this tab. This is usually bound to the active surface.
title: ?[:0]const u8 = null,
/// The binding groups for the current active surface.
surface_bindings: *gobject.BindingGroup,
/// The tooltip of this tab. This is usually bound to the active surface.
tooltip: ?[:0]const u8 = null,
// Template bindings
split_tree: *SplitTree,
@@ -155,15 +183,6 @@ pub const Tab = extern struct {
priv.config = app.getConfig();
}
// Setup binding groups for surface properties
priv.surface_bindings = gobject.BindingGroup.new();
priv.surface_bindings.bind(
"title",
self.as(gobject.Object),
"title",
.{},
);
// Create our initial surface in the split tree.
priv.split_tree.newSplit(.right, null) catch |err| switch (err) {
error.OutOfMemory => {
@@ -213,7 +232,6 @@ pub const Tab = extern struct {
v.unref();
priv.config = null;
}
priv.surface_bindings.setSource(null);
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
@@ -228,11 +246,14 @@ pub const Tab = extern struct {
fn finalize(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.tooltip) |v| {
glib.free(@constCast(@ptrCast(v)));
priv.tooltip = null;
}
if (priv.title) |v| {
glib.free(@constCast(@ptrCast(v)));
priv.title = null;
}
priv.surface_bindings.unref();
gobject.Object.virtual_methods.finalize.call(
Class.parent,
@@ -267,13 +288,36 @@ pub const Tab = extern struct {
_: *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));
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
}
fn closureComputedTitle(
_: *Self,
plain_: ?[*:0]const u8,
zoomed_: c_int,
) callconv(.c) ?[*:0]const u8 {
const zoomed = zoomed_ != 0;
const plain = plain: {
const default = "Ghostty";
const plain = plain_ orelse break :plain default;
break :plain std.mem.span(plain);
};
// If we're zoomed, prefix with the magnifying glass emoji.
if (zoomed) zoomed: {
// This results in an extra allocation (that we free), but I
// prefer using the Zig APIs so much more than the libc ones.
const alloc = Application.default().allocator();
const slice = std.fmt.allocPrint(
alloc,
"🔍 {s}",
.{plain},
) catch break :zoomed;
defer alloc.free(slice);
return glib.ext.dupeZ(u8, slice);
}
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
return glib.ext.dupeZ(u8, plain);
}
const C = Common(Self, Private);
@@ -303,14 +347,17 @@ pub const Tab = extern struct {
gobject.ext.registerProperties(class, &.{
properties.@"active-surface".impl,
properties.config.impl,
properties.@"split-tree".impl,
properties.@"surface-tree".impl,
properties.title.impl,
properties.tooltip.impl,
});
// Bindings
class.bindTemplateChildPrivate("split_tree", .{});
// Template Callbacks
class.bindTemplateCallback("computed_title", &closureComputedTitle);
class.bindTemplateCallback("notify_active_surface", &propActiveSurface);
class.bindTemplateCallback("notify_tree", &propSplitTree);

View File

@@ -416,6 +416,12 @@ pub const Window = extern struct {
"title",
.{ .sync_create = true },
);
_ = tab.as(gobject.Object).bindProperty(
"tooltip",
page.as(gobject.Object),
"tooltip",
.{ .sync_create = true },
);
// Bind signals
const split_tree = tab.getSplitTree();
@@ -1060,6 +1066,21 @@ pub const Window = extern struct {
});
}
fn closureSubtitle(
_: *Self,
config_: ?*Config,
pwd_: ?[*:0]const u8,
) callconv(.c) ?[*:0]const u8 {
const config = if (config_) |v| v.get() else return null;
return switch (config.@"window-subtitle") {
.false => null,
.@"working-directory" => pwd: {
const pwd = pwd_ orelse return null;
break :pwd glib.ext.dupeZ(u8, std.mem.span(pwd));
},
};
}
//---------------------------------------------------------------
// Virtual methods
@@ -1783,6 +1804,9 @@ pub const Window = extern struct {
fn init(class: *Class) callconv(.c) void {
gobject.ext.ensureType(DebugWarning);
gobject.ext.ensureType(SplitTree);
gobject.ext.ensureType(Surface);
gobject.ext.ensureType(Tab);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
@@ -1832,6 +1856,7 @@ pub const Window = extern struct {
class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal);
class.bindTemplateCallback("notify_scale_factor", &propScaleFactor);
class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab);
class.bindTemplateCallback("computed_subtitle", &closureSubtitle);
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);

View File

@@ -8,6 +8,8 @@ template $GhosttyTab: Box {
orientation: vertical;
hexpand: true;
vexpand: true;
title: bind $computed_title(split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed) as <string>;
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
$GhosttySplitTree split_tree {
notify::active-surface => $notify_active_surface();

View File

@@ -40,6 +40,13 @@ template $GhosttyWindow: Adw.ApplicationWindow {
title-widget: Adw.WindowTitle {
title: bind template.title;
// Blueprint auto-formatter won't let me split this into multiple
// lines. Let me explain myself. All parameters to a closure are used
// as notifications to recompute the value of the closure. All
// elements of a property chain are also subscribed to for changes.
// This one long, ugly line saves us from manually building up this
// massive notify chain in code.
subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as <string>;
};
[start]