Gtk: change title prompt (#5905)

This PR implements the title change functionality for the GTK app and
closes https://github.com/ghostty-org/ghostty/issues/5769

Demo:


https://github.com/user-attachments/assets/cad611b3-b7bf-40ac-8d0f-11d2095fe525

For now I came up with a basic UI that i believe is sufficient, but I'm
open to feedback and happy to iterate on it further.

https://github.com/ghostty-org/ghostty/issues/5769#issuecomment-2660341107
- Regarding Mitchell's comment I checked Gnome Console and I could not
find a similar feature.
This commit is contained in:
Jeffrey C. Ollie
2025-02-22 17:00:40 -06:00
committed by GitHub
11 changed files with 226 additions and 21 deletions

View File

@ -70,8 +70,10 @@ parts:
plugin: nil
build-attributes: [enable-patchelf]
build-packages:
- blueprint-compiler
- libgtk-4-dev
- libadwaita-1-dev
- libxml2-utils
- git
- patchelf
override-build: |

View File

@ -495,6 +495,7 @@ pub fn performAction(
.toggle_split_zoom => self.toggleSplitZoom(target),
.toggle_window_decorations => self.toggleWindowDecorations(target),
.quit_timer => self.quitTimer(value),
.prompt_title => try self.promptTitle(target),
// Unimplemented
.close_all_windows,
@ -506,7 +507,6 @@ pub fn performAction(
.render_inspector,
.renderer_health,
.color_change,
.prompt_title,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@ -770,6 +770,15 @@ fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
}
}
fn promptTitle(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |v| {
try v.rt_surface.promptTitle();
},
}
}
fn setTitle(
_: *App,
target: apprt.Target,
@ -777,7 +786,7 @@ fn setTitle(
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setTitle(title.title),
.surface => |v| try v.rt_surface.setTitle(title.title, .terminal),
}
}
@ -1016,6 +1025,7 @@ fn syncActionAccelerators(self: *App) !void {
try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} });
try self.syncActionAccelerator("win.reset", .{ .reset = {} });
try self.syncActionAccelerator("win.clear", .{ .clear_screen = {} });
try self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} });
}
fn syncActionAccelerator(

View File

@ -25,7 +25,7 @@ pub fn init(comptime name: []const u8, comptime kind: enum { blp, ui }) Builder
// GResource.
const gresource = @import("gresource.zig");
for (gresource.blueprint_files) |blueprint_file| {
if (std.mem.eql(u8, blueprint_file, name)) break;
if (std.mem.eql(u8, blueprint_file.name, name)) break;
} else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig");
},
.ui => {

View File

@ -4,6 +4,10 @@
const Surface = @This();
const std = @import("std");
const adw = @import("adw");
const gtk = @import("gtk");
const gio = @import("gio");
const gobject = @import("gobject");
const Allocator = std.mem.Allocator;
const build_config = @import("../../build_config.zig");
const build_options = @import("build_options");
@ -26,6 +30,8 @@ const ResizeOverlay = @import("ResizeOverlay.zig");
const inspector = @import("inspector.zig");
const gtk_key = @import("key.zig");
const c = @import("c.zig").c;
const Builder = @import("Builder.zig");
const adwaita = @import("adwaita.zig");
const log = std.log.scoped(.gtk_surface);
@ -347,6 +353,12 @@ cursor: ?*c.GdkCursor = null,
/// pass it to GTK.
title_text: ?[:0]const u8 = null,
/// The title of the surface as reported by the terminal. If it is null, the
/// title reported by the terminal is currently being used. If the title was
/// manually overridden by the user, this will be set to a non-null value
/// representing the default terminal title.
title_from_terminal: ?[:0]const u8 = null,
/// Our current working directory. We use this value for setting tooltips in
/// the headerbar subtitle if we have focus. When set, the text in this buf
/// will be null-terminated because we need to pass it to GTK.
@ -663,6 +675,7 @@ fn realize(self: *Surface) !void {
pub fn deinit(self: *Surface) void {
self.init_config.deinit(self.app.core_app.alloc);
if (self.title_text) |title| self.app.core_app.alloc.free(title);
if (self.title_from_terminal) |title| self.app.core_app.alloc.free(title);
if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd);
// We don't allocate anything if we aren't realized.
@ -940,8 +953,9 @@ fn updateTitleLabels(self: *Surface) void {
}
const zoom_title_prefix = "🔍 ";
pub const SetTitleSource = enum { user, terminal };
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !void {
const alloc = self.app.core_app.alloc;
// Always allocate with the "🔍 " at the beginning and slice accordingly
@ -954,6 +968,14 @@ pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
};
errdefer alloc.free(copy);
// The user has overridden the title
// We only want to update the terminal provided title so that it can be restored to the most recent state.
if (self.title_from_terminal != null and source == .terminal) {
alloc.free(self.title_from_terminal.?);
self.title_from_terminal = copy;
return;
}
if (self.title_text) |old| alloc.free(old);
self.title_text = copy;
@ -978,15 +1000,41 @@ fn updateTitleTimerExpired(ctx: ?*anyopaque) callconv(.C) c.gboolean {
pub fn getTitle(self: *Surface) ?[:0]const u8 {
if (self.title_text) |title_text| {
return if (self.zoomed_in)
title_text
else
title_text[zoom_title_prefix.len..];
return self.resolveTitle(title_text);
}
return null;
}
pub fn getTerminalTitle(self: *Surface) ?[:0]const u8 {
if (self.title_from_terminal) |title_text| {
return self.resolveTitle(title_text);
}
return null;
}
fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 {
return if (self.zoomed_in)
title
else
title[zoom_title_prefix.len..];
}
pub fn promptTitle(self: *Surface) !void {
if (!adwaita.versionAtLeast(1, 5, 0)) return;
const window = self.container.window() orelse return;
var builder = Builder.init("prompt-title-dialog", .blp);
defer builder.deinit();
const entry = builder.getObject(gtk.Entry, "title_entry").?;
entry.getBuffer().setText(self.getTitle() orelse "", -1);
const dialog = builder.getObject(adw.AlertDialog, "prompt_title_dialog").?;
dialog.choose(@ptrCast(window.window), null, gtkPromptTitleResponse, self);
}
/// Set the current working directory of the surface.
///
/// In addition, update the tab's tooltip text, and if we are the focused child,
@ -2273,3 +2321,40 @@ fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool {
}
return false;
}
fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void {
if (!adwaita.versionAtLeast(1, 5, 0)) return;
const dialog = gobject.ext.cast(adw.AlertDialog, source_object.?).?;
const self = userdataSelf(ud orelse return);
const response = dialog.chooseFinish(result);
if (std.mem.orderZ(u8, "ok", response) == .eq) {
const title_entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?;
const title = std.mem.span(title_entry.getBuffer().getText());
// if the new title is empty and the user has set the title previously, restore the terminal provided title
if (title.len == 0) {
if (self.getTerminalTitle()) |terminal_title| {
self.setTitle(terminal_title, .user) catch |err| {
log.err("failed to set title={}", .{err});
};
self.app.core_app.alloc.free(self.title_from_terminal.?);
self.title_from_terminal = null;
}
} else if (title.len > 0) {
// if this is the first time the user is setting the title, save the current terminal provided title
if (self.title_from_terminal == null and self.title_text != null) {
self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) {
error.OutOfMemory => {
log.err("failed to allocate memory for title={}", .{err});
return;
},
};
}
self.setTitle(title, .user) catch |err| {
log.err("failed to set title={}", .{err});
};
}
}
}

View File

@ -478,6 +478,7 @@ fn initActions(self: *Window) void {
.{ "paste", &gtkActionPaste },
.{ "reset", &gtkActionReset },
.{ "clear", &gtkActionClear },
.{ "prompt_title", &gtkActionPromptTitle },
};
inline for (actions) |entry| {
@ -1071,6 +1072,19 @@ fn gtkActionClear(
};
}
fn gtkActionPromptTitle(
_: *c.GSimpleAction,
_: *c.GVariant,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud orelse return));
const surface = self.actionSurface() orelse return;
_ = surface.performBindingAction(.{ .prompt_surface_title = {} }) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
}
/// Returns the surface to use for an action.
pub fn actionSurface(self: *Window) ?*CoreSurface {
const tab = self.notebook.currentTab() orelse return null;

View File

@ -0,0 +1,57 @@
const std = @import("std");
pub const c = @cImport({
@cInclude("adwaita.h");
});
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
_ = it.next();
const major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10);
const minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10);
const micro = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMicroVersion, 10);
const output = it.next() orelse return error.NoOutput;
const input = it.next() orelse return error.NoInput;
if (c.ADW_MAJOR_VERSION < major or
(c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or
(c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION == minor and c.ADW_MICRO_VERSION < micro))
{
// If the Adwaita version is too old, generate an "empty" file.
const file = try std.fs.createFileAbsolute(output, .{
.truncate = true,
});
try file.writeAll(
\\<?xml version="1.0" encoding="UTF-8"?>
\\<interface domain="com.mitchellh.ghostty"/>
);
defer file.close();
return;
}
var compiler = std.process.Child.init(
&.{
"blueprint-compiler",
"compile",
"--output",
output,
input,
},
alloc,
);
const term = try compiler.spawnAndWait();
switch (term) {
.Exited => |rc| {
if (rc != 0) std.posix.exit(1);
},
else => std.posix.exit(1),
}
}

View File

@ -57,7 +57,17 @@ pub const ui_files = [_][]const u8{
"menu-window-titlebar_menu",
"menu-surface-context_menu",
};
pub const blueprint_files = [_][]const u8{};
pub const VersionedBlueprint = struct {
major: u16,
minor: u16,
micro: u16,
name: []const u8,
};
pub const blueprint_files = [_]VersionedBlueprint{
.{ .major = 1, .minor = 5, .micro = 0, .name = "prompt-title-dialog" },
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
@ -72,9 +82,9 @@ pub fn main() !void {
var it = try std.process.argsWithAllocator(alloc);
defer it.deinit();
while (it.next()) |filename| {
if (std.mem.eql(u8, std.fs.path.extension(filename), ".ui")) {
try extra_ui_files.append(try alloc.dupe(u8, filename));
while (it.next()) |argument| {
if (std.mem.eql(u8, std.fs.path.extension(argument), ".ui")) {
try extra_ui_files.append(try alloc.dupe(u8, argument));
}
}
@ -144,7 +154,7 @@ pub const dependencies = deps: {
index += 1;
}
for (blueprint_files) |blueprint_file| {
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{s}.blp", .{blueprint_file});
deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{s}.blp", .{blueprint_file.name});
index += 1;
}
break :deps deps;

View File

@ -0,0 +1,16 @@
using Gtk 4.0;
using Adw 1;
Adw.AlertDialog prompt_title_dialog {
heading: _("Change Terminal Title");
body: _("Leave blank to restore the default title.");
responses [
cancel: _("Cancel") suggested,
ok: _("OK") destructive
]
focus-widget: title_entry;
extra-child: Entry title_entry {};
}

View File

@ -443,6 +443,7 @@ pub fn add(
.{ "glib", "glib2" },
.{ "gtk", "gtk4" },
.{ "gdk", "gdk4" },
.{ "adw", "adw1" },
};
inline for (gobject_imports) |import| {
const name, const module = import;
@ -451,7 +452,6 @@ pub fn add(
step.linkSystemLibrary2("gtk4", dynamic_link_opts);
step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
step.root_module.addImport("adw", gobject.module("adw1"));
if (self.config.x11) {
step.linkSystemLibrary2("X11", dynamic_link_opts);
@ -500,14 +500,24 @@ pub fn add(
const generate = b.addRunArtifact(generate_gresource_xml);
const gtk_blueprint_compiler = b.addExecutable(.{
.name = "gtk_blueprint_compiler",
.root_source_file = b.path("src/apprt/gtk/blueprint_compiler.zig"),
.target = b.host,
});
gtk_blueprint_compiler.linkSystemLibrary2("gtk4", dynamic_link_opts);
gtk_blueprint_compiler.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
gtk_blueprint_compiler.linkLibC();
for (gresource.blueprint_files) |blueprint_file| {
const blueprint_compiler = b.addSystemCommand(&.{
"blueprint-compiler",
"compile",
"--output",
const blueprint_compiler = b.addRunArtifact(gtk_blueprint_compiler);
blueprint_compiler.addArgs(&.{
b.fmt("{d}", .{blueprint_file.major}),
b.fmt("{d}", .{blueprint_file.minor}),
b.fmt("{d}", .{blueprint_file.micro}),
});
const ui_file = blueprint_compiler.addOutputFileArg(b.fmt("{s}.ui", .{blueprint_file}));
blueprint_compiler.addFileArg(b.path(b.fmt("src/apprt/gtk/ui/{s}.blp", .{blueprint_file})));
const ui_file = blueprint_compiler.addOutputFileArg(b.fmt("{s}.ui", .{blueprint_file.name}));
blueprint_compiler.addFileArg(b.path(b.fmt("src/apprt/gtk/ui/{s}.blp", .{blueprint_file.name})));
generate.addFileArg(ui_file);
}

View File

@ -5,9 +5,11 @@ FROM docker.io/library/debian:${DISTRO_VERSION}
RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \
apt-get -qq -y --no-install-recommends install \
# Build Tools
blueprint-compiler \
build-essential \
libbz2-dev \
libonig-dev \
libxml2-utils \
lintian \
lsb-release \
libxml2-utils \

View File

@ -349,7 +349,6 @@ pub const Action = union(enum) {
toggle_tab_overview: void,
/// Change the title of the current focused surface via a prompt.
/// This only works on macOS currently.
prompt_surface_title: void,
/// Create a new split in the given direction.