diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig new file mode 100644 index 000000000..c702b0513 --- /dev/null +++ b/src/apprt/gtk/TabView.zig @@ -0,0 +1,262 @@ +/// An abstraction over the Adwaita tab view to manage all the terminal tabs in +/// a window. +const TabView = @This(); + +const std = @import("std"); + +const gtk = @import("gtk"); +const adw = @import("adw"); +const gobject = @import("gobject"); + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const adwaita = @import("adwaita.zig"); + +const log = std.log.scoped(.gtk); + +/// our window +window: *Window, + +/// the tab view +tab_view: *adw.TabView, + +/// Set to true so that the adw close-page handler knows we're forcing +/// and to allow a close to happen with no confirm. This is a bit of a hack +/// because we currently use GTK alerts to confirm tab close and they +/// don't carry with them the ADW state that we are confirming or not. +/// Long term we should move to ADW alerts so we can know if we are +/// confirming or not. +forcing_close: bool = false, + +pub fn init(self: *TabView, window: *Window) void { + self.* = .{ + .window = window, + .tab_view = adw.TabView.new(), + }; + self.tab_view.as(gtk.Widget).addCssClass("notebook"); + + if (adwaita.versionAtLeast(1, 2, 0)) { + // Adwaita enables all of the shortcuts by default. + // We want to manage keybindings ourselves. + self.tab_view.removeShortcuts(.{}); + } + + _ = adw.TabView.signals.page_attached.connect( + self.tab_view, + *TabView, + adwPageAttached, + self, + .{}, + ); + _ = adw.TabView.signals.close_page.connect( + self.tab_view, + *TabView, + adwClosePage, + self, + .{}, + ); + _ = adw.TabView.signals.create_window.connect( + self.tab_view, + *TabView, + adwTabViewCreateWindow, + self, + .{}, + ); + _ = gobject.Object.signals.notify.connect( + self.tab_view, + *TabView, + adwSelectPage, + self, + .{ + .detail = "selected-page", + }, + ); +} + +pub fn asWidget(self: *TabView) *gtk.Widget { + return self.tab_view.as(gtk.Widget); +} + +pub fn nPages(self: *TabView) c_int { + return self.tab_view.getNPages(); +} + +/// Returns the index of the currently selected page. +/// Returns null if the notebook has no pages. +fn currentPage(self: *TabView) ?c_int { + const page = self.tab_view.getSelectedPage() orelse return null; + return self.tab_view.getPagePosition(page); +} + +/// Returns the currently selected tab or null if there are none. +pub fn currentTab(self: *TabView) ?*Tab { + const page = self.tab_view.getSelectedPage() orelse return null; + const child = page.getChild().as(gobject.Object); + return @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return null)); +} + +pub fn gotoNthTab(self: *TabView, position: c_int) bool { + const page_to_select = self.tab_view.getNthPage(position); + self.tab_view.setSelectedPage(page_to_select); + return true; +} + +pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int { + const page = self.tab_view.getPage(@ptrCast(tab.box)); + return self.tab_view.getPagePosition(page); +} + +pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool { + const page_idx = self.getTabPosition(tab) orelse return false; + + // The next index is the previous or we wrap around. + const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: { + const max = self.nPages(); + break :next_idx max -| 1; + }; + + // Do nothing if we have one tab + if (next_idx == page_idx) return false; + + return self.gotoNthTab(next_idx); +} + +pub fn gotoNextTab(self: *TabView, tab: *Tab) bool { + const page_idx = self.getTabPosition(tab) orelse return false; + + const max = self.nPages() -| 1; + const next_idx = if (page_idx < max) page_idx + 1 else 0; + if (next_idx == page_idx) return false; + + return self.gotoNthTab(next_idx); +} + +pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void { + const page_idx = self.getTabPosition(tab) orelse return; + + const max = self.nPages() -| 1; + var new_position: c_int = page_idx + position; + + if (new_position < 0) { + new_position = max + new_position + 1; + } else if (new_position > max) { + new_position = new_position - max - 1; + } + + if (new_position == page_idx) return; + self.reorderPage(tab, new_position); +} + +pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void { + const page = self.tab_view.getPage(@ptrCast(tab.box)); + _ = self.tab_view.reorderPage(page, position); +} + +pub fn setTabLabel(self: *TabView, tab: *Tab, title: [:0]const u8) void { + const page = self.tab_view.getPage(@ptrCast(tab.box)); + page.setTitle(title.ptr); +} + +pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void { + const page = self.tab_view.getPage(@ptrCast(tab.box)); + page.setTooltip(tooltip.ptr); +} + +fn newTabInsertPosition(self: *TabView, 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, + .end => numPages, + }; +} + +/// Adds a new tab with the given title to the notebook. +pub fn addTab(self: *TabView, tab: *Tab, title: [:0]const u8) void { + const position = self.newTabInsertPosition(tab); + const box_widget: *gtk.Widget = @ptrCast(tab.box); + const page = self.tab_view.insert(box_widget, position); + self.setTabLabel(tab, title); + self.tab_view.setSelectedPage(page); +} + +pub fn closeTab(self: *TabView, tab: *Tab) void { + // closeTab always expects to close unconditionally so we mark this + // as true so that the close_page call below doesn't request + // confirmation. + self.forcing_close = true; + const n = self.nPages(); + defer { + // self becomes invalid if we close the last page because we close + // the whole window + if (n > 1) self.forcing_close = false; + } + + const page = self.tab_view.getPage(@ptrCast(tab.box)); + self.tab_view.closePage(page); + + // If we have no more tabs we close the window + if (self.nPages() == 0) { + const window: *gtk.Window = @ptrCast(@alignCast(tab.window.window)); + + // 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)) { + const box: *gtk.Box = @ptrCast(@alignCast(tab.box)); + box.as(gobject.Object).unref(); + } + + // `self` will become invalid after this call because it will have + // been freed up as part of the process of closing the window. + window.destroy(); + } +} + +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 adwPageAttached(_: *adw.TabView, page: *adw.TabPage, _: c_int, self: *TabView) callconv(.C) void { + const child = page.getChild().as(gobject.Object); + const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return)); + tab.window = self.window; + + self.window.focusCurrentTab(); +} + +fn adwClosePage( + _: *adw.TabView, + page: *adw.TabPage, + self: *TabView, +) callconv(.C) c_int { + const child = page.getChild().as(gobject.Object); + const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); + self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); + if (!self.forcing_close) tab.closeWithConfirmation(); + return 1; +} + +fn adwTabViewCreateWindow( + _: *adw.TabView, + self: *TabView, +) callconv(.C) ?*adw.TabView { + const window = createWindow(self.window) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + return window.notebook.tab_view; +} + +fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.C) void { + const page = self.tab_view.getSelectedPage() orelse return; + const title = page.getTitle(); + self.window.setTitle(std.mem.span(title)); +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index ab5e54d9f..0fd1e7429 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -22,7 +22,7 @@ const Tab = @import("Tab.zig"); const c = @import("c.zig").c; const adwaita = @import("adwaita.zig"); const gtk_key = @import("key.zig"); -const Notebook = @import("notebook.zig"); +const TabView = @import("TabView.zig"); const HeaderBar = @import("headerbar.zig"); const version = @import("version.zig"); const winproto = @import("winproto.zig"); @@ -42,7 +42,7 @@ headerbar: HeaderBar, tab_overview: ?*c.GtkWidget, /// The notebook (tab grouping) for this window. -notebook: Notebook, +notebook: TabView, context_menu: *c.GtkWidget, @@ -110,12 +110,12 @@ pub fn init(self: *Window, app: *App) !void { const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); // Setup our notebook - self.notebook.init(); + self.notebook.init(self); // If we are using Adwaita, then we can support the tab overview. self.tab_overview = if (adwaita.versionAtLeast(1, 4, 0)) overview: { const tab_overview = c.adw_tab_overview_new(); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.tab_view); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), @ptrCast(@alignCast(self.notebook.tab_view))); c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1); _ = c.g_signal_connect_data( tab_overview, @@ -171,7 +171,7 @@ pub fn init(self: *Window, app: *App) !void { .hidden => btn: { const btn = c.adw_tab_button_new(); - c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.tab_view); + c.adw_tab_button_set_view(@ptrCast(btn), @ptrCast(@alignCast(self.notebook.tab_view))); c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); break :btn btn; }, @@ -229,7 +229,7 @@ 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 (!adwaita.versionAtLeast(1, 4, 0)) unreachable; - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.tab_view); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), @ptrCast(@alignCast(self.notebook.tab_view))); } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); @@ -267,7 +267,7 @@ pub fn init(self: *Window, app: *App) !void { if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); - c.adw_tab_bar_set_view(tab_bar, self.notebook.tab_view); + c.adw_tab_bar_set_view(tab_bar, @ptrCast(@alignCast(self.notebook.tab_view))); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -315,7 +315,7 @@ pub fn init(self: *Window, app: *App) !void { ), .hidden => unreachable, } - c.adw_tab_bar_set_view(tab_bar, self.notebook.tab_view); + c.adw_tab_bar_set_view(tab_bar, @ptrCast(@alignCast(self.notebook.tab_view))); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); } @@ -662,13 +662,13 @@ fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { /// Create a new tab from the AdwTabOverview. We can't copy gtkTabNewClick /// because we need to return an AdwTabPage from this function. fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage { + if (!adwaita.versionAtLeast(1, 4, 0)) unreachable; const self: *Window = userdataSelf(ud.?); - assert(adwaita.versionAtLeast(1, 4, 0)); 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.tab_view, @ptrCast(@alignCast(tab.box))); + return c.adw_tab_view_get_page(@ptrCast(@alignCast(self.notebook.tab_view)), @ptrCast(@alignCast(tab.box))); } fn adwTabOverviewOpen( diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig deleted file mode 100644 index e411ba9ad..000000000 --- a/src/apprt/gtk/notebook.zig +++ /dev/null @@ -1,249 +0,0 @@ -/// An abstraction over the GTK notebook and Adwaita tab view to manage -/// all the terminal tabs in a window. -const Notebook = @This(); - -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 adwaita = @import("adwaita.zig"); - -const log = std.log.scoped(.gtk); - -/// the tab view -tab_view: *c.AdwTabView, - -/// Set to true so that the adw close-page handler knows we're forcing -/// and to allow a close to happen with no confirm. This is a bit of a hack -/// because we currently use GTK alerts to confirm tab close and they -/// don't carry with them the ADW state that we are confirming or not. -/// Long term we should move to ADW alerts so we can know if we are -/// confirming or not. -forcing_close: bool = false, - -pub fn init(self: *Notebook) void { - const window: *Window = @fieldParentPtr("notebook", self); - - const tab_view: *c.AdwTabView = c.adw_tab_view_new() orelse unreachable; - c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook"); - - 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); - } - - self.* = .{ - .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); -} - -pub fn asWidget(self: *Notebook) *c.GtkWidget { - return @ptrCast(@alignCast(self.tab_view)); -} - -pub fn nPages(self: *Notebook) c_int { - return c.adw_tab_view_get_n_pages(self.tab_view); -} - -/// Returns the index of the currently selected page. -/// Returns null if the notebook has no pages. -fn currentPage(self: *Notebook) ?c_int { - 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: *Notebook) ?*Tab { - 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: *Notebook, position: c_int) bool { - const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position) orelse return false; - c.adw_tab_view_set_selected_page(self.tab_view, page_to_select); - return true; -} - -pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int { - 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 gotoPreviousTab(self: *Notebook, tab: *Tab) bool { - const page_idx = self.getTabPosition(tab) orelse return false; - - // The next index is the previous or we wrap around. - const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: { - const max = self.nPages(); - break :next_idx max -| 1; - }; - - // Do nothing if we have one tab - if (next_idx == page_idx) return false; - - return self.gotoNthTab(next_idx); -} - -pub fn gotoNextTab(self: *Notebook, tab: *Tab) bool { - const page_idx = self.getTabPosition(tab) orelse return false; - - const max = self.nPages() -| 1; - const next_idx = if (page_idx < max) page_idx + 1 else 0; - if (next_idx == page_idx) return false; - - return self.gotoNthTab(next_idx); -} - -pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void { - const page_idx = self.getTabPosition(tab) orelse return; - - const max = self.nPages() -| 1; - var new_position: c_int = page_idx + position; - - if (new_position < 0) { - new_position = max + new_position + 1; - } else if (new_position > max) { - new_position = new_position - max - 1; - } - - if (new_position == page_idx) return; - self.reorderPage(tab, new_position); -} - -pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void { - 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: *Notebook, tab: *Tab, title: [:0]const u8) void { - 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: *Notebook, tab: *Tab, tooltip: [:0]const u8) void { - const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_tooltip(page, tooltip.ptr); -} - -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, - .end => numPages, - }; -} - -/// Adds a new tab with the given title to the notebook. -pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void { - const position = self.newTabInsertPosition(tab); - 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: *Notebook, tab: *Tab) void { - // closeTab always expects to close unconditionally so we mark this - // as true so that the close_page call below doesn't request - // confirmation. - self.forcing_close = true; - const n = self.nPages(); - defer { - // self becomes invalid if we close the last page because we close - // the whole window - if (n > 1) self.forcing_close = false; - } - - 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) { - const window = tab.window.window; - - // 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); - } - - // `self` will become invalid after this call because it will have - // been freed up as part of the process of closing the window. - c.gtk_window_destroy(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 adwPageAttached(_: *c.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 adwClosePage( - _: *c.AdwTabView, - page: *c.AdwTabPage, - ud: ?*anyopaque, -) callconv(.C) c.gboolean { - 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; - c.adw_tab_view_close_page_finish( - notebook.tab_view, - page, - @intFromBool(notebook.forcing_close), - ); - if (!notebook.forcing_close) tab.closeWithConfirmation(); - return 1; -} - -fn adwTabViewCreateWindow( - _: *c.AdwTabView, - ud: ?*anyopaque, -) callconv(.C) ?*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.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.tab_view) orelse return; - const title = c.adw_tab_page_get_title(page); - window.setTitle(std.mem.span(title)); -}