apprt/gtk-ng: bind a bunch on page-attach/detach

This commit is contained in:
Mitchell Hashimoto
2025-07-28 19:46:11 -07:00
parent fa45f971f4
commit 5279badd5b
3 changed files with 908 additions and 1 deletions

857
:w Normal file
View File

@ -0,0 +1,857 @@
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 Tab = @import("tab.zig").Tab;
const DebugWarning = @import("debug_warning.zig").DebugWarning;
const log = std.log.scoped(.gtk_ghostty_window);
pub const Window = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.ApplicationWindow;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttyWindow",
.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 debug = struct {
pub const name = "debug";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.nick = "Debug",
.blurb = "True if runtime safety checks are enabled.",
.default = build_config.is_debug,
.accessor = gobject.ext.typedAccessor(Self, bool, .{
.getter = struct {
pub fn getter(_: *Window) bool {
return build_config.is_debug;
}
}.getter,
}),
},
);
};
pub const @"headerbar-visible" = struct {
pub const name = "headerbar-visible";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.nick = "Headerbar Visible",
.blurb = "True if the headerbar is visible.",
.default = true,
.accessor = gobject.ext.typedAccessor(Self, bool, .{
.getter = Self.getHeaderbarVisible,
}),
},
);
};
pub const @"background-opaque" = struct {
pub const name = "background-opaque";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.nick = "Background Opaque",
.blurb = "True if the background should be opaque.",
.default = true,
.accessor = gobject.ext.typedAccessor(Self, bool, .{
.getter = Self.getBackgroundOpaque,
}),
},
);
};
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,
// Template bindings
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, .{
.application = app,
});
// Create our initial tab. This will trigger the selected-page
// signal handler which will setup the remainder of the bindings
// for this to all work.
const priv = self.private();
const tab = gobject.ext.newInstance(Tab, .{
.config = priv.config,
});
if (parent_) |p| tab.setParent(p);
_ = priv.tab_view.append(tab.as(gtk.Widget));
return self;
}
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();
}
// Add our dev CSS class if we're in debug mode.
if (comptime build_config.is_debug) {
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);
// Initialize our actions
self.initActionMap();
// We always sync our appearance at the end because loading our
// config and such can affect our bindings which ar setup initially
// in initTemplate.
self.syncAppearance();
// 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);
}
/// Setup our action map.
fn initActionMap(self: *Self) void {
const actions = .{
.{ "about", actionAbout, null },
.{ "close", actionClose, null },
.{ "new-window", actionNewWindow, null },
.{ "copy", actionCopy, null },
.{ "paste", actionPaste, null },
.{ "reset", actionReset, null },
.{ "clear", actionClear, null },
};
const action_map = self.as(gio.ActionMap);
inline for (actions) |entry| {
const action = gio.SimpleAction.new(
entry[0],
entry[2],
);
defer action.unref();
_ = gio.SimpleAction.signals.activate.connect(
action,
*Self,
entry[1],
self,
.{},
);
action_map.addAction(action.as(gio.Action));
}
}
/// 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 {
// TODO: CSD/SSD
// 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 {
const widget = self.as(gtk.Widget);
if (value)
widget.addCssClass(class.ptr)
else
widget.removeCssClass(class.ptr);
}
/// Perform a binding action on the window's active surface.
fn performBindingAction(
self: *Window,
action: input.Binding.Action,
) void {
const surface = self.getActiveSurface() orelse return;
const core_surface = surface.core() orelse return;
_ = core_surface.performBindingAction(action) catch |err| {
log.warn("error performing binding action error={}", .{err});
return;
};
}
/// Queue a simple text-based toast. All text-based toasts share the
/// same timeout for consistency.
///
// 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 {
const toast = adw.Toast.new(title);
toast.setTimeout(3);
self.private().toast_overlay.addToast(toast);
}
//---------------------------------------------------------------
// Properties
/// Get the currently active surface. See the "active-surface" property.
/// This does not ref the value.
fn getActiveSurface(self: *Self) ?*Surface {
const priv = self.private();
_ = priv;
return null;
}
fn getHeaderbarVisible(self: *Self) bool {
// TODO: CSD/SSD
// TODO: QuickTerminal
// If we're fullscreen we never show the header bar.
if (self.as(gtk.Window).isFullscreen() != 0) return false;
// The remainder needs a config
const config_obj = self.private().config orelse return true;
const config = config_obj.get();
// *Conditionally* disable the header bar when maximized,
// and gtk-titlebar-hide-when-maximized is set
if (self.as(gtk.Window).isMaximized() != 0 and
config.@"gtk-titlebar-hide-when-maximized")
{
return false;
}
return config.@"gtk-titlebar";
}
fn getBackgroundOpaque(self: *Self) bool {
const priv = self.private();
const config = (priv.config orelse return true).get();
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,
self: *Self,
) callconv(.c) void {
self.addToast(i18n._("Reloaded the configuration"));
self.syncAppearance();
}
fn propFullscreened(
_: *adw.ApplicationWindow,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.syncAppearance();
}
fn propMaximized(
_: *adw.ApplicationWindow,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.syncAppearance();
}
fn propMenuActive(
button: *gtk.MenuButton,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
// Debian 12 is stuck on GTK 4.8
if (!gtk_version.atLeast(4, 10, 0)) return;
// We only care if we're activating. If we're activating then
// we need to check the validity of our menu items.
const active = button.getActive() != 0;
if (!active) return;
const has_selection = selection: {
const surface = self.getActiveSurface() orelse
break :selection false;
const core_surface = surface.core() orelse
break :selection false;
break :selection core_surface.hasSelection();
};
const action_map: *gio.ActionMap = gobject.ext.cast(
gio.ActionMap,
self,
) orelse return;
const action: *gio.SimpleAction = gobject.ext.cast(
gio.SimpleAction,
action_map.lookupAction("copy") orelse return,
) orelse return;
action.setEnabled(@intFromBool(has_selection));
}
/// Add or remove "background" CSS class depending on if the background
/// should be opaque.
fn propBackgroundOpaque(
_: *adw.ApplicationWindow,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.toggleCssClass("background", self.getBackgroundOpaque());
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.C) void {
const priv = self.private();
if (priv.config) |v| {
v.unref();
priv.config = null;
}
priv.tab_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();
priv.tab_bindings.unref();
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
//---------------------------------------------------------------
// Signal handlers
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;
// Show a confirmation dialog
const dialog: *CloseConfirmationDialog = .new(.app);
_ = CloseConfirmationDialog.signals.@"close-request".connect(
dialog,
*Self,
closeConfirmationClose,
self,
.{},
);
// Show it
dialog.present(self.as(gtk.Widget));
return @intFromBool(true);
}
self.as(gtk.Window).destroy();
return @intFromBool(false);
}
fn closeConfirmationClose(
_: *CloseConfirmationDialog,
self: *Self,
) callconv(.c) void {
self.as(gtk.Window).destroy();
}
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;
_ = tab;
// Attach listeners for the
_ = self;
}
fn tabViewPageDetached(
_: *adw.TabView,
page: *adw.TabPage,
_: c_int,
self: *Self,
) callconv(.c) void {
_ = page;
_ = self;
}
fn surfaceClipboardWrite(
_: *Surface,
clipboard_type: apprt.Clipboard,
text: [*:0]const u8,
self: *Self,
) callconv(.c) void {
// We only toast for the standard clipboard.
if (clipboard_type != .standard) return;
// We only toast if configured to
const priv = self.private();
const config_obj = priv.config orelse return;
const config = config_obj.get();
if (!config.@"app-notifications".@"clipboard-copy") {
return;
}
if (text[0] != 0)
self.addToast(i18n._("Copied to clipboard"))
else
self.addToast(i18n._("Cleared clipboard"));
}
fn surfaceCloseRequest(
surface: *Surface,
scope: *const Surface.CloseScope,
self: *Self,
) callconv(.c) void {
// Todo
_ = scope;
_ = surface;
self.as(gtk.Window).close();
}
fn surfaceToggleFullscreen(
surface: *Surface,
self: *Self,
) callconv(.c) void {
_ = surface;
if (self.as(gtk.Window).isFullscreen() != 0) {
self.as(gtk.Window).unfullscreen();
} else {
self.as(gtk.Window).fullscreen();
}
// We react to the changes in the propFullscreen callback
}
fn surfaceToggleMaximize(
surface: *Surface,
self: *Self,
) callconv(.c) void {
_ = surface;
if (self.as(gtk.Window).isMaximized() != 0) {
self.as(gtk.Window).unmaximize();
} else {
self.as(gtk.Window).maximize();
}
// We react to the changes in the propMaximized callback
}
fn actionAbout(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const name = "Ghostty";
const icon = "com.mitchellh.ghostty";
const website = "https://ghostty.org";
if (adw_version.supportsDialogs()) {
adw.showAboutDialog(
self.as(gtk.Widget),
"application-name",
name,
"developer-name",
i18n._("Ghostty Developers"),
"application-icon",
icon,
"version",
build_config.version_string.ptr,
"issue-url",
"https://github.com/ghostty-org/ghostty/issues",
"website",
website,
@as(?*anyopaque, null),
);
} else {
gtk.showAboutDialog(
self.as(gtk.Window),
"program-name",
name,
"logo-icon-name",
icon,
"title",
i18n._("About Ghostty"),
"version",
build_config.version_string.ptr,
"website",
website,
@as(?*anyopaque, null),
);
}
}
fn actionClose(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
self.as(gtk.Window).close();
}
fn actionNewWindow(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.new_window);
}
fn actionCopy(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.copy_to_clipboard);
}
fn actionPaste(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.paste_from_clipboard);
}
fn actionReset(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.reset);
}
fn actionClear(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.clear_screen);
}
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(DebugWarning);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 5,
.name = "window",
}),
);
// Properties
gobject.ext.registerProperties(class, &.{
properties.@"active-surface".impl,
properties.@"background-opaque".impl,
properties.config.impl,
properties.debug.impl,
properties.@"headerbar-visible".impl,
properties.@"tabs-autohide".impl,
properties.@"tabs-visible".impl,
properties.@"tabs-wide".impl,
properties.@"toolbar-style".impl,
});
// Bindings
class.bindTemplateChildPrivate("tab_bar", .{});
class.bindTemplateChildPrivate("tab_view", .{});
class.bindTemplateChildPrivate("toolbar", .{});
class.bindTemplateChildPrivate("toast_overlay", .{});
// Template Callbacks
class.bindTemplateCallback("close_request", &windowCloseRequest);
class.bindTemplateCallback("selected_page", &tabViewSelectedPage);
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("notify_config", &propConfig);
class.bindTemplateCallback("notify_fullscreened", &propFullscreened);
class.bindTemplateCallback("notify_maximized", &propMaximized);
class.bindTemplateCallback("notify_menu_active", &propMenuActive);
class.bindTemplateCallback("notify_background_opaque", &propBackgroundOpaque);
// 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

@ -253,7 +253,8 @@ pub const Window = extern struct {
self.as(gtk.Widget).addCssClass("devel");
}
// Setup some of our objects that are never null
// 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", .{});
@ -610,6 +611,51 @@ pub const Window = extern struct {
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 surface.
// 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,
.{},
);
}
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;
// 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 surfaceClipboardWrite(
_: *Surface,
clipboard_type: apprt.Clipboard,
@ -809,6 +855,8 @@ pub const Window = extern struct {
// Template Callbacks
class.bindTemplateCallback("close_request", &windowCloseRequest);
class.bindTemplateCallback("selected_page", &tabViewSelectedPage);
class.bindTemplateCallback("page_attached", &tabViewPageAttached);
class.bindTemplateCallback("page_detached", &tabViewPageDetached);
class.bindTemplateCallback("surface_clipboard_write", &surfaceClipboardWrite);
class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest);
class.bindTemplateCallback("surface_toggle_fullscreen", &surfaceToggleFullscreen);

View File

@ -79,6 +79,8 @@ template $GhosttyWindow: Adw.ApplicationWindow {
Adw.ToastOverlay toast_overlay {
Adw.TabView tab_view {
notify::selected-page => $selected_page();
page-attached => $page_attached();
page-detached => $page_detached();
}
}
}