diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 0818a98f6..f606435c6 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, + .{ .major = 1, .minor = 5, .name = "surface-title-dialog" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 28d1e6a22..becfac14a 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -589,6 +589,8 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), + .prompt_title => return Action.promptTitle(target), + .quit => self.quit(), .quit_timer => try Action.quitTimer(self, value), @@ -618,7 +620,6 @@ pub const Application = extern struct { .toggle_split_zoom => return Action.toggleSplitZoom(target), // Unimplemented but todo on gtk-ng branch - .prompt_title, .inspector, => { log.warn("unimplemented action={}", .{action}); @@ -1955,6 +1956,16 @@ const Action = struct { }; } + pub fn promptTitle(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + } + } + /// Reload the configuration for the application and propagate it /// across the entire application and all terminals. pub fn reloadConfig( diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 631b93e42..adf72c5ce 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -27,6 +27,7 @@ const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; +const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; const Window = @import("window.zig").Window; const log = std.log.scoped(.gtk_ghostty_surface); @@ -186,8 +187,6 @@ pub const Surface = extern struct { pub const @"mouse-hover-url" = struct { pub const name = "mouse-hover-url"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -201,8 +200,6 @@ pub const Surface = extern struct { pub const pwd = struct { pub const name = "pwd"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -216,8 +213,6 @@ pub const Surface = extern struct { 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, @@ -229,6 +224,19 @@ pub const Surface = 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 zoom = struct { pub const name = "zoom"; const impl = gobject.ext.defineProperty( @@ -401,6 +409,9 @@ pub const Surface = extern struct { /// The title of this surface, if any has been set. title: ?[:0]const u8 = null, + /// The manually overridden title of this surface from `promptTitle`. + title_override: ?[:0]const u8 = null, + /// The current focus state of the terminal based on the /// focus events. focused: bool = true, @@ -883,6 +894,26 @@ pub const Surface = extern struct { return false; } + /// Prompt for a manual title change for the surface. + pub fn promptTitle(self: *Self) void { + const priv = self.private(); + const dialog = gobject.ext.newInstance( + TitleDialog, + .{ + .@"initial-value" = priv.title_override orelse priv.title, + }, + ); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + /// Scale x/y by the GDK device scale. fn scaledCoordinates( self: *Self, @@ -1145,6 +1176,9 @@ pub const Surface = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + // Initialize our actions + self.initActions(); + const priv = self.private(); // Initialize some private fields so they aren't undefined @@ -1191,6 +1225,45 @@ pub const Surface = extern struct { self.propConfig(undefined, null); } + 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 = .{ + .{ "prompt-title", actionPromptTitle, 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( + "surface", + group.as(gio.ActionGroup), + ); + } + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { @@ -1254,6 +1327,10 @@ pub const Surface = extern struct { glib.free(@constCast(@ptrCast(v))); priv.title = null; } + if (priv.title_override) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.title_override = null; + } self.clearCgroup(); gobject.Object.virtual_methods.finalize.call( @@ -1270,7 +1347,9 @@ pub const Surface = extern struct { return self.private().title; } - /// Set the title for this surface, copies the value. + /// Set the title for this surface, copies the value. This should always + /// be the title as set by the terminal program, not any manually set + /// title. For manually set titles see `setTitleOverride`. pub fn setTitle(self: *Self, title: ?[:0]const u8) void { const priv = self.private(); if (priv.title) |v| glib.free(@constCast(@ptrCast(v))); @@ -1279,6 +1358,16 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec); } + /// 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(@constCast(@ptrCast(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); + } + /// Returns the pwd property without a copy. pub fn getPwd(self: *Self) ?[:0]const u8 { return self.private().pwd; @@ -1582,6 +1671,17 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Signal Handlers + pub fn actionPromptTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.prompt_surface_title) catch |err| { + log.warn("unable to perform prompt title action err={}", .{err}); + }; + } + fn childExitedClose( _: *ChildExited, self: *Self, @@ -2413,6 +2513,15 @@ pub const Surface = extern struct { media_file.unref(); } + fn titleDialogSet( + _: *TitleDialog, + 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); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -2493,6 +2602,7 @@ pub const Surface = extern struct { properties.@"mouse-hover-url".impl, properties.pwd.impl, properties.title.impl, + properties.@"title-override".impl, properties.zoom.impl, }); diff --git a/src/apprt/gtk-ng/class/surface_title_dialog.zig b/src/apprt/gtk-ng/class/surface_title_dialog.zig new file mode 100644 index 000000000..de36f3090 --- /dev/null +++ b/src/apprt/gtk-ng/class/surface_title_dialog.zig @@ -0,0 +1,190 @@ +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 adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_surface_title_dialog); + +pub const SurfaceTitleDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.AlertDialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurfaceTitleDialog", + .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(@constCast(@ptrCast(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 = "surface-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-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 9a65cd2d7..520050cb6 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -364,7 +364,8 @@ pub const Tab = extern struct { fn closureComputedTitle( _: *Self, config_: ?*Config, - plain_: ?[*:0]const u8, + terminal_: ?[*:0]const u8, + override_: ?[*:0]const u8, zoomed_: c_int, bell_ringing_: c_int, _: *gobject.ParamSpec, @@ -372,9 +373,13 @@ 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 + // the terminal title if it exists, otherwise a default string. const plain = plain: { const default = "Ghostty"; - const plain = plain_ orelse break :plain default; + const plain = override_ orelse + terminal_ orelse + break :plain default; break :plain std.mem.span(plain); }; diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 49aae0a04..1493b9997 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -168,7 +168,7 @@ menu context_menu_model { item { label: _("Change Title…"); - action: "win.prompt-title"; + action: "surface.prompt-title"; } item { diff --git a/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp b/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp new file mode 100644 index 000000000..24ae26f37 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySurfaceTitleDialog: Adw.AlertDialog { + heading: _("Change Terminal Title"); + body: _("Leave blank to restore the default title."); + + responses [ + cancel: _("Cancel") suggested, + ok: _("OK") destructive, + ] + + focus-widget: entry; + + extra-child: Entry entry {}; +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 4b92e38f2..687b18890 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/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.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, 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 {