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 ext = @import("../ext.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 SplitTree = @import("split_tree.zig").SplitTree; 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 surface 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, .{ .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, .{ .accessor = C.privateObjFieldAccessor("config"), }, ); }; pub const @"split-tree" = struct { pub const name = "split-tree"; const impl = gobject.ext.defineProperty( name, Self, ?*SplitTree, .{ .accessor = gobject.ext.typedAccessor( Self, ?*SplitTree, .{ .getter = getSplitTree, }, ), }, ); }; pub const @"surface-tree" = struct { pub const name = "surface-tree"; const impl = gobject.ext.defineProperty( name, Self, ?*Surface.Tree, .{ .accessor = gobject.ext.typedAccessor( Self, ?*Surface.Tree, .{ .getter = getSurfaceTree, }, ), }, ); }; pub const tooltip = struct { pub const name = "tooltip"; const impl = gobject.ext.defineProperty( name, Self, ?[:0]const u8, .{ .default = null, .accessor = C.privateStringFieldAccessor("tooltip"), }, ); }; pub const title = struct { pub const name = "title"; const impl = gobject.ext.defineProperty( name, Self, ?[:0]const u8, .{ .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 of this tab. This is usually bound to the active surface. title: ?[:0]const u8 = null, /// The tooltip of this tab. This is usually bound to the active surface. tooltip: ?[:0]const u8 = null, // Template bindings split_tree: *SplitTree, 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 { if (self.getActiveSurface()) |surface| { surface.setParent(parent); } } fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Init our actions self.initActionMap(); // 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(); } // Create our initial surface in the split tree. priv.split_tree.newSplit(.right, null) catch |err| switch (err) { error.OutOfMemory => { // TODO: We should make our "no surfaces" state more aesthetically // pleasing and show something like an "Oops, something went wrong" // message. For now, this is incredibly unlikely. @panic("oom"); }, }; } fn initActionMap(self: *Self) void { const s_param_type = glib.ext.VariantType.newFor([:0]const u8); defer s_param_type.free(); const actions = [_]ext.actions.Action(Self){ .init("close", actionClose, s_param_type), .init("ring-bell", actionRingBell, null), }; _ = ext.actions.addAsGroup(Self, self, "tab", &actions); } //--------------------------------------------------------------- // Properties /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. pub fn getActiveSurface(self: *Self) ?*Surface { return self.getSplitTree().getActiveSurface(); } /// Get the surface tree of this tab. pub fn getSurfaceTree(self: *Self) ?*Surface.Tree { const priv = self.private(); return priv.split_tree.getTree(); } /// Get the split tree widget that is in this tab. pub fn getSplitTree(self: *Self) *SplitTree { const priv = self.private(); return priv.split_tree; } /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { const tree = self.getSplitTree(); return tree.getNeedsConfirmQuit(); } /// Get the tab page holding this tab, if any. fn getTabPage(self: *Self) ?*adw.TabPage { const tab_view = ext.getAncestor( adw.TabView, self.as(gtk.Widget), ) orelse return null; return tab_view.getPage(self.as(gtk.Widget)); } //--------------------------------------------------------------- // Virtual methods fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); priv.config = 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.tooltip) |v| { glib.free(@ptrCast(@constCast(v))); priv.tooltip = null; } if (priv.title) |v| { glib.free(@ptrCast(@constCast(v))); priv.title = null; } gobject.Object.virtual_methods.finalize.call( Class.parent, self.as(Parent), ); } //--------------------------------------------------------------- // Signal handlers fn propSplitTree( _: *SplitTree, _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); // If our tree is empty we close the tab. const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty; if (tree.isEmpty()) { signals.@"close-request".impl.emit( self, null, .{}, null, ); return; } } fn propActiveSurface( _: *SplitTree, _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } fn actionClose( _: *gio.SimpleAction, param_: ?*glib.Variant, self: *Self, ) callconv(.c) void { const param = param_ orelse { log.warn("tab.close-tab called without a parameter", .{}); return; }; var str: ?[*:0]const u8 = null; param.get("&s", &str); const tab_view = ext.getAncestor( adw.TabView, self.as(gtk.Widget), ) orelse return; const page = tab_view.getPage(self.as(gtk.Widget)); const mode = std.meta.stringToEnum( apprt.action.CloseTabMode, std.mem.span( str orelse { log.warn("invalid mode provided to tab.close-tab", .{}); return; }, ), ) orelse { // Need to be defensive here since actions can be triggered externally. log.warn("invalid mode provided to tab.close-tab: {s}", .{str.?}); return; }; // Delegate to our parent to handle this, since this will emit // a close-page signal that the parent can intercept. switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), } } fn actionRingBell( _: *gio.SimpleAction, _: ?*glib.Variant, self: *Self, ) callconv(.c) void { // Future note: I actually don't like this logic living here at all. // I think a better approach will be for the ring bell action to // specify its sending surface and then do all this in the window. // If the page is selected already we don't mark it as needing // attention. We only want to mark unfocused pages. This will then // clear when the page is selected. const page = self.getTabPage() orelse return; if (page.getSelected() != 0) return; page.setNeedsAttention(@intFromBool(true)); } fn closureComputedTitle( _: *Self, config_: ?*Config, terminal_: ?[*:0]const u8, override_: ?[*:0]const u8, zoomed_: c_int, bell_ringing_: c_int, _: *gobject.ParamSpec, ) callconv(.c) ?[*:0]const u8 { const zoomed = zoomed_ != 0; const bell_ringing = bell_ringing_ != 0; // Our plain title is the overridden title if it exists, otherwise // the terminal title if it exists, otherwise a default string. const plain = plain: { const default = "Ghostty"; const config_title: ?[*:0]const u8 = title: { const config = config_ orelse break :title null; break :title config.get().title orelse null; }; const plain = override_ orelse terminal_ orelse config_title orelse break :plain default; break :plain std.mem.span(plain); }; // We don't need a config in every case, but if we don't have a config // let's just assume something went terribly wrong and use our // default title. Its easier then guarding on the config existing // in every case for something so unlikely. const config = if (config_) |v| v.get() else { log.warn("config unavailable for computed title, likely bug", .{}); return glib.ext.dupeZ(u8, plain); }; // Use an allocator to build up our string as we write it. var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); defer buf.deinit(); // If our bell is ringing, then we prefix the bell icon to the title. if (bell_ringing and config.@"bell-features".title) { buf.writer.writeAll("🔔 ") catch {}; } // If we're zoomed, prefix with the magnifying glass emoji. if (zoomed) { buf.writer.writeAll("🔍 ") catch {}; } buf.writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain); return glib.ext.dupeZ(u8, buf.written()); } 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(SplitTree); 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.@"split-tree".impl, properties.@"surface-tree".impl, properties.title.impl, properties.tooltip.impl, }); // Bindings class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks class.bindTemplateCallback("computed_title", &closureComputedTitle); class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); // 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; }; };