diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 2daa6f20e..0c73f925b 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -37,12 +37,13 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 4, .name = "clipboard-confirmation-dialog" }, .{ .major = 1, .minor = 2, .name = "close-confirmation-dialog" }, .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, + .{ .major = 1, .minor = 2, .name = "debug-warning" }, + .{ .major = 1, .minor = 3, .name = "debug-warning" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, + .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, - .{ .major = 1, .minor = 2, .name = "debug-warning" }, - .{ .major = 1, .minor = 3, .name = "debug-warning" }, }; /// CSS files in css_path diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 636700284..ce6e4d9f6 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -22,6 +22,7 @@ const xev = @import("../../../global.zig").xev; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); const adw_version = @import("../adw_version.zig"); const gtk_version = @import("../gtk_version.zig"); const winprotopkg = @import("../winproto.zig"); @@ -496,10 +497,16 @@ pub const Application = extern struct { value.config, ), + .goto_tab => return Action.gotoTab(target, value), + .mouse_over_link => Action.mouseOverLink(target, value), .mouse_shape => Action.mouseShape(target, value), .mouse_visibility => Action.mouseVisibility(target, value), + .move_tab => return Action.moveTab(target, value), + + .new_tab => return Action.newTab(target), + .new_window => try Action.newWindow( self, switch (target) { @@ -530,11 +537,9 @@ pub const Application = extern struct { .toggle_maximize => Action.toggleMaximize(target), .toggle_fullscreen => Action.toggleFullscreen(target), + .toggle_tab_overview => return Action.toggleTabOverview(target), // Unimplemented but todo on gtk-ng branch - .new_tab, - .goto_tab, - .move_tab, .new_split, .resize_split, .equalize_splits, @@ -545,7 +550,6 @@ pub const Application = extern struct { .present_terminal, .initial_size, .size_limit, - .toggle_tab_overview, .toggle_split_zoom, .toggle_window_decorations, .prompt_title, @@ -1214,6 +1218,32 @@ const Action = struct { } } + pub fn gotoTab( + target: apprt.Target, + tab: apprt.action.GotoTab, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + + return window.selectTab(switch (tab) { + .previous => .previous, + .next => .next, + .last => .last, + else => .{ .n = @intCast(@intFromEnum(tab)) }, + }); + }, + } + } + pub fn mouseOverLink( target: apprt.Target, value: apprt.action.MouseOverLink, @@ -1272,11 +1302,60 @@ const Action = struct { } } + pub fn moveTab( + target: apprt.Target, + value: apprt.action.MoveTab, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + + return window.moveTab( + surface, + @intCast(value.amount), + ); + }, + } + } + + pub fn newTab(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("new tab to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + // Get the window ancestor of the surface. Surfaces shouldn't + // be aware they might be in windows but at the app level we + // can do this. + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + window.newTab(core); + return true; + }, + } + } + pub fn newWindow( self: *Application, parent: ?*CoreSurface, ) !void { - const win = Window.new(self, parent); + const win = Window.new(self); // Setup a binding so that whenever our config changes so does the // window. There's never a time when the window config should be out @@ -1289,6 +1368,9 @@ const Action = struct { .{}, ); + // Create a new tab + win.newTab(parent); + // Show the window gtk.Window.present(win.as(gtk.Window)); } @@ -1434,6 +1516,25 @@ const Action = struct { .surface => |v| v.rt_surface.surface.toggleMaximize(), } } + + pub fn toggleTabOverview(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab", .{}); + return false; + }; + + window.toggleTabOverview(); + return true; + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig index cd22f9b31..579f792ce 100644 --- a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig @@ -57,6 +57,17 @@ pub const CloseConfirmationDialog = extern struct { void, ); }; + + pub const cancel = struct { + pub const name = "cancel"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; }; const Private = struct { @@ -72,14 +83,15 @@ pub const CloseConfirmationDialog = extern struct { fn init(self: *Self, _: *Class) callconv(.C) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + pub fn present(self: *Self, parent: ?*gtk.Widget) void { // Setup our title/body text. const priv = self.private(); self.as(Dialog.Parent).setHeading(priv.target.title()); self.as(Dialog.Parent).setBody(priv.target.body()); - } - pub fn present(self: *Self, parent: ?*gtk.Widget) void { + // Show it self.as(Dialog).present(parent); } @@ -91,13 +103,21 @@ pub const CloseConfirmationDialog = extern struct { self: *Self, response_id: [*:0]const u8, ) callconv(.C) void { - if (std.mem.orderZ(u8, response_id, "close") != .eq) return; - signals.@"close-request".impl.emit( - self, - null, - .{}, - null, - ); + if (std.mem.orderZ(u8, response_id, "close") == .eq) { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + } else { + signals.cancel.impl.emit( + self, + null, + .{}, + null, + ); + } } fn dispose(self: *Self) callconv(.C) void { @@ -141,6 +161,7 @@ pub const CloseConfirmationDialog = extern struct { // Signals signals.@"close-request".impl.register(.{}); + signals.cancel.impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); @@ -158,11 +179,13 @@ pub const CloseConfirmationDialog = extern struct { /// together into one struct that is the sole source of truth. pub const Target = enum(c_int) { app, + tab, window, pub fn title(self: Target) [*:0]const u8 { return switch (self) { .app => i18n._("Quit Ghostty?"), + .tab => i18n._("Close Tab?"), .window => i18n._("Close Window?"), }; } @@ -170,6 +193,7 @@ pub const Target = enum(c_int) { pub fn body(self: Target) [*:0]const u8 { return switch (self) { .app => i18n._("All terminal sessions will be terminated."), + .tab => i18n._("All terminal sessions in this tab will be terminated."), .window => i18n._("All terminal sessions in this window will be terminated."), }; } diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index bb432cf6f..3544fb6d3 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1050,6 +1050,13 @@ pub const Surface = extern struct { ); } + /// Focus this surface. This properly focuses the input part of + /// our surface. + pub fn grabFocus(self: *Self) void { + const priv = self.private(); + _ = priv.gl_area.as(gtk.Widget).grabFocus(); + } + //--------------------------------------------------------------- // Virtual Methods diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig new file mode 100644 index 000000000..9787e991e --- /dev/null +++ b/src/apprt/gtk-ng/class/tab.zig @@ -0,0 +1,289 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); +const assert = std.debug.assert; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const input = @import("../../../input.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const gtk_version = @import("../gtk_version.zig"); +const adw_version = @import("../adw_version.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; + +const log = std.log.scoped(.gtk_ghostty_window); + +pub const Tab = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gtk.Box; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyTab", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + /// The active surface is the focus that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .nick = "Active Surface", + .blurb = "The currently active surface.", + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = Self.getActiveSurface, + }, + ), + }, + ); + }; + + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .nick = "Config", + .blurb = "The configuration that this surface is using.", + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const title = struct { + pub const name = "title"; + pub const get = impl.get; + pub const set = impl.set; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .nick = "Title", + .blurb = "The title of the active surface.", + .default = null, + .accessor = C.privateStringFieldAccessor("title"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted whenever the tab would like to be closed. + pub const @"close-request" = struct { + pub const name = "close-request"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; + + const Private = struct { + /// The configuration that this surface is using. + config: ?*Config = null, + + /// The title to show for this tab. This is usually set to a binding + /// with the active surface but can be manually set to anything. + title: ?[:0]const u8 = null, + + /// The binding groups for the current active surface. + surface_bindings: *gobject.BindingGroup, + + // Template bindings + surface: *Surface, + + pub var offset: c_int = 0; + }; + + /// Set the parent of this tab page. This only affects the first surface + /// ever created for a tab. If a surface was already created this does + /// nothing. + pub fn setParent( + self: *Self, + parent: *CoreSurface, + ) void { + const priv = self.private(); + priv.surface.setParent(parent); + } + + fn init(self: *Self, _: *Class) callconv(.C) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // If our configuration is null then we get the configuration + // from the application. + const priv = self.private(); + if (priv.config == null) { + const app = Application.default(); + priv.config = app.getConfig(); + } + + // Setup binding groups for surface properties + priv.surface_bindings = gobject.BindingGroup.new(); + priv.surface_bindings.bind( + "title", + self.as(gobject.Object), + "title", + .{}, + ); + + // TODO: Eventually this should be set dynamically based on the + // current active surface. + priv.surface_bindings.setSource(priv.surface.as(gobject.Object)); + + // We need to do this so that the title initializes properly, + // I think because its a dynamic getter. + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + //--------------------------------------------------------------- + // Properties + + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) *Surface { + const priv = self.private(); + return priv.surface; + } + + /// Returns true if this tab needs confirmation before quitting based + /// on the various Ghostty configurations. + pub fn getNeedsConfirmQuit(self: *Self) bool { + const surface = self.getActiveSurface(); + const core_surface = surface.core() orelse return false; + return core_surface.needsConfirmQuit(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.C) void { + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + priv.surface_bindings.setSource(null); + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.C) void { + const priv = self.private(); + if (priv.title) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.title = null; + } + priv.surface_bindings.unref(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + //--------------------------------------------------------------- + // Signal handlers + + fn surfaceCloseRequest( + _: *Surface, + scope: *const Surface.CloseScope, + self: *Self, + ) callconv(.c) void { + switch (scope.*) { + // Handled upstream... we don't control our window close. + .window => return, + + // Presently both the same, results in the tab closing. + .surface, .tab => { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + }, + } + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.C) void { + gobject.ext.ensureType(Surface); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "tab", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"active-surface".impl, + properties.config.impl, + properties.title.impl, + }); + + // Bindings + class.bindTemplateChildPrivate("surface", .{}); + + // Template Callbacks + class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + + // Signals + signals.@"close-request".impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index ce40462f8..5a61c725b 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -11,6 +11,7 @@ const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); const gtk_version = @import("../gtk_version.zig"); const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); @@ -19,6 +20,7 @@ const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const Surface = @import("surface.zig").Surface; +const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const log = std.log.scoped(.gtk_ghostty_window); @@ -86,7 +88,7 @@ pub const Window = extern struct { .default = build_config.is_debug, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = struct { - pub fn getter(_: *Window) bool { + pub fn getter(_: *Self) bool { return build_config.is_debug; } }.getter, @@ -128,30 +130,104 @@ pub const Window = extern struct { }, ); }; + + pub const @"tabs-autohide" = struct { + pub const name = "tabs-autohide"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Autohide Tab Bar", + .blurb = "If true, tab bar should autohide.", + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getTabsAutohide, + }), + }, + ); + }; + + pub const @"tabs-wide" = struct { + pub const name = "tabs-wide"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Wide Tabs", + .blurb = "If true, tabs will be in the wide expanded style.", + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getTabsWide, + }), + }, + ); + }; + + pub const @"tabs-visible" = struct { + pub const name = "tabs-visible"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .nick = "Tab Bar Visibility", + .blurb = "If true, tab bar should be visible.", + .default = true, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = Self.getTabsVisible, + }), + }, + ); + }; + + pub const @"toolbar-style" = struct { + pub const name = "toolbar-style"; + const impl = gobject.ext.defineProperty( + name, + Self, + adw.ToolbarStyle, + .{ + .nick = "Toolbar Style", + .blurb = "The style for the toolbar top/bottom bars.", + .default = .raised, + .accessor = gobject.ext.typedAccessor( + Self, + adw.ToolbarStyle, + .{ + .getter = Self.getToolbarStyle, + }, + ), + }, + ); + }; }; const Private = struct { + /// Binding group for our active tab. + tab_bindings: *gobject.BindingGroup, + /// The configuration that this surface is using. config: ?*Config = null, + /// See tabOverviewOpen for why we have this. + tab_overview_focus_timer: ?c_uint = null, + // Template bindings - surface: *Surface, + tab_overview: *adw.TabOverview, + tab_bar: *adw.TabBar, + tab_view: *adw.TabView, + toolbar: *adw.ToolbarView, toast_overlay: *adw.ToastOverlay, pub var offset: c_int = 0; }; - pub fn new(app: *Application, parent_: ?*CoreSurface) *Self { - const self = gobject.ext.newInstance(Self, .{ + pub fn new(app: *Application) *Self { + return gobject.ext.newInstance(Self, .{ .application = app, }); - - if (parent_) |parent| { - const priv = self.private(); - priv.surface.setParent(parent); - } - - return self; } fn init(self: *Self, _: *Class) callconv(.C) void { @@ -170,6 +246,11 @@ pub const Window = extern struct { self.as(gtk.Widget).addCssClass("devel"); } + // Setup our tab binding group. This ensures certain properties + // are only synced from the currently active tab. + priv.tab_bindings = gobject.BindingGroup.new(); + priv.tab_bindings.bind("title", self.as(gobject.Object), "title", .{}); + // Set our window icon. We can't set this in the blueprint file // because its dependent on the build config. self.as(gtk.Window).setIconName(build_config.bundle_id); @@ -192,6 +273,8 @@ pub const Window = extern struct { const actions = .{ .{ "about", actionAbout, null }, .{ "close", actionClose, null }, + .{ "close-tab", actionCloseTab, null }, + .{ "new-tab", actionNewTab, null }, .{ "new-window", actionNewWindow, null }, .{ "copy", actionCopy, null }, .{ "paste", actionPaste, null }, @@ -217,20 +300,194 @@ pub const Window = extern struct { } } + /// Create a new tab with the given parent. The tab will be inserted + /// at the position dictated by the `window-new-tab-position` config. + /// The new tab will be selected. + pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { + _ = self.newTabPage(parent_); + } + + fn newTabPage(self: *Self, parent_: ?*CoreSurface) *adw.TabPage { + const priv = self.private(); + const tab_view = priv.tab_view; + + // Create our new tab object + const tab = gobject.ext.newInstance(Tab, .{ + .config = priv.config, + }); + if (parent_) |p| tab.setParent(p); + + // Get the position that we should insert the new tab at. + const config = if (priv.config) |v| v.get() else { + // If we don't have a config we just append it at the end. + // This should never happen. + return tab_view.append(tab.as(gtk.Widget)); + }; + const position = switch (config.@"window-new-tab-position") { + .current => current: { + const selected = tab_view.getSelectedPage() orelse + break :current tab_view.getNPages(); + const current = tab_view.getPagePosition(selected); + break :current current + 1; + }, + + .end => tab_view.getNPages(), + }; + + // Add the page and select it + const page = tab_view.insert(tab.as(gtk.Widget), position); + tab_view.setSelectedPage(page); + + // Create some property bindings + _ = tab.as(gobject.Object).bindProperty( + "title", + page.as(gobject.Object), + "title", + .{ .sync_create = true }, + ); + + return page; + } + + pub const SelectTab = union(enum) { + previous, + next, + last, + n: usize, + }; + + /// Select the tab as requested. Returns true if the tab selection + /// changed. + pub fn selectTab(self: *Self, n: SelectTab) bool { + const priv = self.private(); + const tab_view = priv.tab_view; + + // Get our current tab numeric position + const selected = tab_view.getSelectedPage() orelse return false; + const current = tab_view.getPagePosition(selected); + + // Get our total + const total = tab_view.getNPages(); + + const goto: c_int = switch (n) { + .previous => if (current > 0) + current - 1 + else + total - 1, + + .next => if (current < total - 1) + current + 1 + else + 0, + + .last => total - 1, + + .n => |v| n: { + // 1-indexed + if (v == 0) return false; + + const n_int = std.math.cast( + c_int, + v, + ) orelse return false; + break :n @min(n_int - 1, total - 1); + }, + }; + assert(goto >= 0); + assert(goto < total); + + // If our target is the same as our current then we do nothing. + if (goto == current) return false; + + // Add the page and select it + const page = tab_view.getNthPage(goto); + tab_view.setSelectedPage(page); + + return true; + } + + /// Move the tab containing the given surface by the given amount. + /// Returns if this affected any tab positioning. + pub fn moveTab( + self: *Self, + surface: *Surface, + amount: isize, + ) bool { + const priv = self.private(); + const tab_view = priv.tab_view; + + // If we have one tab we never move. + const total = tab_view.getNPages(); + if (total == 1) return false; + + // Get the tab that contains the given surface. + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse return false; + + // Get the page position that contains the tab. + const page = tab_view.getPage(tab.as(gtk.Widget)); + const pos = tab_view.getPagePosition(page); + + // Move it + const desired_pos: c_int = desired: { + const initial: c_int = @intCast(pos + amount); + const max = total - 1; + break :desired if (initial < 0) + max + initial + 1 + else if (initial > max) + initial - max - 1 + else + initial; + }; + assert(desired_pos >= 0); + assert(desired_pos < total); + + return tab_view.reorderPage(page, desired_pos) != 0; + } + + pub fn toggleTabOverview(self: *Self) void { + const priv = self.private(); + const tab_overview = priv.tab_overview; + const is_open = tab_overview.getOpen() != 0; + tab_overview.setOpen(@intFromBool(!is_open)); + } + /// Updates various appearance properties. This should always be safe /// to call multiple times. This should be called whenever a change /// happens that might affect how the window appears (config change, /// fullscreen, etc.). - fn syncAppearance(self: *Window) void { + fn syncAppearance(self: *Self) void { // TODO: CSD/SSD - // Trigger our headerbar visibility to refresh - self.as(gobject.Object).notifyByPspec(properties.@"headerbar-visible".impl.param_spec); - // Trigger background opacity to refresh - self.as(gobject.Object).notifyByPspec(properties.@"background-opaque".impl.param_spec); + // Trigger all our dynamic properties that depend on the config. + inline for (&.{ + "background-opaque", + "headerbar-visible", + "tabs-autohide", + "tabs-visible", + "tabs-wide", + "toolbar-style", + }) |key| { + self.as(gobject.Object).notifyByPspec( + @field(properties, key).impl.param_spec, + ); + } + + // Remainder uses the config + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return; + + // Move the tab bar to the proper location. + priv.toolbar.remove(priv.tab_bar.as(gtk.Widget)); + switch (config.@"gtk-tabs-location") { + .top => priv.toolbar.addTopBar(priv.tab_bar.as(gtk.Widget)), + .bottom => priv.toolbar.addBottomBar(priv.tab_bar.as(gtk.Widget)), + } } - fn toggleCssClass(self: *Window, class: [:0]const u8, value: bool) void { + fn toggleCssClass(self: *Self, class: [:0]const u8, value: bool) void { const widget = self.as(gtk.Widget); if (value) widget.addCssClass(class.ptr) @@ -240,7 +497,7 @@ pub const Window = extern struct { /// Perform a binding action on the window's active surface. fn performBindingAction( - self: *Window, + self: *Self, action: input.Binding.Action, ) void { const surface = self.getActiveSurface() orelse return; @@ -257,7 +514,7 @@ pub const Window = extern struct { // This is not `pub` because we should be using signals emitted by // other widgets to trigger our toasts. Other objects should not // trigger toasts directly. - fn addToast(self: *Window, title: [*:0]const u8) void { + fn addToast(self: *Self, title: [*:0]const u8) void { const toast = adw.Toast.new(title); toast.setTimeout(3); self.private().toast_overlay.addToast(toast); @@ -269,8 +526,36 @@ pub const Window = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. fn getActiveSurface(self: *Self) ?*Surface { + const tab = self.getSelectedTab() orelse return null; + return tab.getActiveSurface(); + } + + /// Get the currently selected tab as a Tab object. + fn getSelectedTab(self: *Self) ?*Tab { const priv = self.private(); - return priv.surface; + const page = priv.tab_view.getSelectedPage() orelse return null; + const child = page.getChild(); + assert(gobject.ext.isA(child, Tab)); + return gobject.ext.cast(Tab, child); + } + + /// Returns true if this window needs confirmation before quitting. + fn getNeedsConfirmQuit(self: *Self) bool { + const priv = self.private(); + const n = priv.tab_view.getNPages(); + assert(n >= 0); + + for (0..@intCast(n)) |i| { + const page = priv.tab_view.getNthPage(@intCast(i)); + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse { + log.warn("unexpected non-Tab child in tab view", .{}); + continue; + }; + if (tab.getNeedsConfirmQuit()) return true; + } + + return false; } fn getHeaderbarVisible(self: *Self) bool { @@ -301,6 +586,47 @@ pub const Window = extern struct { return config.@"background-opacity" >= 1.0; } + fn getTabsAutohide(self: *Self) bool { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return true; + return switch (config.@"window-show-tab-bar") { + // Auto we always autohide... obviously. + .auto => true, + + // Always we never autohide because we always show the tab bar. + .always => false, + + // Never we autohide because it doesn't actually matter, + // since getTabsVisible will return false. + .never => true, + }; + } + + fn getTabsVisible(self: *Self) bool { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return true; + return switch (config.@"window-show-tab-bar") { + .always, .auto => true, + .never => false, + }; + } + + fn getTabsWide(self: *Self) bool { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return true; + return config.@"gtk-wide-tabs"; + } + + fn getToolbarStyle(self: *Self) adw.ToolbarStyle { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return .raised; + return switch (config.@"gtk-toolbar-style") { + .flat => .flat, + .raised => .raised, + .@"raised-border" => .raised_border, + }; + } + fn propConfig( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -377,6 +703,7 @@ pub const Window = extern struct { v.unref(); priv.config = null; } + priv.tab_bindings.setSource(null); gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -389,23 +716,86 @@ pub const Window = extern struct { ); } + fn finalize(self: *Self) callconv(.C) void { + const priv = self.private(); + priv.tab_bindings.unref(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + //--------------------------------------------------------------- // Signal handlers + fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void { + self.performBindingAction(.new_tab); + } + + fn tabOverviewCreateTab( + _: *adw.TabOverview, + self: *Self, + ) callconv(.c) *adw.TabPage { + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null); + } + + fn tabOverviewOpen( + tab_overview: *adw.TabOverview, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // We only care about when the tab overview is closed. + if (tab_overview.getOpen() != 0) return; + + // On tab overview close, focus is sometimes lost. This is an + // upstream issue in libadwaita[1]. When this is resolved we + // can put a runtime version check here to avoid this workaround. + // + // Our workaround is to start a timer after 500ms to refocus + // the currently selected tab. We choose 500ms because the adw + // animation is 400ms. + // + // [1]: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/670 + + // If we have an old timer remove it + const priv = self.private(); + if (priv.tab_overview_focus_timer) |timer| { + _ = glib.Source.remove(timer); + } + + // Restart our timer + priv.tab_overview_focus_timer = glib.timeoutAdd( + 500, + tabOverviewFocusTimer, + self, + ); + } + + fn tabOverviewFocusTimer( + ud: ?*anyopaque, + ) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always note our timer is removed + self.private().tab_overview_focus_timer = null; + + // Get our currently active surface which should respect the newly + // selected tab. Grab focus. + const surface = self.getActiveSurface() orelse return 0; + surface.grabFocus(); + + // Remove the timer + return 0; + } + fn windowCloseRequest( _: *gtk.Window, self: *Self, ) callconv(.c) c_int { - // If our surface needs confirmation then we show confirmation. - // This will have to be expanded to a list when we have tabs - // or splits. - confirm: { - const surface = self.getActiveSurface() orelse break :confirm; - const core_surface = surface.core() orelse break :confirm; - if (!core_surface.needsConfirmQuit()) break :confirm; - + if (self.getNeedsConfirmQuit()) { // Show a confirmation dialog - const dialog: *CloseConfirmationDialog = .new(.app); + const dialog: *CloseConfirmationDialog = .new(.window); _ = CloseConfirmationDialog.signals.@"close-request".connect( dialog, *Self, @@ -430,6 +820,237 @@ pub const Window = extern struct { self.as(gtk.Window).destroy(); } + fn closeConfirmationCloseTab( + _: *CloseConfirmationDialog, + page: *adw.TabPage, + ) callconv(.c) void { + const tab_view = ext.getAncestor( + adw.TabView, + page.getChild().as(gtk.Widget), + ) orelse { + log.warn("close confirmation called for non-existent page", .{}); + return; + }; + tab_view.closePageFinish(page, @intFromBool(true)); + } + + fn closeConfirmationCancelTab( + _: *CloseConfirmationDialog, + page: *adw.TabPage, + ) callconv(.c) void { + const tab_view = ext.getAncestor( + adw.TabView, + page.getChild().as(gtk.Widget), + ) orelse { + log.warn("close confirmation called for non-existent page", .{}); + return; + }; + tab_view.closePageFinish(page, @intFromBool(false)); + } + + fn tabViewClosePage( + _: *adw.TabView, + page: *adw.TabPage, + self: *Self, + ) callconv(.c) c_int { + const priv = self.private(); + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse + return @intFromBool(false); + + // If the tab says it doesn't need confirmation then we go ahead + // and close immediately. + if (!tab.getNeedsConfirmQuit()) { + priv.tab_view.closePageFinish(page, @intFromBool(true)); + return @intFromBool(true); + } + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.tab); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *adw.TabPage, + closeConfirmationCloseTab, + page, + .{}, + ); + _ = CloseConfirmationDialog.signals.cancel.connect( + dialog, + *adw.TabPage, + closeConfirmationCancelTab, + page, + .{}, + ); + + // Show it + dialog.present(child); + return @intFromBool(true); + } + + fn tabViewSelectedPage( + _: *adw.TabView, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // Always reset our binding source in case we have no pages. + priv.tab_bindings.setSource(null); + + // Get our current page which MUST be a Tab object. + const page = priv.tab_view.getSelectedPage() orelse return; + const child = page.getChild(); + assert(gobject.ext.isA(child, Tab)); + + // Setup our binding group. This ensures things like the title + // are synced from the active tab. + priv.tab_bindings.setSource(child.as(gobject.Object)); + } + + fn tabViewPageAttached( + _: *adw.TabView, + page: *adw.TabPage, + _: c_int, + self: *Self, + ) callconv(.c) void { + // Get the attached page which must be a Tab object. + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse return; + + // Attach listeners for the tab. + _ = Tab.signals.@"close-request".connect( + tab, + *Self, + tabCloseRequest, + self, + .{}, + ); + + // Attach listeners for the surface. + // + // Interesting behavior here that was previously undocumented but + // I'm going to make it explicit here: we accept all the signals here + // (like toggle-fullscreen) regardless of whether the surface or tab + // is focused. At the time of writing this we have no API that could + // really trigger these that way but its theoretically possible. + // + // What is DEFINITELY possible is something like OSC52 triggering + // a clipboard-write signal on an unfocused tab/surface. We definitely + // want to show the user a notification about that but our notification + // right now is a toast that doesn't make it clear WHO used the + // clipboard. We probably want to change that in the future. + // + // I'm not sure how desirable all the above is, and we probably + // should be thoughtful about future signals here. But all of this + // behavior is consistent with macOS and the previous GTK apprt, + // but that behavior was all implicit and not documented, so here + // I am. + // + // TODO: When we have a split tree we'll want to attach to that. + const surface = tab.getActiveSurface(); + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = Surface.signals.@"clipboard-write".connect( + surface, + *Self, + surfaceClipboardWrite, + self, + .{}, + ); + _ = Surface.signals.@"toggle-fullscreen".connect( + surface, + *Self, + surfaceToggleFullscreen, + self, + .{}, + ); + _ = Surface.signals.@"toggle-maximize".connect( + surface, + *Self, + surfaceToggleMaximize, + self, + .{}, + ); + } + + fn tabViewPageDetached( + _: *adw.TabView, + page: *adw.TabPage, + _: c_int, + self: *Self, + ) callconv(.c) void { + // We need to get the tab to disconnect the signals. + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse return; + _ = gobject.signalHandlersDisconnectMatched( + tab.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + + // Remove all the signals that have this window as the userdata. + const surface = tab.getActiveSurface(); + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + + fn tabViewCreateWindow( + _: *adw.TabView, + _: *Self, + ) callconv(.c) *adw.TabView { + // Create a new window without creating a new tab. + const win = gobject.ext.newInstance( + Self, + .{ + .application = Application.default(), + }, + ); + + // We have to show it otherwise it'll just be hidden. + gtk.Window.present(win.as(gtk.Window)); + + // Get our tab view + return win.private().tab_view; + } + + fn tabCloseRequest( + tab: *Tab, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const page = priv.tab_view.getPage(tab.as(gtk.Widget)); + // TODO: connect close page handler to tab to check for confirmation + priv.tab_view.closePage(page); + } + + fn tabViewNPages( + _: *adw.TabView, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + if (priv.tab_view.getNPages() == 0) { + // If we have no pages left then we want to close window. + self.as(gtk.Window).close(); + } + } + fn surfaceClipboardWrite( _: *Surface, clipboard_type: apprt.Clipboard, @@ -454,15 +1075,22 @@ pub const Window = extern struct { } fn surfaceCloseRequest( - surface: *Surface, + _: *Surface, scope: *const Surface.CloseScope, self: *Self, ) callconv(.c) void { - // Todo - _ = scope; + switch (scope.*) { + // Handled directly by the tab. If the surface is the last + // surface then the tab will emit its own signal to request + // closing itself. + .surface => return, - assert(surface == self.private().surface); - self.as(gtk.Window).close(); + // Also handled directly by the tab. + .tab => return, + + // The only one we care about! + .window => self.as(gtk.Window).close(), + } } fn surfaceToggleFullscreen( @@ -545,6 +1173,14 @@ pub const Window = extern struct { self.as(gtk.Window).close(); } + fn actionCloseTab( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.close_tab); + } + fn actionNewWindow( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -553,6 +1189,14 @@ pub const Window = extern struct { self.performBindingAction(.new_window); } + fn actionNewTab( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.new_tab); + } + fn actionCopy( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -597,7 +1241,6 @@ pub const Window = extern struct { pub const Instance = Self; fn init(class: *Class) callconv(.C) void { - gobject.ext.ensureType(Surface); gobject.ext.ensureType(DebugWarning); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -611,22 +1254,34 @@ pub const Window = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, + properties.@"background-opaque".impl, properties.config.impl, properties.debug.impl, properties.@"headerbar-visible".impl, - properties.@"background-opaque".impl, + properties.@"tabs-autohide".impl, + properties.@"tabs-visible".impl, + properties.@"tabs-wide".impl, + properties.@"toolbar-style".impl, }); // Bindings - class.bindTemplateChildPrivate("surface", .{}); + class.bindTemplateChildPrivate("tab_overview", .{}); + class.bindTemplateChildPrivate("tab_bar", .{}); + class.bindTemplateChildPrivate("tab_view", .{}); + class.bindTemplateChildPrivate("toolbar", .{}); class.bindTemplateChildPrivate("toast_overlay", .{}); // Template Callbacks + class.bindTemplateCallback("new_tab", &btnNewTab); + class.bindTemplateCallback("overview_create_tab", &tabOverviewCreateTab); + class.bindTemplateCallback("overview_notify_open", &tabOverviewOpen); class.bindTemplateCallback("close_request", &windowCloseRequest); - class.bindTemplateCallback("surface_clipboard_write", &surfaceClipboardWrite); - class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); - class.bindTemplateCallback("surface_toggle_fullscreen", &surfaceToggleFullscreen); - class.bindTemplateCallback("surface_toggle_maximize", &surfaceToggleMaximize); + class.bindTemplateCallback("close_page", &tabViewClosePage); + class.bindTemplateCallback("page_attached", &tabViewPageAttached); + class.bindTemplateCallback("page_detached", &tabViewPageDetached); + class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow); + class.bindTemplateCallback("notify_n_pages", &tabViewNPages); + class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage); class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("notify_fullscreened", &propFullscreened); class.bindTemplateCallback("notify_maximized", &propMaximized); @@ -635,6 +1290,7 @@ pub const Window = extern struct { // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); } pub const as = C.Class.as; diff --git a/src/apprt/gtk-ng/ext.zig b/src/apprt/gtk-ng/ext.zig new file mode 100644 index 000000000..2a6ac8f6d --- /dev/null +++ b/src/apprt/gtk-ng/ext.zig @@ -0,0 +1,19 @@ +//! Extensions/helpers for GTK objects, following a similar naming +//! style to zig-gobject. These should, wherever possible, be Zig-friendly +//! wrappers around existing GTK functionality, rather than complex new +//! helpers. + +const std = @import("std"); +const assert = std.debug.assert; + +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +/// Wrapper around `gtk.Widget.getAncestor` to get the widget ancestor +/// of the given type `T`, or null if it doesn't exist. +pub fn getAncestor(comptime T: type, widget: *gtk.Widget) ?*T { + const ancestor_ = widget.getAncestor(gobject.ext.typeFor(T)); + const ancestor = ancestor_ orelse return null; + // We can assert the unwrap because getAncestor above + return gobject.ext.cast(T, ancestor).?; +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp new file mode 100644 index 000000000..476244576 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -0,0 +1,15 @@ +using Gtk 4.0; + +template $GhosttyTab: Box { + styles [ + "tab", + ] + + hexpand: true; + vexpand: true; + // A tab currently just contains a surface directly. When we introduce + // splits we probably want to replace this with the split widget type. + $GhosttySurface surface { + close-request => $surface_close_request(); + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index a7f5d82e0..07057142b 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -16,40 +16,80 @@ template $GhosttyWindow: Adw.ApplicationWindow { // GTK4 grabs F10 input by default to focus the menubar icon. We want // to disable this so that terminal programs can capture F10 (such as htop) handle-menubar-accel: false; - title: bind (template.active-surface as <$GhosttySurface>).title; - content: Box { - orientation: vertical; + content: Adw.TabOverview tab_overview { + create-tab => $overview_create_tab(); + notify::open => $overview_notify_open(); + enable-new-tab: true; + view: tab_view; - Adw.HeaderBar { - visible: bind template.headerbar-visible; + Adw.ToolbarView toolbar { + top-bar-style: bind template.toolbar-style; + bottom-bar-style: bind template.toolbar-style; - title-widget: Adw.WindowTitle { - title: bind (template.active-surface as <$GhosttySurface>).title; - }; + [top] + Adw.HeaderBar { + visible: bind template.headerbar-visible; - [end] - Gtk.Box { - Gtk.MenuButton { - notify::active => $notify_menu_active(); - icon-name: "open-menu-symbolic"; - menu-model: main_menu; - tooltip-text: _("Main Menu"); - can-focus: false; + title-widget: Adw.WindowTitle { + title: bind template.title; + }; + + [start] + Adw.SplitButton { + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + } + + [end] + Gtk.Box { + Gtk.ToggleButton { + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } } } - } - $GhosttyDebugWarning { - visible: bind template.debug; - } + [top] + Adw.TabBar tab_bar { + autohide: bind template.tabs-autohide; + expand-tabs: bind template.tabs-wide; + view: tab_view; + visible: bind template.tabs-visible; + } - Adw.ToastOverlay toast_overlay { - $GhosttySurface surface { - close-request => $surface_close_request(); - clipboard-write => $surface_clipboard_write(); - toggle-fullscreen => $surface_toggle_fullscreen(); - toggle-maximize => $surface_toggle_maximize(); + Box { + orientation: vertical; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + Adw.ToastOverlay toast_overlay { + Adw.TabView tab_view { + notify::n-pages => $notify_n_pages(); + notify::selected-page => $notify_selected_page(); + close-page => $close_page(); + page-attached => $page_attached(); + page-detached => $page_detached(); + create-window => $tab_create_window(); + shortcuts: none; + } + } } } }; diff --git a/valgrind.supp b/valgrind.supp index 3b074607d..040328648 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -135,13 +135,34 @@ ... fun:gsk_gpu_node_processor_process fun:gsk_gpu_frame_render - fun:gsk_gpu_renderer_render - fun:gsk_renderer_render - fun:gtk_widget_render - fun:surface_render ... } +{ + GDK GLArea + Memcheck:Leak + match-leak-kinds: possible + fun:*alloc + ... + fun:gdk_memory_texture_from_texture + fun:gdk_gl_texture_release + fun:delete_one_texture + fun:g_list_foreach + fun:g_list_free_full + fun:gtk_gl_area_unrealize + ... +} + +{ + GDK GLArea Snapshot + Memcheck:Leak + match-leak-kinds: definite + fun:*alloc + ... + fun:gtk_gl_area_snapshot + ... +} + { GSK GPU Rendering Memcheck:Leak @@ -351,6 +372,7 @@ Memcheck:Leak match-leak-kinds: possible fun:*alloc + ... fun:FcFontSet* ... fun:fc_thread_func