diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9ef2f5cc4..49d452ef9 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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: | diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b26bc046f..e9434bb5b 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -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( diff --git a/src/apprt/gtk/Builder.zig b/src/apprt/gtk/Builder.zig index f9b0c226a..473abc0f7 100644 --- a/src/apprt/gtk/Builder.zig +++ b/src/apprt/gtk/Builder.zig @@ -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 => { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 9835b5b77..2636b41aa 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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}); + }; + } + } +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 7b74da722..22148706c 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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; diff --git a/src/apprt/gtk/blueprint_compiler.zig b/src/apprt/gtk/blueprint_compiler.zig new file mode 100644 index 000000000..f1d42c43d --- /dev/null +++ b/src/apprt/gtk/blueprint_compiler.zig @@ -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( + \\ + \\ + ); + 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), + } +} diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index d45997d6c..83978c337 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -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; diff --git a/src/apprt/gtk/ui/prompt-title-dialog.blp b/src/apprt/gtk/ui/prompt-title-dialog.blp new file mode 100644 index 000000000..ffe38c980 --- /dev/null +++ b/src/apprt/gtk/ui/prompt-title-dialog.blp @@ -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 {}; +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index a90fc330a..65b2b47da 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -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); } diff --git a/src/build/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index 307fb7521..7f60ddf1d 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -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 \ diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f91967293..d1e66210b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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.