mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
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:
@ -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: |
|
||||
|
@ -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(
|
||||
|
@ -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 => {
|
||||
|
@ -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});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -478,6 +478,7 @@ fn initActions(self: *Window) void {
|
||||
.{ "paste", >kActionPaste },
|
||||
.{ "reset", >kActionReset },
|
||||
.{ "clear", >kActionClear },
|
||||
.{ "prompt_title", >kActionPromptTitle },
|
||||
};
|
||||
|
||||
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;
|
||||
|
57
src/apprt/gtk/blueprint_compiler.zig
Normal file
57
src/apprt/gtk/blueprint_compiler.zig
Normal 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),
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
16
src/apprt/gtk/ui/prompt-title-dialog.blp
Normal file
16
src/apprt/gtk/ui/prompt-title-dialog.blp
Normal 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 {};
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 \
|
||||
|
@ -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.
|
||||
|
Reference in New Issue
Block a user