From caed393dfcba5c80580569da860f5fbe2f155208 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 29 Oct 2024 10:55:25 -0500 Subject: [PATCH 1/5] gtk: add context menu to libadwaita tab headers --- src/apprt/gtk/notebook.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index 73213e9da..baca32a30 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -9,6 +9,7 @@ const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk); const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque; /// An abstraction over the GTK notebook and Adwaita tab view to manage /// all the terminal tabs in a window. @@ -72,8 +73,11 @@ pub const Notebook = union(enum) { c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); } + c.adw_tab_view_set_menu_model(tab_view, @ptrCast(@alignCast(app.context_menu))); + _ = 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, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "setup-menu", c.G_CALLBACK(&adwTabViewSetupMenu), 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); return .{ .adw_tab_view = tab_view }; @@ -492,3 +496,15 @@ fn createWindow(currentWindow: *Window) !*Window { // Create a new window return Window.create(alloc, app); } + +fn adwTabViewSetupMenu(tab_view: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + + 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, + )); + window.app.refreshContextMenu(if (tab.focus_child) |focus_child| focus_child.core_surface.hasSelection() else false); + + c.adw_tab_view_set_menu_model(tab_view, @ptrCast(@alignCast(window.app.context_menu))); +} From c64bcabb6ef458648c9550e96944aeabb2e3f93f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 29 Oct 2024 12:05:31 -0500 Subject: [PATCH 2/5] gtk: clean up context menu creation and refresh The preferred method to enable/disable menu options is to enable/disable the associated actions. --- src/apprt/gtk/App.zig | 23 ++++++++++------------- src/apprt/gtk/Surface.zig | 2 +- src/apprt/gtk/notebook.zig | 2 +- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b00d65bce..424a97c3a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1531,7 +1531,13 @@ fn initContextMenu(self: *App) void { const menu = c.g_menu_new(); errdefer c.g_object_unref(menu); - createContextMenuCopyPasteSection(menu, false); + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Copy", "win.copy"); + c.g_menu_append(section, "Paste", "win.paste"); + } { const section = c.g_menu_new(); @@ -1552,18 +1558,9 @@ fn initContextMenu(self: *App) void { self.context_menu = menu; } -fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section))); - // FIXME: Feels really hackish, but disabling sensitivity on this doesn't seems to work(?) - c.g_menu_append(section, "Copy", if (has_selection) "win.copy" else "noop"); - c.g_menu_append(section, "Paste", "win.paste"); -} - -pub fn refreshContextMenu(self: *App, has_selection: bool) void { - c.g_menu_remove(self.context_menu, 0); - createContextMenuCopyPasteSection(self.context_menu, has_selection); +pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void { + const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy")); + c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0); } fn isValidAppId(app_id: [:0]const u8) bool { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 22124becd..c422f55ce 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1156,7 +1156,7 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void { }; c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect); - self.app.refreshContextMenu(self.core_surface.hasSelection()); + self.app.refreshContextMenu(window.window, self.core_surface.hasSelection()); c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu))); } diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index baca32a30..0a23a2c37 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -504,7 +504,7 @@ fn adwTabViewSetupMenu(tab_view: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque const tab: *Tab = @ptrCast(@alignCast( c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return, )); - window.app.refreshContextMenu(if (tab.focus_child) |focus_child| focus_child.core_surface.hasSelection() else false); + window.app.refreshContextMenu(window.window, if (tab.focus_child) |focus_child| focus_child.core_surface.hasSelection() else false); c.adw_tab_view_set_menu_model(tab_view, @ptrCast(@alignCast(window.app.context_menu))); } From 229342d508ea51ecad9774fad68568aec1c48a1d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 2 Nov 2024 10:02:36 -0500 Subject: [PATCH 3/5] adw tab headers should have their own menu --- src/apprt/gtk/Window.zig | 27 +++---- src/apprt/gtk/notebook.zig | 143 ++++++++++++++++++++++++++++--------- 2 files changed, 124 insertions(+), 46 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index e220ac03b..8a0c8e849 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -42,8 +42,7 @@ header: ?*c.GtkWidget, tab_overview: ?*c.GtkWidget, /// The notebook (tab grouping) for this window. -/// can be either c.GtkNotebook or c.AdwTabView. -notebook: Notebook, +notebook: *Notebook, context_menu: *c.GtkWidget, @@ -54,7 +53,7 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, -pub fn create(alloc: Allocator, app: *App) !*Window { +pub fn create(alloc: Allocator, app: *App) std.mem.Allocator.Error!*Window { // Allocate a fixed pointer for our window. We try to minimize // allocations but windows and other GUI requirements are so minimal // compared to the steady-state terminal operation so we use heap @@ -64,11 +63,11 @@ pub fn create(alloc: Allocator, app: *App) !*Window { // freed when the window is closed. var window = try alloc.create(Window); errdefer alloc.destroy(window); - try window.init(app); + try window.init(alloc, app); return window; } -pub fn init(self: *Window, app: *App) !void { +pub fn init(self: *Window, alloc: Allocator, app: *App) std.mem.Allocator.Error!void { // Set up our own state self.* = .{ .app = app, @@ -226,7 +225,7 @@ pub fn init(self: *Window, app: *App) !void { } // Setup our notebook - self.notebook = Notebook.create(self); + self.notebook = try Notebook.create(alloc, self); // Setup our toast overlay if we have one self.toast_overlay = if (adwaita.enabled(&self.app.config)) toast: { @@ -245,8 +244,8 @@ pub fn init(self: *Window, app: *App) !void { // If we have a tab overview then we can set it on our notebook. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.notebook == .adw_tab_view); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + assert(self.notebook.* == .adw_tab_view); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view.tab_view); } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); @@ -280,7 +279,7 @@ pub fn init(self: *Window, app: *App) !void { const header_widget: *c.GtkWidget = @ptrCast(@alignCast(self.header.?)); c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); const tab_bar = c.adw_tab_bar_new(); - c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view); + c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -323,7 +322,7 @@ pub fn init(self: *Window, app: *App) !void { ); } } else { - switch (self.notebook) { + switch (self.notebook.*) { .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. @@ -344,7 +343,7 @@ pub fn init(self: *Window, app: *App) !void { @ptrCast(@alignCast(tab_bar)), ), } - c.adw_tab_bar_set_view(tab_bar, tab_view); + c.adw_tab_bar_set_view(tab_bar, tab_view.tab_view); if (!app.config.@"gtk-wide-tabs") { c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -403,6 +402,8 @@ pub fn deinit(self: *Window) void { if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); } + + self.app.core_app.alloc.destroy(self.notebook); } /// Returns true if this window should use an Adwaita window. @@ -552,14 +553,14 @@ fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwT const alloc = self.app.core_app.alloc; const surface = self.actionSurface(); const tab = Tab.create(alloc, self, surface) catch return null; - return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box))); + return c.adw_tab_view_get_page(self.notebook.adw_tab_view.tab_view, @ptrCast(@alignCast(tab.box))); } fn adwTabOverviewOpen( object: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque, -) void { +) callconv(.C) void { const tab_overview: *c.AdwTabOverview = @ptrCast(@alignCast(object)); // We only care about when the tab overview is closed. diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index 0a23a2c37..dbac57fdb 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -14,16 +14,36 @@ const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopa /// An abstraction over the GTK notebook and Adwaita tab view to manage /// all the terminal tabs in a window. pub const Notebook = union(enum) { - adw_tab_view: *AdwTabView, + adw_tab_view: struct { + /// the tab view + tab_view: *AdwTabView, + + /// the last tab to have the context menu shown + last_tab: ?*Tab, + }, + gtk_notebook: *c.GtkNotebook, - pub fn create(window: *Window) Notebook { + pub fn create(alloc: std.mem.Allocator, window: *Window) std.mem.Allocator.Error!*Notebook { + // Allocate a fixed pointer for our notebook. We try to minimize + // allocations but windows and other GUI requirements are so minimal + // compared to the steady-state terminal operation so we use heap + // allocation for this. + // + // The allocation is owned by the GtkWindow created. It will be + // freed when the window is closed. + var notebook = try alloc.create(Notebook); + errdefer alloc.destroy(notebook); const app = window.app; - if (adwaita.enabled(&app.config)) return initAdw(window); - return initGtk(window); + if (adwaita.enabled(&app.config)) { + notebook.initAdw(window); + return notebook; + } + notebook.initGtk(window); + return notebook; } - fn initGtk(window: *Window) Notebook { + fn initGtk(self: *Notebook, window: *Window) void { const app = window.app; // Create a notebook to hold our tabs. @@ -58,10 +78,10 @@ pub const Notebook = union(enum) { _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); - return .{ .gtk_notebook = notebook }; + self.* = .{ .gtk_notebook = notebook }; } - fn initAdw(window: *Window) Notebook { + fn initAdw(self: *Notebook, window: *Window) void { const app = window.app; assert(adwaita.enabled(&app.config)); @@ -73,19 +93,24 @@ pub const Notebook = union(enum) { c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); } - c.adw_tab_view_set_menu_model(tab_view, @ptrCast(@alignCast(app.context_menu))); + self.* = .{ + .adw_tab_view = .{ + .tab_view = tab_view, + .last_tab = null, + }, + }; + + self.initContextMenu(window); _ = 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, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(tab_view, "setup-menu", c.G_CALLBACK(&adwTabViewSetupMenu), 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); - - return .{ .adw_tab_view = tab_view }; + _ = c.g_signal_connect_data(tab_view, "setup-menu", c.G_CALLBACK(&adwTabViewSetupMenu), self, null, c.G_CONNECT_DEFAULT); } pub fn asWidget(self: Notebook) *c.GtkWidget { return switch (self) { - .adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view)), + .adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view.tab_view)), .gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)), }; } @@ -94,7 +119,7 @@ pub const Notebook = union(enum) { return switch (self) { .gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) - c.adw_tab_view_get_n_pages(tab_view) + c.adw_tab_view_get_n_pages(tab_view.tab_view) else unreachable, }; @@ -106,8 +131,8 @@ pub const Notebook = union(enum) { switch (self) { .adw_tab_view => |tab_view| { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - return c.adw_tab_view_get_page_position(tab_view, page); + const page = c.adw_tab_view_get_selected_page(tab_view.tab_view) orelse return null; + return c.adw_tab_view_get_page_position(tab_view.tab_view, page); }, .gtk_notebook => |notebook| { @@ -122,7 +147,7 @@ pub const Notebook = union(enum) { const child = switch (self) { .adw_tab_view => |tab_view| child: { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; + const page = c.adw_tab_view_get_selected_page(tab_view.tab_view) orelse return null; const child = c.adw_tab_page_get_child(page); break :child child; }, @@ -141,8 +166,8 @@ pub const Notebook = union(enum) { switch (self) { .adw_tab_view => |tab_view| { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page_to_select = c.adw_tab_view_get_nth_page(tab_view, position); - c.adw_tab_view_set_selected_page(tab_view, page_to_select); + const page_to_select = c.adw_tab_view_get_nth_page(tab_view.tab_view, position); + c.adw_tab_view_set_selected_page(tab_view.tab_view, page_to_select); }, .gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position), } @@ -152,8 +177,8 @@ pub const Notebook = union(enum) { return switch (self) { .adw_tab_view => |tab_view| page_idx: { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return null; - break :page_idx c.adw_tab_view_get_page_position(tab_view, page); + const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)) orelse return null; + break :page_idx c.adw_tab_view_get_page_position(tab_view.tab_view, page); }, .gtk_notebook => |notebook| page_idx: { const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null; @@ -210,8 +235,8 @@ pub const Notebook = union(enum) { }, .adw_tab_view => |tab_view| { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - _ = c.adw_tab_view_reorder_page(tab_view, page, position); + const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)); + _ = c.adw_tab_view_reorder_page(tab_view.tab_view, page, position); }, } } @@ -220,7 +245,7 @@ pub const Notebook = union(enum) { switch (self) { .adw_tab_view => |tab_view| { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); + const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)); c.adw_tab_page_set_title(page, title.ptr); }, .gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr), @@ -253,11 +278,11 @@ pub const Notebook = union(enum) { .adw_tab_view => |tab_view| { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab)); + const page = c.adw_tab_view_insert(tab_view.tab_view, box_widget, self.newTabInsertPosition(tab)); c.adw_tab_page_set_title(page, title.ptr); // Switch to the new tab - c.adw_tab_view_set_selected_page(tab_view, page); + c.adw_tab_view_set_selected_page(tab_view.tab_view, page); }, .gtk_notebook => |notebook| { // Build the tab label @@ -327,8 +352,8 @@ pub const Notebook = union(enum) { .adw_tab_view => |tab_view| { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return; - c.adw_tab_view_close_page(tab_view, page); + const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)) orelse return; + c.adw_tab_view_close_page(tab_view.tab_view, page); // If we have no more tabs we close the window if (self.nPages() == 0) { @@ -385,6 +410,53 @@ pub const Notebook = union(enum) { return c.g_value_get_int(&value); } + + pub fn initContextMenu(self: *Notebook, window: *Window) void { + switch (self.*) { + .adw_tab_view => |tab_view| { + { + var buf: [32]u8 = undefined; + const action_name = std.fmt.bufPrintZ( + &buf, + "close-tab-{x:8>0}", + .{@intFromPtr(self)}, + ) catch unreachable; + + const action = c.g_simple_action_new(action_name, null); + defer c.g_object_unref(action); + _ = c.g_signal_connect_data( + action, + "activate", + c.G_CALLBACK(&adwTabViewCloseTab), + self, + null, + c.G_CONNECT_DEFAULT, + ); + c.g_action_map_add_action(@ptrCast(window.window), @ptrCast(action)); + } + + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + + { + var buf: [32]u8 = undefined; + const action_name = std.fmt.bufPrintZ( + &buf, + "win.close-tab-{x:8>0}", + .{@intFromPtr(self)}, + ) catch unreachable; + + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Close Tab", action_name); + } + + c.adw_tab_view_set_menu_model(tab_view.tab_view, @ptrCast(@alignCast(menu))); + }, + .gtk_notebook => {}, + } + } }; fn gtkPageRemoved( @@ -442,7 +514,7 @@ fn gtkPageAdded( 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 page = c.adw_tab_view_get_selected_page(window.notebook.adw_tab_view.tab_view) orelse return; const title = c.adw_tab_page_get_title(page); c.gtk_window_set_title(window.window, title); } @@ -464,7 +536,7 @@ fn adwTabViewCreateWindow( log.warn("error creating new window error={}", .{err}); return null; }; - return window.notebook.adw_tab_view; + return window.notebook.adw_tab_view.tab_view; } fn gtkNotebookCreateWindow( @@ -497,14 +569,19 @@ fn createWindow(currentWindow: *Window) !*Window { return Window.create(alloc, app); } -fn adwTabViewSetupMenu(tab_view: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); +fn adwTabViewSetupMenu(_: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void { + const self: *Notebook = @ptrCast(@alignCast(ud.?)); + self.adw_tab_view.last_tab = null; 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, )); - window.app.refreshContextMenu(window.window, if (tab.focus_child) |focus_child| focus_child.core_surface.hasSelection() else false); - c.adw_tab_view_set_menu_model(tab_view, @ptrCast(@alignCast(window.app.context_menu))); + self.adw_tab_view.last_tab = tab; +} + +fn adwTabViewCloseTab(_: *c.GSimpleAction, _: *c.GVariant, ud: ?*anyopaque) callconv(.C) void { + const self: *Notebook = @ptrCast(@alignCast(ud.?)); + self.closeTab(self.adw_tab_view.last_tab orelse return); } From 0d0775d03468f1e5e1365f9e4830700ec5b7578a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 2 Nov 2024 13:10:22 -0500 Subject: [PATCH 4/5] gtk: refactor gtk & adw notebook implementations Put GTK and libadwaita notebook implementations into separate structs/ files for clarity. --- src/apprt/gtk/Tab.zig | 2 +- src/apprt/gtk/Window.zig | 14 +- src/apprt/gtk/notebook.zig | 529 ++++----------------------------- src/apprt/gtk/notebook_adw.zig | 228 ++++++++++++++ src/apprt/gtk/notebook_gtk.zig | 282 ++++++++++++++++++ 5 files changed, 577 insertions(+), 478 deletions(-) create mode 100644 src/apprt/gtk/notebook_adw.zig create mode 100644 src/apprt/gtk/notebook_gtk.zig diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 82384a44a..ed0804fd3 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -76,7 +76,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { // Set the userdata of the box to point to this tab. c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self); - try window.notebook.addTab(self, "Ghostty"); + window.notebook.addTab(self, "Ghostty"); // Attach all events _ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 8a0c8e849..c98c53969 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -244,8 +244,8 @@ pub fn init(self: *Window, alloc: Allocator, app: *App) std.mem.Allocator.Error! // If we have a tab overview then we can set it on our notebook. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.notebook.* == .adw_tab_view); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view.tab_view); + assert(self.notebook.* == .adw); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); @@ -279,7 +279,7 @@ pub fn init(self: *Window, alloc: Allocator, app: *App) std.mem.Allocator.Error! const header_widget: *c.GtkWidget = @ptrCast(@alignCast(self.header.?)); c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); const tab_bar = c.adw_tab_bar_new(); - c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view.tab_view); + c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -323,7 +323,7 @@ pub fn init(self: *Window, alloc: Allocator, app: *App) std.mem.Allocator.Error! } } else { switch (self.notebook.*) { - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + .adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) { // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?; @@ -343,14 +343,14 @@ pub fn init(self: *Window, alloc: Allocator, app: *App) std.mem.Allocator.Error! @ptrCast(@alignCast(tab_bar)), ), } - c.adw_tab_bar_set_view(tab_bar, tab_view.tab_view); + c.adw_tab_bar_set_view(tab_bar, adw.tab_view); if (!app.config.@"gtk-wide-tabs") { c.adw_tab_bar_set_expand_tabs(tab_bar, 0); } }, - .gtk_notebook => {}, + .gtk => {}, } // The box is our main child @@ -553,7 +553,7 @@ fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwT const alloc = self.app.core_app.alloc; const surface = self.actionSurface(); const tab = Tab.create(alloc, self, surface) catch return null; - return c.adw_tab_view_get_page(self.notebook.adw_tab_view.tab_view, @ptrCast(@alignCast(tab.box))); + return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box))); } fn adwTabOverviewOpen( diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index dbac57fdb..e9bdaacc7 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -4,25 +4,17 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); +const NotebookAdw = @import("notebook_adw.zig").NotebookAdw; +const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk; const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk); -const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; -const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque; - /// An abstraction over the GTK notebook and Adwaita tab view to manage /// all the terminal tabs in a window. pub const Notebook = union(enum) { - adw_tab_view: struct { - /// the tab view - tab_view: *AdwTabView, - - /// the last tab to have the context menu shown - last_tab: ?*Tab, - }, - - gtk_notebook: *c.GtkNotebook, + adw: NotebookAdw, + gtk: NotebookGtk, pub fn create(alloc: std.mem.Allocator, window: *Window) std.mem.Allocator.Error!*Notebook { // Allocate a fixed pointer for our notebook. We try to minimize @@ -32,162 +24,63 @@ pub const Notebook = union(enum) { // // The allocation is owned by the GtkWindow created. It will be // freed when the window is closed. - var notebook = try alloc.create(Notebook); + const notebook = try alloc.create(Notebook); errdefer alloc.destroy(notebook); const app = window.app; if (adwaita.enabled(&app.config)) { - notebook.initAdw(window); + NotebookAdw.init(notebook, window); return notebook; } - notebook.initGtk(window); + NotebookGtk.init(notebook, window); return notebook; } - fn initGtk(self: *Notebook, window: *Window) void { - const app = window.app; - - // Create a notebook to hold our tabs. - const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); - const notebook: *c.GtkNotebook = @ptrCast(notebook_widget); - const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { - .top => c.GTK_POS_TOP, - .bottom => c.GTK_POS_BOTTOM, - .left => c.GTK_POS_LEFT, - .right => c.GTK_POS_RIGHT, - }; - c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos); - c.gtk_notebook_set_scrollable(notebook, 1); - c.gtk_notebook_set_show_tabs(notebook, 0); - c.gtk_notebook_set_show_border(notebook, 0); - - // This enables all Ghostty terminal tabs to be exchanged across windows. - c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs"); - - // This is important so the notebook expands to fit available space. - // Otherwise, it will be zero/zero in the box below. - c.gtk_widget_set_vexpand(notebook_widget, 1); - c.gtk_widget_set_hexpand(notebook_widget, 1); - - // Remove the background from the stack widget - const stack = c.gtk_widget_get_last_child(notebook_widget); - c.gtk_widget_add_css_class(stack, "transparent"); - - // All of our events - _ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); - - self.* = .{ .gtk_notebook = notebook }; - } - - fn initAdw(self: *Notebook, window: *Window) void { - const app = window.app; - assert(adwaita.enabled(&app.config)); - - const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; - - if (comptime adwaita.versionAtLeast(1, 2, 0) and 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); - } - - self.* = .{ - .adw_tab_view = .{ - .tab_view = tab_view, - .last_tab = null, - }, - }; - - self.initContextMenu(window); - - _ = 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, "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); - _ = c.g_signal_connect_data(tab_view, "setup-menu", c.G_CALLBACK(&adwTabViewSetupMenu), self, null, c.G_CONNECT_DEFAULT); - } - - pub fn asWidget(self: Notebook) *c.GtkWidget { - return switch (self) { - .adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view.tab_view)), - .gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)), + pub fn asWidget(self: *Notebook) *c.GtkWidget { + return switch (self.*) { + .adw => |*adw| adw.asWidget(), + .gtk => |*gtk| gtk.asWidget(), }; } - pub fn nPages(self: Notebook) c_int { - return switch (self) { - .gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) - c.adw_tab_view_get_n_pages(tab_view.tab_view) - else - unreachable, + pub fn nPages(self: *Notebook) c_int { + return switch (self.*) { + .adw => |*adw| adw.nPages(), + .gtk => |*gtk| gtk.nPages(), }; } /// Returns the index of the currently selected page. /// Returns null if the notebook has no pages. - fn currentPage(self: Notebook) ?c_int { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view.tab_view) orelse return null; - return c.adw_tab_view_get_page_position(tab_view.tab_view, page); - }, - - .gtk_notebook => |notebook| { - const current = c.gtk_notebook_get_current_page(notebook); - return if (current == -1) null else current; - }, - } + fn currentPage(self: *Notebook) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.currentPage(), + .gtk => |*gtk| gtk.currentPage(), + }; } /// Returns the currently selected tab or null if there are none. - pub fn currentTab(self: Notebook) ?*Tab { - const child = switch (self) { - .adw_tab_view => |tab_view| child: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view.tab_view) orelse return null; - const child = c.adw_tab_page_get_child(page); - break :child child; - }, - - .gtk_notebook => |notebook| child: { - const page = self.currentPage() orelse return null; - break :child c.gtk_notebook_get_nth_page(notebook, page); - }, + pub fn currentTab(self: *Notebook) ?*Tab { + return switch (self.*) { + .adw => |*adw| adw.currentTab(), + .gtk => |*gtk| gtk.currentTab(), }; - return @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, - )); } - pub fn gotoNthTab(self: Notebook, position: c_int) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page_to_select = c.adw_tab_view_get_nth_page(tab_view.tab_view, position); - c.adw_tab_view_set_selected_page(tab_view.tab_view, page_to_select); - }, - .gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position), + pub fn gotoNthTab(self: *Notebook, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.gotoNthTab(position), + .gtk => |*gtk| gtk.gotoNthTab(position), } } - pub fn getTabPosition(self: Notebook, tab: *Tab) ?c_int { - return switch (self) { - .adw_tab_view => |tab_view| page_idx: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)) orelse return null; - break :page_idx c.adw_tab_view_get_page_position(tab_view.tab_view, page); - }, - .gtk_notebook => |notebook| page_idx: { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null; - break :page_idx getNotebookPageIndex(page); - }, + pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.getTabPosition(tab), + .gtk => |*gtk| gtk.getTabPosition(tab), }; } - pub fn gotoPreviousTab(self: Notebook, tab: *Tab) void { + pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; // The next index is the previous or we wrap around. @@ -202,7 +95,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn gotoNextTab(self: Notebook, tab: *Tab) void { + pub fn gotoNextTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -212,7 +105,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void { + pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -228,42 +121,28 @@ pub const Notebook = union(enum) { self.reorderPage(tab, new_position); } - pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void { - switch (self) { - .gtk_notebook => |notebook| { - c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position); - }, - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)); - _ = c.adw_tab_view_reorder_page(tab_view.tab_view, page, position); - }, + pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.reorderPage(tab, position), + .gtk => |*gtk| gtk.reorderPage(tab, position), } } - pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_title(page, title.ptr); - }, - .gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr), + pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabLabel(tab, title), + .gtk => |*gtk| gtk.setTabLabel(tab, title), } } - pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_tooltip(page, tooltip.ptr); - }, - .gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr), + pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabTooltip(tab, tooltip), + .gtk => |*gtk| gtk.setTabTooltip(tab, tooltip), } } - fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { + fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int { const numPages = self.nPages(); return switch (tab.window.app.config.@"window-new-tab-position") { .current => if (self.currentPage()) |page| page + 1 else numPages, @@ -272,316 +151,26 @@ pub const Notebook = union(enum) { } /// Adds a new tab with the given title to the notebook. - pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void { - const box_widget: *c.GtkWidget = @ptrCast(tab.box); - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_insert(tab_view.tab_view, box_widget, self.newTabInsertPosition(tab)); - c.adw_tab_page_set_title(page, title.ptr); - - // Switch to the new tab - c.adw_tab_view_set_selected_page(tab_view.tab_view, page); - }, - .gtk_notebook => |notebook| { - // Build the tab label - const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); - const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); - const label_text_widget = c.gtk_label_new(title.ptr); - const label_text: *c.GtkLabel = @ptrCast(label_text_widget); - c.gtk_box_append(label_box, label_text_widget); - tab.label_text = label_text; - - const window = tab.window; - if (window.app.config.@"gtk-wide-tabs") { - c.gtk_widget_set_hexpand(label_box_widget, 1); - c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); - c.gtk_widget_set_hexpand(label_text_widget, 1); - c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); - - // This ensures that tabs are always equal width. If they're too - // long, they'll be truncated with an ellipsis. - c.gtk_label_set_max_width_chars(label_text, 1); - c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); - - // We need to set a minimum width so that at a certain point - // the notebook will have an arrow button rather than shrinking tabs - // to an unreadably small size. - c.gtk_widget_set_size_request(label_text_widget, 100, 1); - } - - // Build the close button for the tab - const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); - const label_close: *c.GtkButton = @ptrCast(label_close_widget); - c.gtk_button_set_has_frame(label_close, 0); - c.gtk_box_append(label_box, label_close_widget); - - const page_idx = c.gtk_notebook_insert_page( - notebook, - box_widget, - label_box_widget, - self.newTabInsertPosition(tab), - ); - - // Clicks - const gesture_tab_click = c.gtk_gesture_click_new(); - c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); - c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); - - _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); - - // Tab settings - c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1); - c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1); - - if (self.nPages() > 1) { - c.gtk_notebook_set_show_tabs(notebook, 1); - } - - // Switch to the new tab - c.gtk_notebook_set_current_page(notebook, page_idx); - }, - } - } - - pub fn closeTab(self: Notebook, tab: *Tab) void { - const window = tab.window; - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_get_page(tab_view.tab_view, @ptrCast(tab.box)) orelse return; - c.adw_tab_view_close_page(tab_view.tab_view, page); - - // If we have no more tabs we close the window - if (self.nPages() == 0) { - // libadw versions <= 1.3.x leak the final page view - // which causes our surface to not properly cleanup. We - // unref to force the cleanup. This will trigger a critical - // warning from GTK, but I don't know any other workaround. - // Note: I'm not actually sure if 1.4.0 contains the fix, - // I just know that 1.3.x is broken and 1.5.1 is fixed. - // If we know that 1.4.0 is fixed, we can change this. - if (!adwaita.versionAtLeast(1, 4, 0)) { - c.g_object_unref(tab.box); - } - - c.gtk_window_destroy(tab.window.window); - } - }, - .gtk_notebook => |notebook| { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return; - - // Find page and tab which we're closing - const page_idx = getNotebookPageIndex(page); - - // Remove the page. This will destroy the GTK widgets in the page which - // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. - c.gtk_notebook_remove_page(notebook, page_idx); - - const remaining = self.nPages(); - switch (remaining) { - // If we have no more tabs we close the window - 0 => c.gtk_window_destroy(tab.window.window), - - // If we have one more tab we hide the tab bar - 1 => c.gtk_notebook_set_show_tabs(notebook, 0), - - else => {}, - } - - // If we have remaining tabs, we need to make sure we grab focus. - if (remaining > 0) window.focusCurrentTab(); - }, - } - } - - fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { - var value: c.GValue = std.mem.zeroes(c.GValue); - defer c.g_value_unset(&value); - _ = c.g_value_init(&value, c.G_TYPE_INT); - c.g_object_get_property( - @ptrCast(@alignCast(page)), - "position", - &value, - ); - - return c.g_value_get_int(&value); - } - - pub fn initContextMenu(self: *Notebook, window: *Window) void { + pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + const position = self.newTabInsertPosition(tab); switch (self.*) { - .adw_tab_view => |tab_view| { - { - var buf: [32]u8 = undefined; - const action_name = std.fmt.bufPrintZ( - &buf, - "close-tab-{x:8>0}", - .{@intFromPtr(self)}, - ) catch unreachable; + .adw => |*adw| adw.addTab(tab, position, title), + .gtk => |*gtk| gtk.addTab(tab, position, title), + } + } - const action = c.g_simple_action_new(action_name, null); - defer c.g_object_unref(action); - _ = c.g_signal_connect_data( - action, - "activate", - c.G_CALLBACK(&adwTabViewCloseTab), - self, - null, - c.G_CONNECT_DEFAULT, - ); - c.g_action_map_add_action(@ptrCast(window.window), @ptrCast(action)); - } - - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - - { - var buf: [32]u8 = undefined; - const action_name = std.fmt.bufPrintZ( - &buf, - "win.close-tab-{x:8>0}", - .{@intFromPtr(self)}, - ) catch unreachable; - - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Close Tab", action_name); - } - - c.adw_tab_view_set_menu_model(tab_view.tab_view, @ptrCast(@alignCast(menu))); - }, - .gtk_notebook => {}, + pub fn closeTab(self: *Notebook, tab: *Tab) void { + switch (self.*) { + .adw => |*adw| adw.closeTab(tab), + .gtk => |*gtk| gtk.closeTab(tab), } } }; -fn gtkPageRemoved( - _: *c.GtkNotebook, - _: *c.GtkWidget, - _: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - const notebook: *c.GtkNotebook = self.notebook.gtk_notebook; - - // Hide the tab bar if we only have one tab after removal - const remaining = c.gtk_notebook_get_n_pages(notebook); - if (remaining == 1) { - c.gtk_notebook_set_show_tabs(notebook, 0); - } -} - -fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void { - _ = position; - _ = tab_view; - const self: *Window = @ptrCast(@alignCast(ud.?)); - - 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 = self; - - self.focusCurrentTab(); -} - -fn gtkPageAdded( - notebook: *c.GtkNotebook, - _: *c.GtkWidget, - page_idx: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - // The added page can come from another window with drag and drop, thus we migrate the tab - // window to be self. - const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, - )); - tab.window = self; - - // Whenever a new page is added, we always grab focus of the - // currently selected page. This was added specifically so that when - // we drag a tab out to create a new window ("create-window" event) - // we grab focus in the new window. Without this, the terminal didn't - // have focus. - self.focusCurrentTab(); -} - -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.tab_view) orelse return; - const title = c.adw_tab_page_get_title(page); - c.gtk_window_set_title(window.window, title); -} - -fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); - const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_notebook, page))); - const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); - const label_text = c.gtk_label_get_text(gtk_label); - c.gtk_window_set_title(window.window, label_text); -} - -fn adwTabViewCreateWindow( - _: *AdwTabView, - ud: ?*anyopaque, -) callconv(.C) ?*AdwTabView { - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - return window.notebook.adw_tab_view.tab_view; -} - -fn gtkNotebookCreateWindow( - _: *c.GtkNotebook, - page: *c.GtkWidget, - ud: ?*anyopaque, -) callconv(.C) ?*c.GtkNotebook { - // The tab for the page is stored in the widget data. - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, - )); - - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - - // And add it to the new window. - tab.window = window; - - return window.notebook.gtk_notebook; -} - -fn createWindow(currentWindow: *Window) !*Window { +pub fn createWindow(currentWindow: *Window) !*Window { const alloc = currentWindow.app.core_app.alloc; const app = currentWindow.app; // Create a new window return Window.create(alloc, app); } - -fn adwTabViewSetupMenu(_: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void { - const self: *Notebook = @ptrCast(@alignCast(ud.?)); - self.adw_tab_view.last_tab = null; - - 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.adw_tab_view.last_tab = tab; -} - -fn adwTabViewCloseTab(_: *c.GSimpleAction, _: *c.GVariant, ud: ?*anyopaque) callconv(.C) void { - const self: *Notebook = @ptrCast(@alignCast(ud.?)); - self.closeTab(self.adw_tab_view.last_tab orelse return); -} diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig new file mode 100644 index 000000000..773d8f490 --- /dev/null +++ b/src/apprt/gtk/notebook_adw.zig @@ -0,0 +1,228 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; +const adwaita = @import("adwaita.zig"); + +const log = std.log.scoped(.gtk); + +const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque; + +pub const NotebookAdw = struct { + /// the tab view + tab_view: *AdwTabView, + + /// the last tab to have the context menu shown + last_tab: ?*Tab, + + pub fn init(notebook: *Notebook, window: *Window) void { + const app = window.app; + assert(adwaita.enabled(&app.config)); + + const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; + + if (comptime adwaita.versionAtLeast(1, 2, 0) and 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); + } + + notebook.* = .{ + .adw = .{ + .tab_view = tab_view, + .last_tab = null, + }, + }; + + const self = ¬ebook.adw; + self.initContextMenu(window); + + _ = 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, "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); + _ = c.g_signal_connect_data(tab_view, "setup-menu", c.G_CALLBACK(&adwTabViewSetupMenu), self, null, c.G_CONNECT_DEFAULT); + } + + pub fn initContextMenu(self: *NotebookAdw, window: *Window) void { + { + var buf: [32]u8 = undefined; + const action_name = std.fmt.bufPrintZ( + &buf, + "close-tab-{x:8>0}", + .{@intFromPtr(self)}, + ) catch unreachable; + + const action = c.g_simple_action_new(action_name, null); + defer c.g_object_unref(action); + _ = c.g_signal_connect_data( + action, + "activate", + c.G_CALLBACK(&adwTabViewCloseTab), + self, + null, + c.G_CONNECT_DEFAULT, + ); + c.g_action_map_add_action(@ptrCast(window.window), @ptrCast(action)); + } + + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + + { + var buf: [32]u8 = undefined; + const action_name = std.fmt.bufPrintZ( + &buf, + "win.close-tab-{x:8>0}", + .{@intFromPtr(self)}, + ) catch unreachable; + + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Close Tab", action_name); + } + + c.adw_tab_view_set_menu_model(self.tab_view, @ptrCast(@alignCast(menu))); + } + + pub fn asWidget(self: *NotebookAdw) *c.GtkWidget { + 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; + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookAdw) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookAdw) ?*Tab { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + const child = c.adw_tab_page_get_child(page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + pub fn gotoNthTab(self: *NotebookAdw, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position); + c.adw_tab_view_set_selected_page(self.tab_view, page_to_select); + } + + pub fn getTabPosition(self: *NotebookAdw, tab: *Tab) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + pub fn reorderPage(self: *NotebookAdw, tab: *Tab, position: c_int) 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_view_reorder_page(self.tab_view, page, position); + } + + pub fn setTabLabel(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); + } + + pub fn setTabTooltip(self: *NotebookAdw, tab: *Tab, tooltip: [: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_tooltip(page, tooltip.ptr); + } + + pub fn addTab(self: *NotebookAdw, tab: *Tab, position: c_int, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + const page = c.adw_tab_view_insert(self.tab_view, box_widget, position); + c.adw_tab_page_set_title(page, title.ptr); + c.adw_tab_view_set_selected_page(self.tab_view, page); + } + + pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; + c.adw_tab_view_close_page(self.tab_view, page); + + // If we have no more tabs we close the window + if (self.nPages() == 0) { + // libadw versions <= 1.3.x leak the final page view + // which causes our surface to not properly cleanup. We + // unref to force the cleanup. This will trigger a critical + // warning from GTK, but I don't know any other workaround. + // Note: I'm not actually sure if 1.4.0 contains the fix, + // I just know that 1.3.x is broken and 1.5.1 is fixed. + // If we know that 1.4.0 is fixed, we can change this. + if (!adwaita.versionAtLeast(1, 4, 0)) { + c.g_object_unref(tab.box); + } + + c.gtk_window_destroy(tab.window.window); + } + } +}; + +fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + + 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; + + window.focusCurrentTab(); +} + +fn adwTabViewCreateWindow( + _: *AdwTabView, + ud: ?*anyopaque, +) callconv(.C) ?*AdwTabView { + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const window = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + return window.notebook.adw.tab_view; +} + +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 title = c.adw_tab_page_get_title(page); + c.gtk_window_set_title(window.window, title); +} + +fn adwTabViewSetupMenu(_: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void { + const self: *NotebookAdw = @ptrCast(@alignCast(ud.?)); + self.last_tab = null; + + 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_tab = tab; +} + +fn adwTabViewCloseTab(_: *c.GSimpleAction, _: *c.GVariant, ud: ?*anyopaque) callconv(.C) void { + const self: *NotebookAdw = @ptrCast(@alignCast(ud.?)); + self.closeTab(self.last_tab orelse return); +} diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig new file mode 100644 index 000000000..a76982ba9 --- /dev/null +++ b/src/apprt/gtk/notebook_gtk.zig @@ -0,0 +1,282 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; + +const log = std.log.scoped(.gtk); + +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. +pub const NotebookGtk = struct { + notebook: *c.GtkNotebook, + + pub fn init(notebook: *Notebook, window: *Window) void { + const app = window.app; + + // Create a notebook to hold our tabs. + const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); + const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget); + const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { + .top => c.GTK_POS_TOP, + .bottom => c.GTK_POS_BOTTOM, + .left => c.GTK_POS_LEFT, + .right => c.GTK_POS_RIGHT, + }; + c.gtk_notebook_set_tab_pos(gtk_notebook, notebook_tab_pos); + c.gtk_notebook_set_scrollable(gtk_notebook, 1); + c.gtk_notebook_set_show_tabs(gtk_notebook, 0); + c.gtk_notebook_set_show_border(gtk_notebook, 0); + + // This enables all Ghostty terminal tabs to be exchanged across windows. + c.gtk_notebook_set_group_name(gtk_notebook, "ghostty-terminal-tabs"); + + // This is important so the notebook expands to fit available space. + // Otherwise, it will be zero/zero in the box below. + c.gtk_widget_set_vexpand(notebook_widget, 1); + c.gtk_widget_set_hexpand(notebook_widget, 1); + + // Remove the background from the stack widget + const stack = c.gtk_widget_get_last_child(notebook_widget); + c.gtk_widget_add_css_class(stack, "transparent"); + + notebook.* = .{ + .gtk = .{ + .notebook = gtk_notebook, + }, + }; + + // All of our events + _ = c.g_signal_connect_data(gtk_notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); + } + + /// return the underlying widget as a generic GtkWidget + pub fn asWidget(self: *NotebookGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.notebook)); + } + + /// returns the number of pages in the notebook + pub fn nPages(self: *NotebookGtk) c_int { + return c.gtk_notebook_get_n_pages(self.notebook); + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookGtk) ?c_int { + const current = c.gtk_notebook_get_current_page(self.notebook); + return if (current == -1) null else current; + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookGtk) ?*Tab { + log.warn("currentTab", .{}); + const page = self.currentPage() orelse return null; + const child = c.gtk_notebook_get_nth_page(self.notebook, page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + /// focus the nth tab + pub fn gotoNthTab(self: *NotebookGtk, position: c_int) void { + c.gtk_notebook_set_current_page(self.notebook, position); + } + + /// get the position of the current tab + pub fn getTabPosition(self: *NotebookGtk, tab: *Tab) ?c_int { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return null; + return getNotebookPageIndex(page); + } + + pub fn reorderPage(self: *NotebookGtk, tab: *Tab, position: c_int) void { + c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position); + } + + pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void { + c.gtk_label_set_text(tab.label_text, title.ptr); + } + + pub fn setTabTooltip(_: *NotebookGtk, tab: *Tab, tooltip: [:0]const u8) void { + c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr); + } + + /// Adds a new tab with the given title to the notebook. + pub fn addTab(self: *NotebookGtk, tab: *Tab, position: c_int, title: [:0]const u8) void { + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + + // Build the tab label + const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); + const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); + const label_text_widget = c.gtk_label_new(title.ptr); + const label_text: *c.GtkLabel = @ptrCast(label_text_widget); + c.gtk_box_append(label_box, label_text_widget); + tab.label_text = label_text; + + const window = tab.window; + if (window.app.config.@"gtk-wide-tabs") { + c.gtk_widget_set_hexpand(label_box_widget, 1); + c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); + c.gtk_widget_set_hexpand(label_text_widget, 1); + c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); + + // This ensures that tabs are always equal width. If they're too + // long, they'll be truncated with an ellipsis. + c.gtk_label_set_max_width_chars(label_text, 1); + c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); + + // We need to set a minimum width so that at a certain point + // the notebook will have an arrow button rather than shrinking tabs + // to an unreadably small size. + c.gtk_widget_set_size_request(label_text_widget, 100, 1); + } + + // Build the close button for the tab + const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); + const label_close: *c.GtkButton = @ptrCast(label_close_widget); + c.gtk_button_set_has_frame(label_close, 0); + c.gtk_box_append(label_box, label_close_widget); + + const page_idx = c.gtk_notebook_insert_page( + self.notebook, + box_widget, + label_box_widget, + position, + ); + + // Clicks + const gesture_tab_click = c.gtk_gesture_click_new(); + c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); + c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); + + _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); + + // Tab settings + c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); + c.gtk_notebook_set_tab_detachable(self.notebook, box_widget, 1); + + if (self.nPages() > 1) { + c.gtk_notebook_set_show_tabs(self.notebook, 1); + } + + // Switch to the new tab + c.gtk_notebook_set_current_page(self.notebook, page_idx); + } + + pub fn closeTab(self: *NotebookGtk, tab: *Tab) void { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return; + + // Find page and tab which we're closing + const page_idx = getNotebookPageIndex(page); + + // Remove the page. This will destroy the GTK widgets in the page which + // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. + c.gtk_notebook_remove_page(self.notebook, page_idx); + + const remaining = self.nPages(); + switch (remaining) { + // If we have no more tabs we close the window + 0 => c.gtk_window_destroy(tab.window.window), + + // If we have one more tab we hide the tab bar + 1 => c.gtk_notebook_set_show_tabs(self.notebook, 0), + + else => {}, + } + + // If we have remaining tabs, we need to make sure we grab focus. + if (remaining > 0) + (self.currentTab() orelse return).window.focusCurrentTab(); + } +}; + +fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { + var value: c.GValue = std.mem.zeroes(c.GValue); + defer c.g_value_unset(&value); + _ = c.g_value_init(&value, c.G_TYPE_INT); + c.g_object_get_property( + @ptrCast(@alignCast(page)), + "position", + &value, + ); + + return c.g_value_get_int(&value); +} + +fn gtkPageAdded( + notebook: *c.GtkNotebook, + _: *c.GtkWidget, + page_idx: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud.?)); + + // The added page can come from another window with drag and drop, thus we migrate the tab + // window to be self. + const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, + )); + tab.window = self; + + // Whenever a new page is added, we always grab focus of the + // currently selected page. This was added specifically so that when + // we drag a tab out to create a new window ("create-window" event) + // we grab focus in the new window. Without this, the terminal didn't + // have focus. + self.focusCurrentTab(); +} + +fn gtkPageRemoved( + _: *c.GtkNotebook, + _: *c.GtkWidget, + _: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + log.warn("gtkPageRemoved", .{}); + const window: *Window = @ptrCast(@alignCast(ud.?)); + + // Hide the tab bar if we only have one tab after removal + const remaining = c.gtk_notebook_get_n_pages(window.notebook.gtk.notebook); + + if (remaining == 1) { + c.gtk_notebook_set_show_tabs(window.notebook.gtk.notebook, 0); + } +} + +fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + const self = &window.notebook.gtk; + const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); + const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); + const label_text = c.gtk_label_get_text(gtk_label); + c.gtk_window_set_title(window.window, label_text); +} + +fn gtkNotebookCreateWindow( + _: *c.GtkNotebook, + page: *c.GtkWidget, + ud: ?*anyopaque, +) callconv(.C) ?*c.GtkNotebook { + // The tab for the page is stored in the widget data. + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, + )); + + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const newWindow = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + + // And add it to the new window. + tab.window = newWindow; + + return newWindow.notebook.gtk.notebook; +} From 9350389ecb96be4809bed57507e96c312a7a29b2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 10 Nov 2024 12:20:26 -0600 Subject: [PATCH 5/5] Add "move_tab_to_new_window" action. Fixes #2630 Requires #2529 for the refactoring. Implemented for GTK/Adwaita only. --- include/ghostty.h | 1 + macos/Sources/Ghostty/Ghostty.App.swift | 2 + src/Surface.zig | 6 ++ src/apprt/action.zig | 4 + src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 20 ++++ src/apprt/gtk/Window.zig | 23 +++++ src/apprt/gtk/notebook.zig | 7 ++ src/apprt/gtk/notebook_adw.zig | 132 ++++++++++++++++-------- src/input/Binding.zig | 5 + 10 files changed, 159 insertions(+), 42 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 41e3b3fe2..032fe04a7 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -544,6 +544,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_VISIBILITY, GHOSTTY_ACTION_MOVE_TAB, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_RESIZE_SPLIT, diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 489493ad3..609adbbb3 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -524,6 +524,8 @@ extension Ghostty { case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) + case GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW: + fallthrough case GHOSTTY_ACTION_COLOR_CHANGE: fallthrough case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: diff --git a/src/Surface.zig b/src/Surface.zig index fbb589638..23aa56522 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3923,6 +3923,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .amount = position }, ), + .move_tab_to_new_window => try self.rt_app.performAction( + .{ .surface = self }, + .move_tab_to_new_window, + {}, + ), + .new_split => |direction| try self.rt_app.performAction( .{ .surface = self }, .new_split, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 1f954c37c..20ced89c7 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -107,6 +107,9 @@ pub const Action = union(Key) { /// cyclically within the tab range. move_tab: MoveTab, + /// Moves the tab that contains the target surface to a new window. + move_tab_to_new_window, + /// Jump to a specific tab. Must handle the scenario that the tab /// value is invalid. goto_tab: GotoTab, @@ -205,6 +208,7 @@ pub const Action = union(Key) { toggle_quick_terminal, toggle_visibility, move_tab, + move_tab_to_new_window, goto_tab, goto_split, resize_split, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 54c53139c..d78edc9ab 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -214,6 +214,7 @@ pub const App = struct { .toggle_visibility, .goto_tab, .move_tab, + .move_tab_to_new_window, .inspector, .render_inspector, .quit_timer, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 424a97c3a..804893fc2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -457,6 +457,7 @@ pub fn performAction( .new_tab => try self.newTab(target), .goto_tab => self.gotoTab(target, value), .move_tab => self.moveTab(target, value), + .move_tab_to_new_window => self.moveTabToNewWindow(target), .new_split => try self.newSplit(target, value), .resize_split => self.resizeSplit(target, value), .equalize_splits => self.equalizeSplits(target), @@ -547,6 +548,23 @@ fn moveTab(_: *App, target: apprt.Target, move_tab: apprt.action.MoveTab) void { } } +fn moveTabToNewWindow(_: *App, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "moveTabToNewWindow invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + window.moveTabToNewWindow(v.rt_surface); + }, + } +} + fn newSplit( self: *App, target: apprt.Target, @@ -887,6 +905,7 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("win.split_down", .{ .new_split = .down }); try self.syncActionAccelerator("win.split_left", .{ .new_split = .left }); try self.syncActionAccelerator("win.split_up", .{ .new_split = .up }); + try self.syncActionAccelerator("win.move_tab_to_new_window", .{ .move_tab_to_new_window = {} }); try self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); try self.syncActionAccelerator("win.reset", .{ .reset = {} }); @@ -1545,6 +1564,7 @@ fn initContextMenu(self: *App) void { c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); c.g_menu_append(section, "Split Right", "win.split_right"); c.g_menu_append(section, "Split Down", "win.split_down"); + c.g_menu_append(section, "Move Tab to New Window", "win.move_tab_to_new_window"); } { diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index c98c53969..76c2b5c31 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -375,6 +375,7 @@ fn initActions(self: *Window) void { .{ "split_down", >kActionSplitDown }, .{ "split_left", >kActionSplitLeft }, .{ "split_up", >kActionSplitUp }, + .{ "move_tab_to_new_window", >kActionMoveTabToNewWindow }, .{ "toggle_inspector", >kActionToggleInspector }, .{ "copy", >kActionCopy }, .{ "paste", >kActionPaste }, @@ -462,6 +463,15 @@ pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void { self.notebook.moveTab(tab, position); } +/// Move the current tab for a surface to a new window. +pub fn moveTabToNewWindow(self: *Window, surface: *Surface) void { + const tab = surface.container.tab() orelse { + log.info("surface is not attached to a tab bar, cannot navigate", .{}); + return; + }; + self.notebook.moveTabToNewWindow(tab); +} + /// Go to the next tab for a surface. pub fn gotoLastTab(self: *Window) void { const max = self.notebook.nPages() -| 1; @@ -853,6 +863,19 @@ fn gtkActionSplitUp( }; } +fn gtkActionMoveTabToNewWindow( + _: *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(.{ .move_tab_to_new_window = {} }) catch |err| { + log.warn("error performing binding action error={}", .{err}); + return; + }; +} + fn gtkActionToggleInspector( _: *c.GSimpleAction, _: *c.GVariant, diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index e9bdaacc7..f05c388bb 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -121,6 +121,13 @@ pub const Notebook = union(enum) { self.reorderPage(tab, new_position); } + pub fn moveTabToNewWindow(self: *Notebook, tab: *Tab) void { + switch (self.*) { + .adw => |*adw| adw.moveTabToNewWindow(tab), + .gtk => log.warn("move_tab_to_new_window is not implemented for non-Adwaita notebooks", .{}), + } + } + pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void { switch (self.*) { .adw => |*adw| adw.reorderPage(tab, position), diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 773d8f490..47d2b9257 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -14,6 +14,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 + window: *Window, + /// the tab view tab_view: *AdwTabView, @@ -34,6 +37,7 @@ pub const NotebookAdw = struct { notebook.* = .{ .adw = .{ + .window = window, .tab_view = tab_view, .last_tab = null, }, @@ -42,49 +46,62 @@ pub const NotebookAdw = struct { const self = ¬ebook.adw; self.initContextMenu(window); - _ = 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, "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); + _ = 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, "page-attached", c.G_CALLBACK(&adwTabViewPageAttached), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "page-reordered", c.G_CALLBACK(&adwTabViewPageAttached), 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); + _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), self, null, c.G_CONNECT_DEFAULT); } pub fn initContextMenu(self: *NotebookAdw, window: *Window) void { - { - var buf: [32]u8 = undefined; - const action_name = std.fmt.bufPrintZ( - &buf, - "close-tab-{x:8>0}", - .{@intFromPtr(self)}, - ) catch unreachable; - - const action = c.g_simple_action_new(action_name, null); - defer c.g_object_unref(action); - _ = c.g_signal_connect_data( - action, - "activate", - c.G_CALLBACK(&adwTabViewCloseTab), - self, - null, - c.G_CONNECT_DEFAULT, - ); - c.g_action_map_add_action(@ptrCast(window.window), @ptrCast(action)); - } - const menu = c.g_menu_new(); errdefer c.g_object_unref(menu); { - var buf: [32]u8 = undefined; - const action_name = std.fmt.bufPrintZ( - &buf, - "win.close-tab-{x:8>0}", - .{@intFromPtr(self)}, - ) catch unreachable; - const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - c.g_menu_append(section, "Close Tab", action_name); + // The set of menu items. Each menu item has (in order): + // [0] The action name + // [1] The menu name + // [2] The callback function + const menu_items = .{ + .{ "close-tab", "Close Tab", &adwTabViewCloseTab }, + .{ "move-tab-to-new-window", "Move Tab to New Window", &adwTabViewMoveTabToNewWindow }, + }; + + inline for (menu_items) |menu_item| { + var buf: [48]u8 = undefined; + + const action_name = std.fmt.bufPrintZ( + &buf, + "{s}-{x:8>0}", + .{ menu_item[0], @intFromPtr(self) }, + ) catch unreachable; + + const action = c.g_simple_action_new(action_name, null); + defer c.g_object_unref(action); + _ = c.g_signal_connect_data( + action, + "activate", + c.G_CALLBACK(menu_item[2]), + self, + null, + c.G_CONNECT_DEFAULT, + ); + c.g_action_map_add_action(@ptrCast(window.window), @ptrCast(action)); + } + + inline for (menu_items) |menu_item| { + var buf: [48]u8 = undefined; + const action_name = std.fmt.bufPrintZ( + &buf, + "win.{s}-{x:8>0}", + .{ menu_item[0], @intFromPtr(self) }, + ) catch unreachable; + + c.g_menu_append(section, menu_item[1], action_name); + } } c.adw_tab_view_set_menu_model(self.tab_view, @ptrCast(@alignCast(menu))); @@ -176,27 +193,53 @@ pub const NotebookAdw = struct { c.g_object_unref(tab.box); } - c.gtk_window_destroy(tab.window.window); + c.gtk_window_destroy(self.window.window); + } + } + + pub fn moveTabToNewWindow(self: *NotebookAdw, tab: *Tab) void { + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse { + log.err("tab is not part of this notebook", .{}); + return; + }; + const other_window = createWindow(self.window) catch { + log.err("unable to create window", .{}); + return; + }; + switch (other_window.notebook.*) { + .adw => |*other| { + c.adw_tab_view_transfer_page(self.tab_view, page, other.tab_view, 0); + other_window.focusCurrentTab(); + }, + .gtk => { + log.err("expecting an Adwaita notebook!", .{}); + c.gtk_window_destroy(other_window.window); + return; + }, + } + + if (self.nPages() == 0) { + c.gtk_window_destroy(self.window.window); } } }; -fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); +fn adwTabViewPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void { + const self: *NotebookAdw = @ptrCast(@alignCast(ud.?)); 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 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.?)); + const window = createWindow(self.window) catch |err| { log.warn("error creating new window error={}", .{err}); return null; }; @@ -204,10 +247,10 @@ 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.?)); + const page = c.adw_tab_view_get_selected_page(self.window.notebook.adw.tab_view) orelse return; const title = c.adw_tab_page_get_title(page); - c.gtk_window_set_title(window.window, title); + c.gtk_window_set_title(self.window.window, title); } fn adwTabViewSetupMenu(_: *AdwTabView, page: *AdwTabPage, ud: ?*anyopaque) callconv(.C) void { @@ -226,3 +269,8 @@ fn adwTabViewCloseTab(_: *c.GSimpleAction, _: *c.GVariant, ud: ?*anyopaque) call const self: *NotebookAdw = @ptrCast(@alignCast(ud.?)); self.closeTab(self.last_tab orelse return); } + +fn adwTabViewMoveTabToNewWindow(_: *c.GSimpleAction, _: *c.GVariant, ud: ?*anyopaque) callconv(.C) void { + const self: *NotebookAdw = @ptrCast(@alignCast(ud.?)); + self.moveTabToNewWindow(self.last_tab orelse return); +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 347bc56d2..fe2d49e01 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -306,6 +306,10 @@ pub const Action = union(enum) { /// If the new position is out of bounds, it wraps around cyclically within the tab range. move_tab: isize, + /// Move tab to a new window, where it will become the first and only tab in + /// that window. + move_tab_to_new_window: void, + /// Toggle the tab overview. /// This only works with libadwaita enabled currently. toggle_tab_overview: void, @@ -653,6 +657,7 @@ pub const Action = union(enum) { .last_tab, .goto_tab, .move_tab, + .move_tab_to_new_window, .toggle_tab_overview, .new_split, .goto_split,