mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-09 03:16:33 +00:00

Signal handlers are connected to surface objects in two spots - when a tab is added to a page and when the split tree changes. This resulted in duplicate signal handlers being added for each surface. This was most noticeable when copying the selection to the clipboard - you would see two "Copied to clipboard" toasts. Ensure that there is only one signal handler by removing any old ones before adding the new ones.
2003 lines
65 KiB
Zig
2003 lines
65 KiB
Zig
const std = @import("std");
|
|
const build_config = @import("../../../build_config.zig");
|
|
const assert = std.debug.assert;
|
|
const adw = @import("adw");
|
|
const gdk = @import("gdk");
|
|
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 configpkg = @import("../../../config.zig");
|
|
const TitlebarStyle = configpkg.Config.GtkTitlebarStyle;
|
|
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 winprotopkg = @import("../winproto.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 SplitTree = @import("split_tree.zig").SplitTree;
|
|
const Surface = @import("surface.zig").Surface;
|
|
const Tab = @import("tab.zig").Tab;
|
|
const DebugWarning = @import("debug_warning.zig").DebugWarning;
|
|
const CommandPalette = @import("command_palette.zig").CommandPalette;
|
|
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
|
|
const WeakRef = @import("../weak_ref.zig").WeakRef;
|
|
|
|
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,
|
|
.{
|
|
.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 debug = struct {
|
|
pub const name = "debug";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
bool,
|
|
.{
|
|
.default = build_config.is_debug,
|
|
.accessor = gobject.ext.typedAccessor(Self, bool, .{
|
|
.getter = struct {
|
|
pub fn getter(_: *Self) bool {
|
|
return build_config.is_debug;
|
|
}
|
|
}.getter,
|
|
}),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"titlebar-style" = struct {
|
|
pub const name = "titlebar-style";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
TitlebarStyle,
|
|
.{
|
|
.default = .native,
|
|
.accessor = gobject.ext.typedAccessor(
|
|
Self,
|
|
TitlebarStyle,
|
|
.{
|
|
.getter = Self.getTitlebarStyle,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"headerbar-visible" = struct {
|
|
pub const name = "headerbar-visible";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
bool,
|
|
.{
|
|
.default = true,
|
|
.accessor = gobject.ext.typedAccessor(Self, bool, .{
|
|
.getter = Self.getHeaderbarVisible,
|
|
}),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"quick-terminal" = struct {
|
|
pub const name = "quick-terminal";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
bool,
|
|
.{
|
|
.default = true,
|
|
.accessor = gobject.ext.privateFieldAccessor(
|
|
Self,
|
|
Private,
|
|
&Private.offset,
|
|
"quick_terminal",
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"tabs-autohide" = struct {
|
|
pub const name = "tabs-autohide";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
bool,
|
|
.{
|
|
.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,
|
|
.{
|
|
.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,
|
|
.{
|
|
.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,
|
|
.{
|
|
.default = .raised,
|
|
.accessor = gobject.ext.typedAccessor(
|
|
Self,
|
|
adw.ToolbarStyle,
|
|
.{
|
|
.getter = Self.getToolbarStyle,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
};
|
|
};
|
|
|
|
const Private = struct {
|
|
/// Whether this window is a quick terminal. If it is then it
|
|
/// behaves slightly differently under certain scenarios.
|
|
quick_terminal: bool = false,
|
|
|
|
/// The window decoration override. If this is not set then we'll
|
|
/// inherit whatever the config has. This allows overriding the
|
|
/// config on a per-window basis.
|
|
window_decoration: ?configpkg.WindowDecoration = null,
|
|
|
|
/// Binding group for our active tab.
|
|
tab_bindings: *gobject.BindingGroup,
|
|
|
|
/// The configuration that this surface is using.
|
|
config: ?*Config = null,
|
|
|
|
/// State and logic for windowing protocol for a window.
|
|
winproto: winprotopkg.Window,
|
|
|
|
/// Kind of hacky to have this but this lets us know if we've
|
|
/// initialized any single surface yet. We need this because we
|
|
/// gate default size on this so that we don't resize the window
|
|
/// after surfaces already exist.
|
|
///
|
|
/// I think long term we can probably get rid of this by implementing
|
|
/// a property or method that gets us all the surfaces in all the
|
|
/// tabs and checking if we have zero or one that isn't initialized.
|
|
///
|
|
/// For now, this logic is more similar to our legacy GTK side.
|
|
surface_init: bool = false,
|
|
|
|
/// See tabOverviewOpen for why we have this.
|
|
tab_overview_focus_timer: ?c_uint = null,
|
|
|
|
/// A weak reference to a command palette.
|
|
command_palette: WeakRef(CommandPalette) = .empty,
|
|
|
|
// Template bindings
|
|
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) *Self {
|
|
return gobject.ext.newInstance(Self, .{
|
|
.application = app,
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// We initialize our windowing protocol to none because we can't
|
|
// actually initialize this until we get realized.
|
|
priv.winproto = .none;
|
|
|
|
// 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();
|
|
|
|
// Start states based on config.
|
|
if (priv.config) |config_obj| {
|
|
const config = config_obj.get();
|
|
if (config.maximize) self.as(gtk.Window).maximize();
|
|
if (config.fullscreen) self.as(gtk.Window).fullscreen();
|
|
|
|
// If we have an explicit title set, we set that immediately
|
|
// so that any applications inspecting the window states see
|
|
// an immediate title set when the window appears, rather than
|
|
// waiting possibly a few event loop ticks for it to sync from
|
|
// the surface.
|
|
if (config.title) |v| self.as(gtk.Window).setTitle(v);
|
|
}
|
|
|
|
// We always sync our appearance at the end because loading our
|
|
// config and such can affect our bindings which are 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 s_variant_type = glib.ext.VariantType.newFor([:0]const u8);
|
|
defer s_variant_type.free();
|
|
|
|
const actions = [_]ext.actions.Action(Self){
|
|
.init("about", actionAbout, null),
|
|
.init("close", actionClose, null),
|
|
.init("close-tab", actionCloseTab, s_variant_type),
|
|
.init("new-tab", actionNewTab, null),
|
|
.init("new-window", actionNewWindow, null),
|
|
.init("ring-bell", actionRingBell, null),
|
|
.init("split-right", actionSplitRight, null),
|
|
.init("split-left", actionSplitLeft, null),
|
|
.init("split-up", actionSplitUp, null),
|
|
.init("split-down", actionSplitDown, null),
|
|
.init("copy", actionCopy, null),
|
|
.init("paste", actionPaste, null),
|
|
.init("reset", actionReset, null),
|
|
.init("clear", actionClear, null),
|
|
// TODO: accept the surface that toggled the command palette
|
|
.init("toggle-command-palette", actionToggleCommandPalette, null),
|
|
.init("toggle-inspector", actionToggleInspector, null),
|
|
};
|
|
|
|
ext.actions.add(Self, self, &actions);
|
|
}
|
|
|
|
/// Winproto backend for this window.
|
|
pub fn winproto(self: *Self) *winprotopkg.Window {
|
|
return &self.private().winproto;
|
|
}
|
|
|
|
/// 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 },
|
|
);
|
|
_ = tab.as(gobject.Object).bindProperty(
|
|
"tooltip",
|
|
page.as(gobject.Object),
|
|
"tooltip",
|
|
.{ .sync_create = true },
|
|
);
|
|
|
|
// Bind signals
|
|
const split_tree = tab.getSplitTree();
|
|
_ = SplitTree.signals.changed.connect(
|
|
split_tree,
|
|
*Self,
|
|
tabSplitTreeChanged,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Run an initial notification for the surface tree so we can setup
|
|
// initial state.
|
|
tabSplitTreeChanged(
|
|
split_tree,
|
|
null,
|
|
split_tree.getTree(),
|
|
self,
|
|
);
|
|
|
|
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));
|
|
}
|
|
|
|
/// Toggle the visible property.
|
|
pub fn toggleVisibility(self: *Self) void {
|
|
const widget = self.as(gtk.Widget);
|
|
widget.setVisible(@intFromBool(widget.isVisible() == 0));
|
|
}
|
|
|
|
/// 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: *Self) void {
|
|
const priv = self.private();
|
|
const widget = self.as(gtk.Widget);
|
|
|
|
// Toggle style classes based on whether we're using CSDs or SSDs.
|
|
//
|
|
// These classes are defined in the gtk.Window documentation:
|
|
// https://docs.gtk.org/gtk4/class.Window.html#css-nodes.
|
|
{
|
|
// Reset all style classes first
|
|
inline for (&.{
|
|
"ssd",
|
|
"csd",
|
|
"solid-csd",
|
|
"no-border-radius",
|
|
}) |class|
|
|
widget.removeCssClass(class);
|
|
|
|
const csd_enabled = priv.winproto.clientSideDecorationEnabled();
|
|
self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled));
|
|
|
|
if (csd_enabled) {
|
|
const display = widget.getDisplay();
|
|
|
|
// We do the exact same check GTK is doing internally and toggle
|
|
// either the `csd` or `solid-csd` style, based on whether the user's
|
|
// window manager is deemed _non-compositing_.
|
|
//
|
|
// In practice this only impacts users of traditional X11 window
|
|
// managers (e.g. i3, dwm, awesomewm, etc.) and not X11 desktop
|
|
// environments or Wayland compositors/DEs.
|
|
if (display.isRgba() != 0 and display.isComposited() != 0) {
|
|
widget.addCssClass("csd");
|
|
} else {
|
|
widget.addCssClass("solid-csd");
|
|
}
|
|
} else {
|
|
widget.addCssClass("ssd");
|
|
// Fix any artifacting that may occur in window corners.
|
|
widget.addCssClass("no-border-radius");
|
|
}
|
|
}
|
|
|
|
// Trigger all our dynamic properties that depend on the config.
|
|
inline for (&.{
|
|
"headerbar-visible",
|
|
"tabs-autohide",
|
|
"tabs-visible",
|
|
"tabs-wide",
|
|
"toolbar-style",
|
|
"titlebar-style",
|
|
}) |key| {
|
|
self.as(gobject.Object).notifyByPspec(
|
|
@field(properties, key).impl.param_spec,
|
|
);
|
|
}
|
|
|
|
// Remainder uses the config
|
|
const config = if (priv.config) |v| v.get() else return;
|
|
|
|
// Only add a solid background if we're opaque.
|
|
self.toggleCssClass(
|
|
"background",
|
|
config.@"background-opacity" >= 1,
|
|
);
|
|
|
|
// Apply class to color headerbar if window-theme is set to `ghostty` and
|
|
// GTK version is before 4.16. The conditional is because above 4.16
|
|
// we use GTK CSS color variables.
|
|
self.toggleCssClass(
|
|
"window-theme-ghostty",
|
|
!gtk_version.atLeast(4, 16, 0) and
|
|
config.@"window-theme" == .ghostty,
|
|
);
|
|
|
|
// 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)),
|
|
}
|
|
|
|
// Do our window-protocol specific appearance sync.
|
|
priv.winproto.syncAppearance() catch |err| {
|
|
log.warn("failed to sync winproto appearance error={}", .{err});
|
|
};
|
|
}
|
|
|
|
/// Sync the state of any actions on this window.
|
|
fn syncActions(self: *Self) void {
|
|
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));
|
|
}
|
|
|
|
fn toggleCssClass(self: *Self, 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: *Self,
|
|
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: *Self, title: [*:0]const u8) void {
|
|
const toast = adw.Toast.new(title);
|
|
toast.setTimeout(3);
|
|
self.private().toast_overlay.addToast(toast);
|
|
}
|
|
|
|
fn connectSurfaceHandlers(
|
|
self: *Self,
|
|
tree: *const Surface.Tree,
|
|
) void {
|
|
const priv = self.private();
|
|
var it = tree.iterator();
|
|
while (it.next()) |entry| {
|
|
const surface = entry.view;
|
|
// Before adding any new signal handlers, disconnect any that we may
|
|
// have added before. Otherwise we may get multiple handlers for the
|
|
// same signal.
|
|
_ = gobject.signalHandlersDisconnectMatched(
|
|
surface.as(gobject.Object),
|
|
.{ .data = true },
|
|
0,
|
|
0,
|
|
null,
|
|
null,
|
|
self,
|
|
);
|
|
|
|
_ = Surface.signals.@"present-request".connect(
|
|
surface,
|
|
*Self,
|
|
surfacePresentRequest,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = Surface.signals.@"clipboard-write".connect(
|
|
surface,
|
|
*Self,
|
|
surfaceClipboardWrite,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = Surface.signals.menu.connect(
|
|
surface,
|
|
*Self,
|
|
surfaceMenu,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = Surface.signals.@"toggle-fullscreen".connect(
|
|
surface,
|
|
*Self,
|
|
surfaceToggleFullscreen,
|
|
self,
|
|
.{},
|
|
);
|
|
_ = Surface.signals.@"toggle-maximize".connect(
|
|
surface,
|
|
*Self,
|
|
surfaceToggleMaximize,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// If we've never had a surface initialize yet, then we register
|
|
// this signal. Its theoretically possible to launch multiple surfaces
|
|
// before init so we could register this on multiple and that is not
|
|
// a problem because we'll check the flag again in each handler.
|
|
if (!priv.surface_init) {
|
|
_ = Surface.signals.init.connect(
|
|
surface,
|
|
*Self,
|
|
surfaceInit,
|
|
self,
|
|
.{},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Disconnect all the surface handlers for the given tree. This should
|
|
/// be called whenever a tree is no longer present in the window, e.g.
|
|
/// when a tab is detached or the tree changes.
|
|
fn disconnectSurfaceHandlers(
|
|
self: *Self,
|
|
tree: *const Surface.Tree,
|
|
) void {
|
|
var it = tree.iterator();
|
|
while (it.next()) |entry| {
|
|
const surface = entry.view;
|
|
_ = gobject.signalHandlersDisconnectMatched(
|
|
surface.as(gobject.Object),
|
|
.{ .data = true },
|
|
0,
|
|
0,
|
|
null,
|
|
null,
|
|
self,
|
|
);
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Properties
|
|
|
|
/// Whether this terminal is a quick terminal or not.
|
|
pub fn isQuickTerminal(self: *Self) bool {
|
|
return self.private().quick_terminal;
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// Returns the configuration for this window. The reference count
|
|
/// is not increased.
|
|
pub fn getConfig(self: *Self) ?*Config {
|
|
return self.private().config;
|
|
}
|
|
|
|
/// Get the current window decoration value for this window.
|
|
pub fn getWindowDecoration(self: *Self) configpkg.WindowDecoration {
|
|
const priv = self.private();
|
|
if (priv.window_decoration) |v| return v;
|
|
if (priv.config) |v| return v.get().@"window-decoration";
|
|
return .auto;
|
|
}
|
|
|
|
/// Toggle the window decorations for this window.
|
|
pub fn toggleWindowDecorations(self: *Self) void {
|
|
const priv = self.private();
|
|
|
|
if (priv.window_decoration) |_| {
|
|
// Unset any previously set window decoration settings
|
|
self.setWindowDecoration(null);
|
|
return;
|
|
}
|
|
|
|
const config = if (priv.config) |v| v.get() else return;
|
|
self.setWindowDecoration(switch (config.@"window-decoration") {
|
|
// Use auto when the decoration is initially none
|
|
.none => .auto,
|
|
|
|
// Anything non-none to none
|
|
.auto, .client, .server => .none,
|
|
});
|
|
}
|
|
|
|
/// Set the window decoration override for this window. If this is null,
|
|
/// then we'll revert back to the configuration's default.
|
|
fn setWindowDecoration(
|
|
self: *Self,
|
|
new_: ?configpkg.WindowDecoration,
|
|
) void {
|
|
const priv = self.private();
|
|
priv.window_decoration = new_;
|
|
self.syncAppearance();
|
|
}
|
|
|
|
/// Get the currently selected tab as a Tab object.
|
|
fn getSelectedTab(self: *Self) ?*Tab {
|
|
const priv = self.private();
|
|
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 isFullscreen(self: *Window) bool {
|
|
return self.as(gtk.Window).isFullscreen() != 0;
|
|
}
|
|
|
|
fn isMaximized(self: *Window) bool {
|
|
return self.as(gtk.Window).isMaximized() != 0;
|
|
}
|
|
|
|
fn getHeaderbarVisible(self: *Self) bool {
|
|
const priv = self.private();
|
|
|
|
// Never display the header bar when CSDs are disabled.
|
|
const csd_enabled = priv.winproto.clientSideDecorationEnabled();
|
|
if (!csd_enabled) return false;
|
|
|
|
// Never display the header bar as a quick terminal.
|
|
if (priv.quick_terminal) return false;
|
|
|
|
// If we're fullscreen we never show the header bar.
|
|
if (self.isFullscreen()) 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.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") {
|
|
return false;
|
|
}
|
|
|
|
return switch (config.@"gtk-titlebar-style") {
|
|
// If the titlebar style is tabs never show the titlebar.
|
|
.tabs => false,
|
|
|
|
// If the titlebar style is native show the titlebar if configured
|
|
// to do so.
|
|
.native => config.@"gtk-titlebar",
|
|
};
|
|
}
|
|
|
|
fn getTabsAutohide(self: *Self) bool {
|
|
const priv = self.private();
|
|
const config = if (priv.config) |v| v.get() else return true;
|
|
|
|
return switch (config.@"gtk-titlebar-style") {
|
|
// If the titlebar style is tabs we cannot autohide.
|
|
.tabs => false,
|
|
|
|
.native => 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;
|
|
|
|
switch (config.@"gtk-titlebar-style") {
|
|
.tabs => {
|
|
// *Conditionally* disable the tab bar when maximized, the titlebar
|
|
// style is tabs, and gtk-titlebar-hide-when-maximized is set.
|
|
if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") return false;
|
|
|
|
// If the titlebar style is tabs the tab bar must always be visible.
|
|
return true;
|
|
},
|
|
.native => {
|
|
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 getTitlebarStyle(self: *Self) TitlebarStyle {
|
|
const priv = self.private();
|
|
const config = if (priv.config) |v| v.get() else return .native;
|
|
return config.@"gtk-titlebar-style";
|
|
}
|
|
|
|
fn propConfig(
|
|
_: *adw.ApplicationWindow,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const priv = self.private();
|
|
if (priv.config) |config_obj| {
|
|
const config = config_obj.get();
|
|
if (config.@"app-notifications".@"config-reload") {
|
|
self.addToast(i18n._("Reloaded the configuration"));
|
|
}
|
|
}
|
|
|
|
self.syncAppearance();
|
|
}
|
|
|
|
fn propGdkSurfaceHeight(
|
|
_: *gdk.Surface,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// X11 needs to fix blurring on resize, but winproto implementations
|
|
// could do anything.
|
|
self.private().winproto.resizeEvent() catch |err| {
|
|
log.warn(
|
|
"winproto resize event failed error={}",
|
|
.{err},
|
|
);
|
|
};
|
|
}
|
|
|
|
fn propIsActive(
|
|
_: *gtk.Window,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// Don't change urgency if we're not the active window.
|
|
if (self.as(gtk.Window).isActive() == 0) return;
|
|
|
|
self.winproto().setUrgent(false) catch |err| {
|
|
log.warn(
|
|
"winproto failed to reset urgency={}",
|
|
.{err},
|
|
);
|
|
};
|
|
}
|
|
|
|
fn propGdkSurfaceWidth(
|
|
_: *gdk.Surface,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// X11 needs to fix blurring on resize, but winproto implementations
|
|
// could do anything.
|
|
self.private().winproto.resizeEvent() catch |err| {
|
|
log.warn(
|
|
"winproto resize event failed error={}",
|
|
.{err},
|
|
);
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
self.syncActions();
|
|
}
|
|
|
|
fn propQuickTerminal(
|
|
_: *adw.ApplicationWindow,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const priv = self.private();
|
|
if (priv.surface_init) {
|
|
log.warn("quick terminal property can't be changed after surfaces have been initialized", .{});
|
|
return;
|
|
}
|
|
|
|
if (priv.quick_terminal) {
|
|
// Initialize the quick terminal at the app-layer
|
|
Application.default().winproto().initQuickTerminal(self) catch |err| {
|
|
log.warn("failed to initialize quick terminal error={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
fn propScaleFactor(
|
|
_: *adw.ApplicationWindow,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// On some platforms (namely X11) we need to refresh our appearance when
|
|
// the scale factor changes. In theory this could be more fine-grained as
|
|
// a full refresh could be expensive, but a) this *should* be rare, and
|
|
// b) quite noticeable visual bugs would occur if this is not present.
|
|
self.private().winproto.syncAppearance() catch |err| {
|
|
log.warn(
|
|
"failed to sync appearance after scale factor has been updated={}",
|
|
.{err},
|
|
);
|
|
return;
|
|
};
|
|
}
|
|
|
|
fn closureTitlebarStyleIsTab(
|
|
_: *Self,
|
|
value: TitlebarStyle,
|
|
) callconv(.c) c_int {
|
|
return @intFromBool(switch (value) {
|
|
.native => false,
|
|
.tabs => true,
|
|
});
|
|
}
|
|
|
|
fn closureSubtitle(
|
|
_: *Self,
|
|
config_: ?*Config,
|
|
pwd_: ?[*:0]const u8,
|
|
) callconv(.c) ?[*:0]const u8 {
|
|
const config = if (config_) |v| v.get() else return null;
|
|
return switch (config.@"window-subtitle") {
|
|
.false => null,
|
|
.@"working-directory" => pwd: {
|
|
const pwd = pwd_ orelse return null;
|
|
break :pwd glib.ext.dupeZ(u8, std.mem.span(pwd));
|
|
},
|
|
};
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Virtual methods
|
|
|
|
fn dispose(self: *Self) callconv(.c) void {
|
|
const priv = self.private();
|
|
|
|
priv.command_palette.set(null);
|
|
|
|
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();
|
|
priv.winproto.deinit(Application.default().allocator());
|
|
|
|
gobject.Object.virtual_methods.finalize.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Signal handlers
|
|
|
|
fn windowRealize(_: *gtk.Widget, self: *Window) callconv(.c) void {
|
|
const app = Application.default();
|
|
|
|
// Initialize our window protocol logic
|
|
if (winprotopkg.Window.init(
|
|
app.allocator(),
|
|
app.winproto(),
|
|
self,
|
|
)) |wp| {
|
|
self.private().winproto = wp;
|
|
} else |err| {
|
|
log.warn("failed to initialize window protocol error={}", .{err});
|
|
return;
|
|
}
|
|
|
|
// We need to setup resize notifications on our surface,
|
|
// which is only available after the window had been realized.
|
|
if (self.as(gtk.Native).getSurface()) |gdk_surface| {
|
|
_ = gobject.Object.signals.notify.connect(
|
|
gdk_surface,
|
|
*Self,
|
|
propGdkSurfaceWidth,
|
|
self,
|
|
.{ .detail = "width" },
|
|
);
|
|
_ = gobject.Object.signals.notify.connect(
|
|
gdk_surface,
|
|
*Self,
|
|
propGdkSurfaceHeight,
|
|
self,
|
|
.{ .detail = "height" },
|
|
);
|
|
}
|
|
|
|
// When we are realized we always setup our appearance since this
|
|
// calls some winproto functions.
|
|
self.syncAppearance();
|
|
}
|
|
|
|
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 (self.getNeedsConfirmQuit()) {
|
|
// Show a confirmation dialog
|
|
const dialog: *CloseConfirmationDialog = .new(.window);
|
|
_ = 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 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));
|
|
|
|
// If the tab was previously marked as needing attention
|
|
// (e.g. due to a bell character), we now unmark that
|
|
page.setNeedsAttention(@intFromBool(false));
|
|
}
|
|
|
|
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.
|
|
if (tab.getSurfaceTree()) |tree| {
|
|
self.connectSurfaceHandlers(tree);
|
|
}
|
|
}
|
|
|
|
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 the tree handlers
|
|
if (tab.getSurfaceTree()) |tree| {
|
|
self.disconnectSurfaceHandlers(tree);
|
|
}
|
|
}
|
|
|
|
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.
|
|
|
|
// If the tab overview is open, then we don't close the window
|
|
// because its a rather abrupt experience. This also fixes an
|
|
// issue where dragging out the last tab in the tab overview
|
|
// won't cause Ghostty to exit.
|
|
if (priv.tab_overview.getOpen() != 0) return;
|
|
|
|
self.as(gtk.Window).close();
|
|
}
|
|
}
|
|
|
|
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 surfaceMenu(
|
|
_: *Surface,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
self.syncActions();
|
|
}
|
|
|
|
fn surfacePresentRequest(
|
|
surface: *Surface,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// Verify that this surface is actually in this window.
|
|
{
|
|
const surface_window = ext.getAncestor(
|
|
Self,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn(
|
|
"present request called for non-existent surface",
|
|
.{},
|
|
);
|
|
return;
|
|
};
|
|
if (surface_window != self) {
|
|
log.warn(
|
|
"present request called for surface in different window",
|
|
.{},
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Get the tab for this surface.
|
|
const tab = ext.getAncestor(
|
|
Tab,
|
|
surface.as(gtk.Widget),
|
|
) orelse {
|
|
log.warn("present request surface not found", .{});
|
|
return;
|
|
};
|
|
|
|
// Get the page that contains this tab
|
|
const priv = self.private();
|
|
const tab_view = priv.tab_view;
|
|
const page = tab_view.getPage(tab.as(gtk.Widget));
|
|
tab_view.setSelectedPage(page);
|
|
|
|
// Grab focus
|
|
surface.grabFocus();
|
|
}
|
|
|
|
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 surfaceInit(
|
|
surface: *Surface,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const priv = self.private();
|
|
|
|
// Make sure we init only once
|
|
if (priv.surface_init) return;
|
|
priv.surface_init = true;
|
|
|
|
// Setup our default and minimum size.
|
|
if (surface.getDefaultSize()) |size| {
|
|
self.as(gtk.Window).setDefaultSize(
|
|
@intCast(size.width),
|
|
@intCast(size.height),
|
|
);
|
|
}
|
|
if (surface.getMinSize()) |size| {
|
|
self.as(gtk.Widget).setSizeRequest(
|
|
@intCast(size.width),
|
|
@intCast(size.height),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn tabSplitTreeChanged(
|
|
_: *SplitTree,
|
|
old_tree: ?*const Surface.Tree,
|
|
new_tree: ?*const Surface.Tree,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
if (old_tree) |tree| {
|
|
self.disconnectSurfaceHandlers(tree);
|
|
}
|
|
|
|
if (new_tree) |tree| {
|
|
self.connectSurfaceHandlers(tree);
|
|
}
|
|
}
|
|
|
|
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 actionCloseTab(
|
|
_: *gio.SimpleAction,
|
|
param_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
const param = param_ orelse {
|
|
log.warn("win.close-tab called without a parameter", .{});
|
|
return;
|
|
};
|
|
|
|
var str: ?[*:0]const u8 = null;
|
|
param.get("&s", &str);
|
|
|
|
const mode = std.meta.stringToEnum(
|
|
input.Binding.Action.CloseTabMode,
|
|
std.mem.span(
|
|
str orelse {
|
|
log.warn("invalid mode provided to win.close-tab", .{});
|
|
return;
|
|
},
|
|
),
|
|
) orelse {
|
|
log.warn("invalid mode provided to win.close-tab: {s}", .{str.?});
|
|
return;
|
|
};
|
|
|
|
self.performBindingAction(.{ .close_tab = mode });
|
|
}
|
|
|
|
fn actionNewWindow(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
self.performBindingAction(.new_window);
|
|
}
|
|
|
|
fn actionNewTab(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
self.performBindingAction(.new_tab);
|
|
}
|
|
|
|
fn actionSplitRight(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
self.performBindingAction(.{ .new_split = .right });
|
|
}
|
|
|
|
fn actionSplitLeft(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
self.performBindingAction(.{ .new_split = .left });
|
|
}
|
|
|
|
fn actionSplitUp(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
self.performBindingAction(.{ .new_split = .up });
|
|
}
|
|
|
|
fn actionSplitDown(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
self.performBindingAction(.{ .new_split = .down });
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
fn actionRingBell(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
const priv = self.private();
|
|
const config = if (priv.config) |v| v.get() else return;
|
|
|
|
if (config.@"bell-features".system) system: {
|
|
const native = self.as(gtk.Native).getSurface() orelse {
|
|
log.warn("unable to get native surface from window", .{});
|
|
break :system;
|
|
};
|
|
native.beep();
|
|
}
|
|
|
|
if (config.@"bell-features".attention) attention: {
|
|
// Dont set urgency if the window is already active.
|
|
if (self.as(gtk.Window).isActive() != 0) break :attention;
|
|
|
|
// Request user attention
|
|
self.winproto().setUrgent(true) catch |err| {
|
|
log.warn("winproto failed to set urgency={}", .{err});
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Toggle the command palette.
|
|
///
|
|
/// TODO: accept the surface that toggled the command palette as a parameter
|
|
fn toggleCommandPalette(self: *Window) void {
|
|
const priv = self.private();
|
|
|
|
// Get a reference to a command palette. First check the weak reference
|
|
// that we save to see if we already have one stored. If we don't then
|
|
// create a new one.
|
|
const command_palette = priv.command_palette.get() orelse command_palette: {
|
|
// Create a fresh command palette.
|
|
const command_palette = CommandPalette.new();
|
|
|
|
// Synchronize our config to the command palette's config.
|
|
_ = gobject.Object.bindProperty(
|
|
self.as(gobject.Object),
|
|
"config",
|
|
command_palette.as(gobject.Object),
|
|
"config",
|
|
.{ .sync_create = true },
|
|
);
|
|
|
|
// Listen to the activate signal to know if the user selected an option in
|
|
// the command palette.
|
|
_ = CommandPalette.signals.trigger.connect(
|
|
command_palette,
|
|
*Window,
|
|
signalCommandPaletteTrigger,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
// Save a weak reference to the command palette. We use a weak reference to avoid
|
|
// reference counting cycles that might cause problems later.
|
|
priv.command_palette.set(command_palette);
|
|
|
|
break :command_palette command_palette;
|
|
};
|
|
defer command_palette.unref();
|
|
|
|
// Tell the command palette to toggle itself. If the dialog gets
|
|
// presented (instead of hidden) it will be modal over our window.
|
|
command_palette.toggle(self);
|
|
}
|
|
|
|
// React to a signal from a command palette asking an action to be performed.
|
|
fn signalCommandPaletteTrigger(_: *CommandPalette, action: *const input.Binding.Action, self: *Self) callconv(.c) void {
|
|
// If the activation actually has an action, perform it.
|
|
self.performBindingAction(action.*);
|
|
}
|
|
|
|
/// React to a GTK action requesting that the command palette be toggled.
|
|
fn actionToggleCommandPalette(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
// TODO: accept the surface that toggled the command palette as a
|
|
// parameter
|
|
self.toggleCommandPalette();
|
|
}
|
|
|
|
/// Toggle the Ghostty inspector for the active surface.
|
|
fn toggleInspector(self: *Self) void {
|
|
const surface = self.getActiveSurface() orelse return;
|
|
_ = surface.controlInspector(.toggle);
|
|
}
|
|
|
|
/// React to a GTK action requesting that the Ghostty inspector be toggled.
|
|
fn actionToggleInspector(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Window,
|
|
) callconv(.c) void {
|
|
// TODO: accept the surface that toggled the command palette as a
|
|
// parameter
|
|
self.toggleInspector();
|
|
}
|
|
|
|
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);
|
|
gobject.ext.ensureType(SplitTree);
|
|
gobject.ext.ensureType(Surface);
|
|
gobject.ext.ensureType(Tab);
|
|
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.config.impl,
|
|
properties.debug.impl,
|
|
properties.@"headerbar-visible".impl,
|
|
properties.@"quick-terminal".impl,
|
|
properties.@"tabs-autohide".impl,
|
|
properties.@"tabs-visible".impl,
|
|
properties.@"tabs-wide".impl,
|
|
properties.@"toolbar-style".impl,
|
|
properties.@"titlebar-style".impl,
|
|
});
|
|
|
|
// Bindings
|
|
class.bindTemplateChildPrivate("tab_overview", .{});
|
|
class.bindTemplateChildPrivate("tab_bar", .{});
|
|
class.bindTemplateChildPrivate("tab_view", .{});
|
|
class.bindTemplateChildPrivate("toolbar", .{});
|
|
class.bindTemplateChildPrivate("toast_overlay", .{});
|
|
|
|
// Template Callbacks
|
|
class.bindTemplateCallback("realize", &windowRealize);
|
|
class.bindTemplateCallback("new_tab", &btnNewTab);
|
|
class.bindTemplateCallback("overview_create_tab", &tabOverviewCreateTab);
|
|
class.bindTemplateCallback("overview_notify_open", &tabOverviewOpen);
|
|
class.bindTemplateCallback("close_request", &windowCloseRequest);
|
|
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_is_active", &propIsActive);
|
|
class.bindTemplateCallback("notify_maximized", &propMaximized);
|
|
class.bindTemplateCallback("notify_menu_active", &propMenuActive);
|
|
class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal);
|
|
class.bindTemplateCallback("notify_scale_factor", &propScaleFactor);
|
|
class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab);
|
|
class.bindTemplateCallback("computed_subtitle", &closureSubtitle);
|
|
|
|
// 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;
|
|
};
|
|
};
|