gtk/adw: add context menu to tab titles to rename tab

This commit is contained in:
Jeffrey C. Ollie
2025-01-27 10:46:31 -06:00
parent b916e1aca6
commit 7f3724ec6f
12 changed files with 337 additions and 32 deletions

View File

@ -0,0 +1,124 @@
const RenameTabAdw = @This();
const std = @import("std");
const assert = std.debug.assert;
const c = @import("c.zig").c;
const Builder = @import("Builder.zig");
const Tab = @import("Tab.zig");
const NotebookAdw = @import("notebook_adw.zig").NotebookAdw;
const log = std.log.scoped(.gtk_rename_tab);
tab: ?*Tab = null,
dialog: ?*c.AdwDialog = null,
title: ?*c.AdwEntryRow = null,
pub fn init(self: *RenameTabAdw) void {
self.* = .{};
}
pub fn deinit(self: *RenameTabAdw) void {
if (self.dialog) |dialog| {
_ = c.adw_dialog_close(dialog);
}
}
pub fn show(self: *RenameTabAdw, tab: *Tab) void {
if (self.tab) |old_tab| {
if (old_tab == tab) {
if (self.dialog) |dialog| {
c.gtk_window_present(@ptrCast(@alignCast(dialog)));
return;
}
}
}
if (self.dialog) |dialog| {
_ = c.adw_dialog_close(dialog);
}
self.* = .{};
assert(tab.window.notebook == .adw);
const builder = Builder.init("window-adw-rename-tab");
defer builder.deinit();
const dialog = builder.getObject(c.AdwDialog, "rename-tab");
const title = builder.getObject(c.AdwEntryRow, "title");
if (tab.manual_title) |manual_label| {
var value: c.GValue = std.mem.zeroes(c.GValue);
defer c.g_value_unset(&value);
_ = c.g_value_init(&value, c.G_TYPE_STRING);
c.g_value_set_string(&value, manual_label.ptr);
c.g_object_set_property(@ptrCast(@alignCast(title)), "text", &value);
}
self.* = .{
.tab = tab,
.dialog = dialog,
.title = title,
};
_ = c.g_signal_connect_data(dialog, "closed", c.G_CALLBACK(&adwDialogClosed), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(title, "apply", c.G_CALLBACK(&adwEntryRowApply), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(title, "entry-activated", c.G_CALLBACK(&adwEntryRowApply), self, null, c.G_CONNECT_DEFAULT);
c.adw_dialog_present(dialog, @ptrCast(@alignCast(tab.box)));
}
fn adwDialogClosed(dialog: *c.AdwDialog, ud: ?*anyopaque) callconv(.C) void {
const self: *RenameTabAdw = @ptrCast(@alignCast(ud orelse return));
assert(dialog == self.dialog);
if (self.tab) |tab| {
tab.window.focusCurrentTab();
}
self.* = .{};
}
fn adwEntryRowApply(
_: *c.GObject,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *RenameTabAdw = @ptrCast(@alignCast(ud orelse return));
const tab = self.tab orelse return;
const title = self.title orelse return;
var value: c.GValue = std.mem.zeroes(c.GValue);
defer c.g_value_unset(&value);
_ = c.g_value_init(&value, c.G_TYPE_STRING);
c.g_object_get_property(@ptrCast(@alignCast(title)), "text", &value);
const text = c.g_value_get_string(&value);
tab.setManualTitle(std.mem.span(text));
_ = c.adw_dialog_close(self.dialog);
}
fn adwEntryRowEntryActivated(
_: *c.GObject,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *RenameTabAdw = @ptrCast(@alignCast(ud orelse return));
const tab = self.tab orelse return;
const title = self.title orelse return;
var value: c.GValue = std.mem.zeroes(c.GValue);
defer c.g_value_unset(&value);
_ = c.g_value_init(&value, c.G_TYPE_STRING);
c.g_object_get_property(@ptrCast(@alignCast(title)), "text", &value);
const text = c.g_value_get_string(&value);
tab.setManualTitle(std.mem.span(text));
_ = c.adw_dialog_close(self.dialog);
}

View File

@ -908,7 +908,7 @@ fn updateTitleLabels(self: *Surface) void {
// If we have a tab and are the focused child, then we have to update the tab
if (self.container.tab()) |tab| {
if (tab.focus_child == self) tab.setLabelText(title);
if (tab.focus_child == self) tab.setTitleText(title);
}
// If we have a window and are focused, then we have to update the window title.

View File

@ -37,6 +37,12 @@ elem: Surface.Container.Elem,
// can easily re-focus that terminal.
focus_child: ?*Surface,
/// Manually set title, will override titles set by ESC sequences.
manual_title: ?[:0]const u8 = null,
/// The last title set by ESC sequences.
auto_title: ?[:0]const u8 = null,
pub fn create(alloc: Allocator, window: *Window, parent_: ?*CoreSurface) !*Tab {
var tab = try alloc.create(Tab);
errdefer alloc.destroy(tab);
@ -88,6 +94,8 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
/// Deinits tab by deiniting child elem.
pub fn deinit(self: *Tab, alloc: Allocator) void {
if (self.manual_title) |manual_title| alloc.free(manual_title);
if (self.auto_title) |auto_title| alloc.free(auto_title);
self.elem.deinit(alloc);
}
@ -108,8 +116,49 @@ pub fn replaceElem(self: *Tab, elem: Surface.Container.Elem) void {
self.elem = elem;
}
pub fn setLabelText(self: *Tab, title: [:0]const u8) void {
self.window.notebook.setTabLabel(self, title);
pub fn setManualTitle(self: *Tab, title: []const u8) void {
const alloc = self.window.app.core_app.alloc;
self.unsetManualTitle();
const stripped = std.mem.trim(u8, title, &std.ascii.whitespace);
if (stripped.len == 0) {
if (self.auto_title) |auto_title| {
self.window.notebook.setTabTitle(self, auto_title);
}
return;
}
const manual_title = alloc.dupeZ(u8, title) catch |err| {
log.warn("unable to copy manual title: {}", .{err});
return;
};
self.manual_title = manual_title;
self.window.notebook.setTabTitle(self, manual_title);
}
pub fn unsetManualTitle(self: *Tab) void {
const alloc = self.window.app.core_app.alloc;
if (self.manual_title) |manual_title| {
alloc.free(manual_title);
self.manual_title = null;
}
}
pub fn setTitleText(self: *Tab, title: [:0]const u8) void {
const alloc = self.window.app.core_app.alloc;
if (self.manual_title) |manual_title| {
self.window.notebook.setTabTitle(self, manual_title);
return;
}
if (self.auto_title) |auto_title| {
alloc.free(auto_title);
self.auto_title = null;
}
self.auto_title = alloc.dupeZ(u8, title) catch null;
self.window.notebook.setTabTitle(self, title);
}
pub fn setTooltipText(self: *Tab, tooltip: [:0]const u8) void {

View File

@ -510,6 +510,7 @@ fn initActions(self: *Window) void {
.{ "paste", &gtkActionPaste },
.{ "reset", &gtkActionReset },
.{ "clear", &gtkActionClear },
.{ "rename-tab", &gtkActionRenameTab },
};
inline for (actions) |entry| {
@ -528,6 +529,7 @@ fn initActions(self: *Window) void {
}
pub fn deinit(self: *Window) void {
self.notebook.deinit();
self.winproto.deinit(self.app.core_app.alloc);
if (self.adw_tab_overview_focus_timer) |timer| {
@ -1138,6 +1140,15 @@ fn gtkActionClear(
};
}
fn gtkActionRenameTab(
_: *c.GSimpleAction,
_: *c.GVariant,
ud: ?*anyopaque,
) callconv(.C) void {
const self: *Window = @ptrCast(@alignCast(ud orelse return));
self.notebook.showRenameTab();
}
/// Returns the surface to use for an action.
pub fn actionSurface(self: *Window) ?*CoreSurface {
const tab = self.notebook.currentTab() orelse return null;

View File

@ -1,7 +1,11 @@
const std = @import("std");
const build_options = @import("build_options");
pub const c = @cImport({
@cInclude("gtk/gtk.h");
if (build_options.adwaita) {
@cInclude("libadwaita-1/adwaita.h");
}
});
pub fn main() !void {
@ -17,6 +21,12 @@ pub fn main() !void {
};
defer alloc.free(filename);
if (comptime build_options.adwaita) {
c.adw_init();
} else {
if (std.mem.indexOf(u8, filename, "adw")) |_| return;
}
const builder = c.gtk_builder_new_from_file(filename.ptr);
defer c.g_object_unref(builder);
}

View File

@ -54,9 +54,11 @@ const icons = [_]struct {
};
pub const ui_files = [_][]const u8{
"menu-adw-notebook-tab",
"menu-surface-context_menu",
"menu-window-menubar",
"menu-window-titlebar_menu",
"window-adw-rename-tab",
};
pub const gresource_xml = comptimeGenerateGResourceXML();

View File

@ -12,8 +12,6 @@ const log = std.log.scoped(.gtk);
const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque;
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
/// An abstraction over the GTK notebook and Adwaita tab view to manage
/// all the terminal tabs in a window.
pub const Notebook = union(enum) {
@ -28,6 +26,12 @@ pub const Notebook = union(enum) {
return NotebookGtk.init(self);
}
pub fn deinit(self: *Notebook) void {
switch (self.*) {
inline else => |*n| n.deinit(),
}
}
pub fn asWidget(self: *Notebook) *c.GtkWidget {
return switch (self.*) {
.adw => |*adw| adw.asWidget(),
@ -121,10 +125,10 @@ pub const Notebook = union(enum) {
}
}
pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
pub fn setTabTitle(self: *Notebook, tab: *Tab, title: [:0]const u8) void {
switch (self.*) {
.adw => |*adw| adw.setTabLabel(tab, title),
.gtk => |*gtk| gtk.setTabLabel(tab, title),
.adw => |*adw| adw.setTabTitle(tab, title),
.gtk => |*gtk| gtk.setTabTitle(tab, title),
}
}
@ -158,6 +162,13 @@ pub const Notebook = union(enum) {
.gtk => |*gtk| gtk.closeTab(tab),
}
}
pub fn showRenameTab(self: *Notebook) void {
switch (self.*) {
.adw => |*adw| adw.showRenameTab(),
.gtk => {},
}
}
};
pub fn createWindow(currentWindow: *Window) !*Window {

View File

@ -7,6 +7,8 @@ const Tab = @import("Tab.zig");
const Notebook = @import("notebook.zig").Notebook;
const createWindow = @import("notebook.zig").createWindow;
const adwaita = @import("adwaita.zig");
const Builder = @import("Builder.zig");
const RenameTabAdw = if (adwaita.versionAtLeast(0, 0, 0)) @import("RenameTabAdw.zig") else struct {};
const log = std.log.scoped(.gtk);
@ -14,6 +16,9 @@ const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopa
const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque;
pub const NotebookAdw = struct {
/// The window that we belong to
window: *Window,
/// the tab view
tab_view: *AdwTabView,
@ -25,7 +30,14 @@ pub const NotebookAdw = struct {
/// confirming or not.
forcing_close: bool = false,
/// The last tab that was selected with the "setup-menu" signal.
last_setup_menu_tab: ?*Tab = null,
/// rename tab window
rename_tab: RenameTabAdw = .{},
pub fn init(notebook: *Notebook) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const window: *Window = @fieldParentPtr("notebook", notebook);
const app = window.app;
assert(adwaita.enabled(&app.config));
@ -33,33 +45,46 @@ pub const NotebookAdw = struct {
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook");
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
if (adwaita.versionAtLeast(1, 2, 0)) {
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
}
const builder = Builder.init("menu-adw-notebook-tab");
defer builder.deinit();
c.adw_tab_view_set_menu_model(tab_view, builder.getObject(c.GMenuModel, "menu"));
notebook.* = .{
.adw = .{
.window = window,
.tab_view = tab_view,
},
};
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "close-page", c.G_CALLBACK(&adwClosePage), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT);
const self = &notebook.adw;
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "close-page", c.G_CALLBACK(&adwClosePage), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "setup-menu", c.G_CALLBACK(&adwTabViewSetupMenu), self, null, c.G_CONNECT_DEFAULT);
}
pub fn deinit(self: *NotebookAdw) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
self.rename_tab.deinit();
}
pub fn asWidget(self: *NotebookAdw) *c.GtkWidget {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
return @ptrCast(@alignCast(self.tab_view));
}
pub fn nPages(self: *NotebookAdw) c_int {
if (comptime adwaita.versionAtLeast(0, 0, 0))
return c.adw_tab_view_get_n_pages(self.tab_view)
else
unreachable;
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
return c.adw_tab_view_get_n_pages(self.tab_view);
}
/// Returns the index of the currently selected page.
@ -98,7 +123,7 @@ pub const NotebookAdw = struct {
_ = c.adw_tab_view_reorder_page(self.tab_view, page, position);
}
pub fn setTabLabel(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void {
pub fn setTabTitle(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box));
c.adw_tab_page_set_title(page, title.ptr);
@ -155,16 +180,23 @@ pub const NotebookAdw = struct {
c.gtk_window_destroy(window);
}
}
pub fn showRenameTab(self: *NotebookAdw) void {
if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable;
const tab = self.last_setup_menu_tab orelse return;
assert(tab.window == self.window);
self.rename_tab.show(tab);
}
};
fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
const self: *NotebookAdw = @ptrCast(@alignCast(ud orelse return));
const child = c.adw_tab_page_get_child(page);
const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return));
tab.window = window;
tab.window = self.window;
window.focusCurrentTab();
self.window.focusCurrentTab();
}
fn adwClosePage(
@ -172,20 +204,20 @@ fn adwClosePage(
page: *c.AdwTabPage,
ud: ?*anyopaque,
) callconv(.C) c.gboolean {
const self: *NotebookAdw = @ptrCast(@alignCast(ud orelse return 0));
const child = c.adw_tab_page_get_child(page);
const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(
@ptrCast(child),
Tab.GHOSTTY_TAB,
) orelse return 0));
const window: *Window = @ptrCast(@alignCast(ud.?));
const notebook = window.notebook.adw;
c.adw_tab_view_close_page_finish(
notebook.tab_view,
self.tab_view,
page,
@intFromBool(notebook.forcing_close),
@intFromBool(self.forcing_close),
);
if (!notebook.forcing_close) tab.closeWithConfirmation();
if (!self.forcing_close) tab.closeWithConfirmation();
return 1;
}
@ -193,8 +225,8 @@ fn adwTabViewCreateWindow(
_: *AdwTabView,
ud: ?*anyopaque,
) callconv(.C) ?*AdwTabView {
const currentWindow: *Window = @ptrCast(@alignCast(ud.?));
const window = createWindow(currentWindow) catch |err| {
const self: *NotebookAdw = @ptrCast(@alignCast(ud orelse return null));
const window = createWindow(self.window) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
@ -202,8 +234,22 @@ fn adwTabViewCreateWindow(
}
fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void {
const window: *Window = @ptrCast(@alignCast(ud.?));
const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return;
const self: *NotebookAdw = @ptrCast(@alignCast(ud orelse return));
const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return;
const title = c.adw_tab_page_get_title(page);
window.setTitle(std.mem.span(title));
self.window.setTitle(std.mem.span(title));
}
fn adwTabViewSetupMenu(tab_view: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void {
const self: *NotebookAdw = @ptrCast(@alignCast(ud orelse return));
assert(self.tab_view == tab_view);
const child = c.adw_tab_page_get_child(page);
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return,
));
self.last_setup_menu_tab = tab;
}

View File

@ -59,6 +59,10 @@ pub const NotebookGtk = struct {
_ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(&gtkNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT);
}
pub fn deinit(self: NotebookGtk) void {
_ = self;
}
/// return the underlying widget as a generic GtkWidget
pub fn asWidget(self: *NotebookGtk) *c.GtkWidget {
return @ptrCast(@alignCast(self.notebook));
@ -101,7 +105,7 @@ pub const NotebookGtk = struct {
c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position);
}
pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void {
pub fn setTabTitle(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void {
c.gtk_label_set_text(tab.label_text, title.ptr);
}

View File

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<interface domain="com.mitchellh.ghostty">
<requires lib="gtk" version="4.0"/>
<menu id="menu">
<section>
<item>
<attribute name="label" translatable="yes">Rename</attribute>
<attribute name="action">win.rename-tab</attribute>
</item>
</section>
</menu>
</interface>

View File

@ -0,0 +1,33 @@
<?xml version='1.0' encoding='UTF-8'?>
<interface domain="com.mitchellh.ghostty">
<requires lib="gtk" version="4.0"/>
<requires lib="Adw" version="1.0"/>
<object class="AdwDialog" id="rename-tab">
<property name="content-width">350</property>
<property name="title">Set Tab Title</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="AdwPreferencesGroup">
<property name="margin-bottom">18</property>
<property name="margin-end">18</property>
<property name="margin-start">18</property>
<property name="margin-top">18</property>
<child>
<object class="AdwEntryRow" id="title">
<property name="title" translatable="yes">Title</property>
<property name="show-apply-button">true</property>
<property name="enable-emoji-completion">true</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View File

@ -475,7 +475,9 @@ pub fn add(
.root_source_file = b.path("src/apprt/gtk/builder_check.zig"),
.target = b.host,
});
builder_check.root_module.addOptions("build_options", self.options);
builder_check.linkSystemLibrary2("gtk4", dynamic_link_opts);
if (self.config.adwaita) builder_check.linkSystemLibrary2("libadwaita-1", dynamic_link_opts);
builder_check.linkLibC();
for (gresource.dependencies) |pathname| {