apprt/gtk-ng: tabs are back! (#8098)

This brings back all tabbing behaviors.

I ran through create/close tabs and windows with Valgrind and everything
ran clean.

TODO:

- [x] goto tab keybinding
- [x] move tab
- [x] toggle tab overview (binding only, UI works!)
- [x] create window action to pull tab out into a window
This commit is contained in:
Mitchell Hashimoto
2025-07-30 07:13:38 -07:00
committed by GitHub
10 changed files with 1261 additions and 87 deletions

View File

@ -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

View File

@ -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

View File

@ -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."),
};
}

View File

@ -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

View File

@ -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;
};
};

View File

@ -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;

19
src/apprt/gtk-ng/ext.zig Normal file
View File

@ -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).?;
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}
}
};

View File

@ -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