diff --git a/include/ghostty.h b/include/ghostty.h index d0426e995..0a36efa97 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 27a3fb5a8..2b133f88c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3948,6 +3948,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 aef6937a8..49c7730d5 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, @@ -212,6 +215,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 3c866a1de..7d33944e9 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 99148fd87..d3576d2ca 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), @@ -548,6 +549,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, @@ -917,6 +935,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 = {} }); @@ -1561,7 +1580,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(); @@ -1569,6 +1594,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"); } { @@ -1582,18 +1608,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 aef67b308..5dc9b83ff 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1195,7 +1195,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/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 e220ac03b..76c2b5c31 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); + 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))); @@ -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); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -323,8 +322,8 @@ pub fn init(self: *Window, app: *App) !void { ); } } else { - switch (self.notebook) { - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + switch (self.notebook.*) { + .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().?; @@ -344,14 +343,14 @@ 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, 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 @@ -376,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 }, @@ -403,6 +403,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. @@ -461,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; @@ -552,14 +563,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, @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. @@ -852,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 73213e9da..f05c388bb 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -4,161 +4,83 @@ 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; - /// 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, - gtk_notebook: *c.GtkNotebook, + adw: NotebookAdw, + gtk: NotebookGtk, - 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. + const notebook = try alloc.create(Notebook); + errdefer alloc.destroy(notebook); const app = window.app; - if (adwaita.enabled(&app.config)) return initAdw(window); - return initGtk(window); - } - - fn initGtk(window: *Window) Notebook { - 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); - - return .{ .gtk_notebook = notebook }; - } - - fn initAdw(window: *Window) Notebook { - 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); + if (adwaita.enabled(&app.config)) { + NotebookAdw.init(notebook, window); + return notebook; } - - _ = 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); - - return .{ .adw_tab_view = tab_view }; + NotebookGtk.init(notebook, window); + return notebook; } - pub fn asWidget(self: Notebook) *c.GtkWidget { - return switch (self) { - .adw_tab_view => |tab_view| @ptrCast(@alignCast(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) - 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) orelse return null; - return c.adw_tab_view_get_page_position(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) 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, position); - c.adw_tab_view_set_selected_page(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, @ptrCast(tab.box)) orelse return null; - break :page_idx c.adw_tab_view_get_page_position(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. @@ -173,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; @@ -183,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; @@ -199,42 +121,35 @@ 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, @ptrCast(tab.box)); - _ = c.adw_tab_view_reorder_page(tab_view, page, 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 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, @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 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 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 setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabLabel(tab, title), + .gtk => |*gtk| gtk.setTabLabel(tab, title), } } - fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { + 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 { const numPages = self.nPages(); return switch (tab.window.app.config.@"window-new-tab-position") { .current => if (self.currentPage()) |page| page + 1 else numPages, @@ -243,249 +158,23 @@ 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, 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); - }, - .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 addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + const position = self.newTabInsertPosition(tab); + switch (self.*) { + .adw => |*adw| adw.addTab(tab, position, title), + .gtk => |*gtk| gtk.addTab(tab, position, title), } } - 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, @ptrCast(tab.box)) orelse return; - c.adw_tab_view_close_page(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(); - }, + pub fn closeTab(self: *Notebook, tab: *Tab) void { + switch (self.*) { + .adw => |*adw| adw.closeTab(tab), + .gtk => |*gtk| gtk.closeTab(tab), } } - - 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 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) 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; -} - -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; diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig new file mode 100644 index 000000000..47d2b9257 --- /dev/null +++ b/src/apprt/gtk/notebook_adw.zig @@ -0,0 +1,276 @@ +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 window + window: *Window, + + /// 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 = .{ + .window = window, + .tab_view = tab_view, + .last_tab = null, + }, + }; + + const self = ¬ebook.adw; + self.initContextMenu(window); + + _ = 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 { + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + // 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))); + } + + 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(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 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 = self.window; + + self.window.focusCurrentTab(); +} + +fn adwTabViewCreateWindow( + _: *AdwTabView, + ud: ?*anyopaque, +) callconv(.C) ?*AdwTabView { + const self: *NotebookAdw = @ptrCast(@alignCast(ud.?)); + const window = createWindow(self.window) 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 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(self.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); +} + +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/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; +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig index fa719d981..e3464b13d 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, @@ -665,6 +669,7 @@ pub const Action = union(enum) { .last_tab, .goto_tab, .move_tab, + .move_tab_to_new_window, .toggle_tab_overview, .new_split, .goto_split,