From a03d721599bbf0cb91e965ec0b18415e920e4ca6 Mon Sep 17 00:00:00 2001 From: David Matos Date: Sat, 13 Dec 2025 13:41:35 +0100 Subject: [PATCH] gtk: Change tab title --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/application.zig | 18 +- .../gtk/class/prompt_tab_title_dialog.zig | 186 ++++++++++++++++++ src/apprt/gtk/class/tab.zig | 72 ++++++- src/apprt/gtk/class/window.zig | 9 + src/apprt/gtk/ui/1.2/surface.blp | 5 + .../gtk/ui/1.5/prompt-tab-title-dialog.blp | 19 ++ src/apprt/gtk/ui/1.5/tab.blp | 2 +- src/apprt/gtk/ui/1.5/window.blp | 5 + 9 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 src/apprt/gtk/class/prompt_tab_title_dialog.zig create mode 100644 src/apprt/gtk/ui/1.5/prompt-tab-title-dialog.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index c77579aab..282643cfc 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -42,6 +42,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "imgui-widget" }, .{ .major = 1, .minor = 5, .name = "inspector-widget" }, .{ .major = 1, .minor = 5, .name = "inspector-window" }, + .{ .major = 1, .minor = 5, .name = "prompt-tab-title-dialog" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 2, .name = "search-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d404304d0..51441fe15 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -34,6 +34,7 @@ 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 Tab = @import("tab.zig").Tab; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; @@ -2326,8 +2327,21 @@ const Action = struct { }, }, .tab => { - // GTK does not yet support tab title prompting - return false; + switch (target) { + .app => return false, + .surface => |v| { + const surface = v.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring prompt_tab_title", .{}); + return false; + }; + tab.promptTabTitle(); + return true; + }, + } }, } } diff --git a/src/apprt/gtk/class/prompt_tab_title_dialog.zig b/src/apprt/gtk/class/prompt_tab_title_dialog.zig new file mode 100644 index 000000000..00163ac79 --- /dev/null +++ b/src/apprt/gtk/class/prompt_tab_title_dialog.zig @@ -0,0 +1,186 @@ +const std = @import("std"); +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const ext = @import("../ext.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_prompt_tab_title_dialog); + +pub const PromptTabTitleDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.AlertDialog; + pub const getGObjectType = gobject.ext + .defineClass(Self, .{ + .name = "GhosttyPromptTabTitleDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + pub const properties = struct { + pub const @"initial-value" = struct { + pub const name = "initial-value"; + pub const get = impl.get; + pub const set = impl.set; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("initial_value"), + }, + ); + }; + }; + pub const signals = struct { + /// Set the title to the given value. + pub const set = struct { + pub const name = "set"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{[*:0]const u8}, + void, + ); + }; + }; + + const Private = struct { + /// The initial value of the entry field. + initial_value: ?[:0]const u8 = null, + + // Template bindings + entry: *gtk.Entry, + + pub var offset: c_int = 0; + }; + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + pub fn present(self: *Self, parent_: *gtk.Widget) void { + // If we have a window we can attach to, we prefer that. + const parent: *gtk.Widget = if (ext.getAncestor( + adw.ApplicationWindow, + parent_, + )) |window| + window.as(gtk.Widget) + else if (ext.getAncestor( + adw.Window, + parent_, + )) |window| + window.as(gtk.Widget) + else + parent_; + + // Set our initial value + const priv = self.private(); + if (priv.initial_value) |v| { + priv.entry.getBuffer().setText(v, -1); + } + + // Show it. We could also just use virtual methods to bind to + // response but this is pretty simple. + self.as(adw.AlertDialog).choose( + parent, + null, + alertDialogReady, + self, + ); + } + + fn alertDialogReady( + _: ?*gobject.Object, + result: *gio.AsyncResult, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + const response = self.as(adw.AlertDialog).chooseFinish(result); + + // If we didn't hit "okay" then we do nothing. + if (std.mem.orderZ(u8, "ok", response) != .eq) return; + + // Emit our signal with the new title. + const title = std.mem.span(self.private().entry.getBuffer().getText()); + signals.set.impl.emit( + self, + null, + .{title.ptr}, + null, + ); + } + + 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(); + if (priv.initial_value) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.initial_value = 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; + 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 = "prompt-tab-title-dialog", + }), + ); + + // Signals + signals.set.impl.register(.{}); + + // Bindings + class.bindTemplateChildPrivate("entry", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"initial-value".impl, + }); + + // 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/class/tab.zig b/src/apprt/gtk/class/tab.zig index fb3b8b0ef..8426e89c3 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -14,6 +14,8 @@ const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; +const TabDialog = @import("prompt_tab_title_dialog.zig") + .PromptTabTitleDialog; const log = std.log.scoped(.gtk_ghostty_window); @@ -125,6 +127,18 @@ pub const Tab = extern struct { }, ); }; + pub const @"title-override" = struct { + pub const name = "title-override"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title_override"), + }, + ); + }; }; pub const signals = struct { @@ -148,6 +162,9 @@ pub const Tab = extern struct { /// The title of this tab. This is usually bound to the active surface. title: ?[:0]const u8 = null, + /// The manually overridden title from `promptTabTitle`. + title_override: ?[:0]const u8 = null, + /// The tooltip of this tab. This is usually bound to the active surface. tooltip: ?[:0]const u8 = null, @@ -198,6 +215,7 @@ pub const Tab = extern struct { const actions = [_]ext.actions.Action(Self){ .init("close", actionClose, s_param_type), .init("ring-bell", actionRingBell, null), + .init("prompt-tab-title", actionPromptTabTitle, null), }; _ = ext.actions.addAsGroup(Self, self, "tab", &actions); @@ -206,6 +224,42 @@ pub const Tab = extern struct { //--------------------------------------------------------------- // Properties + /// Overridden title. This will be generally be shown over the title + /// unless this is unset (null). + pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec); + } + fn tabDialogSet( + _: *TabDialog, + title_ptr: [*:0]const u8, + self: *Self, + ) callconv(.c) void { + const title = std.mem.span(title_ptr); + self.setTitleOverride(if (title.len == 0) null else title); + } + pub fn promptTabTitle(self: *Self) void { + const priv = self.private(); + const dialog = gobject.ext.newInstance( + TabDialog, + .{ + .@"initial-value" = priv.title_override orelse priv.title, + }, + ); + _ = TabDialog.signals.set.connect( + dialog, + *Self, + tabDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. pub fn getActiveSurface(self: *Self) ?*Surface { @@ -351,6 +405,14 @@ pub const Tab = extern struct { } } + fn actionPromptTabTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + self.promptTabTitle(); + } + fn actionRingBell( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -372,7 +434,8 @@ pub const Tab = extern struct { _: *Self, config_: ?*Config, terminal_: ?[*:0]const u8, - override_: ?[*:0]const u8, + surface_override_: ?[*:0]const u8, + tab_override: ?[*:0]const u8, zoomed_: c_int, bell_ringing_: c_int, _: *gobject.ParamSpec, @@ -380,7 +443,8 @@ pub const Tab = extern struct { const zoomed = zoomed_ != 0; const bell_ringing = bell_ringing_ != 0; - // Our plain title is the overridden title if it exists, otherwise + // Our plain title is the manually tab overriden title if it exists, + // otherwise the overridden title if it exists, otherwise // the terminal title if it exists, otherwise a default string. const plain = plain: { const default = "Ghostty"; @@ -389,7 +453,8 @@ pub const Tab = extern struct { break :title config.get().title orelse null; }; - const plain = override_ orelse + const plain = tab_override orelse + surface_override_ orelse terminal_ orelse config_title orelse break :plain default; @@ -453,6 +518,7 @@ pub const Tab = extern struct { properties.@"split-tree".impl, properties.@"surface-tree".impl, properties.title.impl, + properties.@"title-override".impl, properties.tooltip.impl, }); diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 77fd2eea5..d71e6c768 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -335,6 +335,7 @@ pub const Window = extern struct { .init("close-tab", actionCloseTab, s_variant_type), .init("new-tab", actionNewTab, null), .init("new-window", actionNewWindow, null), + .init("prompt-tab-title", actionPromptTabTitle, null), .init("ring-bell", actionRingBell, null), .init("split-right", actionSplitRight, null), .init("split-left", actionSplitLeft, null), @@ -1763,6 +1764,14 @@ pub const Window = extern struct { self.performBindingAction(.new_tab); } + fn actionPromptTabTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_tab_title); + } + fn actionSplitRight( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 4ebfeabfb..2d73652ef 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -277,6 +277,11 @@ menu context_menu_model { submenu { label: _("Tab"); + item { + label: _("Change Tab Title..."); + action: "tab.prompt-tab-title"; + } + item { label: _("New Tab"); action: "win.new-tab"; diff --git a/src/apprt/gtk/ui/1.5/prompt-tab-title-dialog.blp b/src/apprt/gtk/ui/1.5/prompt-tab-title-dialog.blp new file mode 100644 index 000000000..695b5521e --- /dev/null +++ b/src/apprt/gtk/ui/1.5/prompt-tab-title-dialog.blp @@ -0,0 +1,19 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyPromptTabTitleDialog: Adw.AlertDialog { + heading: _("Change Tab Title"); + body: _("Leave blank to restore the default."); + + responses [ + cancel: _("Cancel"), + ok: _("OK") suggested, + ] + + default-response: "ok"; + focus-widget: entry; + + extra-child: Entry entry { + activates-default: true; + }; +} diff --git a/src/apprt/gtk/ui/1.5/tab.blp b/src/apprt/gtk/ui/1.5/tab.blp index 687b18890..2df290da3 100644 --- a/src/apprt/gtk/ui/1.5/tab.blp +++ b/src/apprt/gtk/ui/1.5/tab.blp @@ -8,7 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override,split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb..88e7d5324 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -218,6 +218,11 @@ menu main_menu { } section { + item { + label: _("Change Tab Title…"); + action: "win.prompt-tab-title"; + } + item { label: _("New Tab"); action: "win.new-tab";