mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-19 05:50:27 +00:00
567 lines
18 KiB
Zig
567 lines
18 KiB
Zig
const std = @import("std");
|
|
const adw = @import("adw");
|
|
const gio = @import("gio");
|
|
const glib = @import("glib");
|
|
const gobject = @import("gobject");
|
|
const gtk = @import("gtk");
|
|
|
|
const apprt = @import("../../../apprt.zig");
|
|
const CoreSurface = @import("../../../Surface.zig");
|
|
const ext = @import("../ext.zig");
|
|
const gresource = @import("../build/gresource.zig");
|
|
const Common = @import("../class.zig").Common;
|
|
const Config = @import("config.zig").Config;
|
|
const Application = @import("application.zig").Application;
|
|
const SplitTree = @import("split_tree.zig").SplitTree;
|
|
const Surface = @import("surface.zig").Surface;
|
|
const TitleDialog = @import("title_dialog.zig").TitleDialog;
|
|
|
|
const log = std.log.scoped(.gtk_ghostty_window);
|
|
|
|
pub const Tab = extern struct {
|
|
const Self = @This();
|
|
parent_instance: Parent,
|
|
pub const Parent = gtk.Box;
|
|
pub const getGObjectType = gobject.ext.defineClass(Self, .{
|
|
.name = "GhosttyTab",
|
|
.instanceInit = &init,
|
|
.classInit = &Class.init,
|
|
.parent_class = &Class.parent,
|
|
.private = .{ .Type = Private, .offset = &Private.offset },
|
|
});
|
|
|
|
pub const properties = struct {
|
|
/// The active surface is the surface that should be receiving all
|
|
/// surface-targeted actions. This is usually the focused surface,
|
|
/// but may also not be focused if the user has selected a non-surface
|
|
/// widget.
|
|
pub const @"active-surface" = struct {
|
|
pub const name = "active-surface";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?*Surface,
|
|
.{
|
|
.accessor = gobject.ext.typedAccessor(
|
|
Self,
|
|
?*Surface,
|
|
.{
|
|
.getter = Self.getActiveSurface,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const config = struct {
|
|
pub const name = "config";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?*Config,
|
|
.{
|
|
.accessor = C.privateObjFieldAccessor("config"),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"split-tree" = struct {
|
|
pub const name = "split-tree";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?*SplitTree,
|
|
.{
|
|
.accessor = gobject.ext.typedAccessor(
|
|
Self,
|
|
?*SplitTree,
|
|
.{
|
|
.getter = getSplitTree,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const @"surface-tree" = struct {
|
|
pub const name = "surface-tree";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?*Surface.Tree,
|
|
.{
|
|
.accessor = gobject.ext.typedAccessor(
|
|
Self,
|
|
?*Surface.Tree,
|
|
.{
|
|
.getter = getSurfaceTree,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const tooltip = struct {
|
|
pub const name = "tooltip";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?[:0]const u8,
|
|
.{
|
|
.default = null,
|
|
.accessor = C.privateStringFieldAccessor("tooltip"),
|
|
},
|
|
);
|
|
};
|
|
|
|
pub const title = struct {
|
|
pub const name = "title";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?[:0]const u8,
|
|
.{
|
|
.default = null,
|
|
.accessor = C.privateStringFieldAccessor("title"),
|
|
},
|
|
);
|
|
};
|
|
pub const @"title-override" = struct {
|
|
pub const name = "title-override";
|
|
const impl = gobject.ext.defineProperty(
|
|
name,
|
|
Self,
|
|
?[:0]const u8,
|
|
.{
|
|
.default = null,
|
|
.accessor = C.privateStringFieldAccessor("title_override"),
|
|
},
|
|
);
|
|
};
|
|
};
|
|
|
|
pub const signals = struct {
|
|
/// Emitted whenever the tab would like to be closed.
|
|
pub const @"close-request" = struct {
|
|
pub const name = "close-request";
|
|
pub const connect = impl.connect;
|
|
const impl = gobject.ext.defineSignal(
|
|
name,
|
|
Self,
|
|
&.{},
|
|
void,
|
|
);
|
|
};
|
|
};
|
|
|
|
const Private = struct {
|
|
/// The configuration that this surface is using.
|
|
config: ?*Config = null,
|
|
|
|
/// The title of this tab. This is usually bound to the active surface.
|
|
title: ?[:0]const u8 = null,
|
|
|
|
/// The manually overridden title from `promptTabTitle`.
|
|
title_override: ?[:0]const u8 = null,
|
|
|
|
/// The tooltip of this tab. This is usually bound to the active surface.
|
|
tooltip: ?[:0]const u8 = null,
|
|
|
|
// Template bindings
|
|
split_tree: *SplitTree,
|
|
|
|
pub var offset: c_int = 0;
|
|
};
|
|
|
|
/// Set the parent of this tab page. This only affects the first surface
|
|
/// ever created for a tab. If a surface was already created this does
|
|
/// nothing.
|
|
pub fn setParent(self: *Self, parent: *CoreSurface) void {
|
|
self.setParentWithContext(parent, .tab);
|
|
}
|
|
|
|
pub fn setParentWithContext(self: *Self, parent: *CoreSurface, context: apprt.surface.NewSurfaceContext) void {
|
|
if (self.getActiveSurface()) |surface| {
|
|
surface.setParent(parent, context);
|
|
}
|
|
}
|
|
|
|
fn init(self: *Self, _: *Class) callconv(.c) void {
|
|
gtk.Widget.initTemplate(self.as(gtk.Widget));
|
|
|
|
// Init our actions
|
|
self.initActionMap();
|
|
|
|
// If our configuration is null then we get the configuration
|
|
// from the application.
|
|
const priv = self.private();
|
|
if (priv.config == null) {
|
|
const app = Application.default();
|
|
priv.config = app.getConfig();
|
|
}
|
|
|
|
// Create our initial surface in the split tree.
|
|
priv.split_tree.newSplit(.right, null) catch |err| switch (err) {
|
|
error.OutOfMemory => {
|
|
// TODO: We should make our "no surfaces" state more aesthetically
|
|
// pleasing and show something like an "Oops, something went wrong"
|
|
// message. For now, this is incredibly unlikely.
|
|
@panic("oom");
|
|
},
|
|
};
|
|
}
|
|
|
|
fn initActionMap(self: *Self) void {
|
|
const s_param_type = glib.ext.VariantType.newFor([:0]const u8);
|
|
defer s_param_type.free();
|
|
|
|
const actions = [_]ext.actions.Action(Self){
|
|
.init("close", actionClose, s_param_type),
|
|
.init("ring-bell", actionRingBell, null),
|
|
.init("next-page", actionNextPage, null),
|
|
.init("previous-page", actionPreviousPage, null),
|
|
.init("prompt-tab-title", actionPromptTabTitle, null),
|
|
};
|
|
|
|
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Properties
|
|
|
|
/// Overridden title. This will be generally be shown over the title
|
|
/// unless this is unset (null).
|
|
pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void {
|
|
const priv = self.private();
|
|
if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v)));
|
|
priv.title_override = null;
|
|
if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v);
|
|
self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec);
|
|
}
|
|
fn titleDialogSet(
|
|
_: *TitleDialog,
|
|
title_ptr: [*:0]const u8,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const title = std.mem.span(title_ptr);
|
|
self.setTitleOverride(if (title.len == 0) null else title);
|
|
}
|
|
pub fn promptTabTitle(self: *Self) void {
|
|
const priv = self.private();
|
|
const dialog = TitleDialog.new(.tab, priv.title_override orelse priv.title);
|
|
_ = TitleDialog.signals.set.connect(
|
|
dialog,
|
|
*Self,
|
|
titleDialogSet,
|
|
self,
|
|
.{},
|
|
);
|
|
|
|
dialog.present(self.as(gtk.Widget));
|
|
}
|
|
|
|
/// Get the currently active surface. See the "active-surface" property.
|
|
/// This does not ref the value.
|
|
pub fn getActiveSurface(self: *Self) ?*Surface {
|
|
return self.getSplitTree().getActiveSurface();
|
|
}
|
|
|
|
/// Get the surface tree of this tab.
|
|
pub fn getSurfaceTree(self: *Self) ?*Surface.Tree {
|
|
const priv = self.private();
|
|
return priv.split_tree.getTree();
|
|
}
|
|
|
|
/// Get the split tree widget that is in this tab.
|
|
pub fn getSplitTree(self: *Self) *SplitTree {
|
|
const priv = self.private();
|
|
return priv.split_tree;
|
|
}
|
|
|
|
/// Returns true if this tab needs confirmation before quitting based
|
|
/// on the various Ghostty configurations.
|
|
pub fn getNeedsConfirmQuit(self: *Self) bool {
|
|
const tree = self.getSplitTree();
|
|
return tree.getNeedsConfirmQuit();
|
|
}
|
|
|
|
/// Get the tab view holding this tab, if any.
|
|
fn getTabView(self: *Self) ?*adw.TabView {
|
|
return ext.getAncestor(
|
|
adw.TabView,
|
|
self.as(gtk.Widget),
|
|
);
|
|
}
|
|
|
|
/// Get the tab page holding this tab, if any.
|
|
fn getTabPage(self: *Self) ?*adw.TabPage {
|
|
const tab_view = self.getTabView() orelse return null;
|
|
return tab_view.getPage(self.as(gtk.Widget));
|
|
}
|
|
|
|
//---------------------------------------------------------------
|
|
// Virtual methods
|
|
|
|
fn dispose(self: *Self) callconv(.c) void {
|
|
const priv = self.private();
|
|
if (priv.config) |v| {
|
|
v.unref();
|
|
priv.config = null;
|
|
}
|
|
|
|
gtk.Widget.disposeTemplate(
|
|
self.as(gtk.Widget),
|
|
getGObjectType(),
|
|
);
|
|
|
|
gobject.Object.virtual_methods.dispose.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
}
|
|
|
|
fn finalize(self: *Self) callconv(.c) void {
|
|
const priv = self.private();
|
|
if (priv.tooltip) |v| {
|
|
glib.free(@ptrCast(@constCast(v)));
|
|
priv.tooltip = null;
|
|
}
|
|
if (priv.title) |v| {
|
|
glib.free(@ptrCast(@constCast(v)));
|
|
priv.title = null;
|
|
}
|
|
|
|
gobject.Object.virtual_methods.finalize.call(
|
|
Class.parent,
|
|
self.as(Parent),
|
|
);
|
|
}
|
|
//---------------------------------------------------------------
|
|
// Signal handlers
|
|
|
|
fn propSplitTree(
|
|
_: *SplitTree,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec);
|
|
|
|
// If our tree is empty we close the tab.
|
|
const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty;
|
|
if (tree.isEmpty()) {
|
|
signals.@"close-request".impl.emit(
|
|
self,
|
|
null,
|
|
.{},
|
|
null,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
fn propActiveSurface(
|
|
_: *SplitTree,
|
|
_: *gobject.ParamSpec,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
|
}
|
|
|
|
fn actionClose(
|
|
_: *gio.SimpleAction,
|
|
param_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const param = param_ orelse {
|
|
log.warn("tab.close-tab called without a parameter", .{});
|
|
return;
|
|
};
|
|
|
|
var str: ?[*:0]const u8 = null;
|
|
param.get("&s", &str);
|
|
|
|
const tab_view = self.getTabView() orelse return;
|
|
const page = tab_view.getPage(self.as(gtk.Widget));
|
|
|
|
const mode = std.meta.stringToEnum(
|
|
apprt.action.CloseTabMode,
|
|
std.mem.span(
|
|
str orelse {
|
|
log.warn("invalid mode provided to tab.close-tab", .{});
|
|
return;
|
|
},
|
|
),
|
|
) orelse {
|
|
// Need to be defensive here since actions can be triggered externally.
|
|
log.warn("invalid mode provided to tab.close-tab: {s}", .{str.?});
|
|
return;
|
|
};
|
|
|
|
// Delegate to our parent to handle this, since this will emit
|
|
// a close-page signal that the parent can intercept.
|
|
switch (mode) {
|
|
.this => tab_view.closePage(page),
|
|
.other => tab_view.closeOtherPages(page),
|
|
.right => tab_view.closePagesAfter(page),
|
|
}
|
|
}
|
|
|
|
fn actionPromptTabTitle(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
self.promptTabTitle();
|
|
}
|
|
|
|
fn actionRingBell(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
// Future note: I actually don't like this logic living here at all.
|
|
// I think a better approach will be for the ring bell action to
|
|
// specify its sending surface and then do all this in the window.
|
|
|
|
// If the page is selected already we don't mark it as needing
|
|
// attention. We only want to mark unfocused pages. This will then
|
|
// clear when the page is selected.
|
|
const page = self.getTabPage() orelse return;
|
|
if (page.getSelected() != 0) return;
|
|
page.setNeedsAttention(@intFromBool(true));
|
|
}
|
|
|
|
/// Select the next tab page.
|
|
fn actionNextPage(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const tab_view = self.getTabView() orelse return;
|
|
_ = tab_view.selectNextPage();
|
|
}
|
|
|
|
/// Select the previous tab page.
|
|
fn actionPreviousPage(
|
|
_: *gio.SimpleAction,
|
|
_: ?*glib.Variant,
|
|
self: *Self,
|
|
) callconv(.c) void {
|
|
const tab_view = self.getTabView() orelse return;
|
|
_ = tab_view.selectPreviousPage();
|
|
}
|
|
|
|
fn closureComputedTitle(
|
|
_: *Self,
|
|
config_: ?*Config,
|
|
terminal_: ?[*:0]const u8,
|
|
surface_override_: ?[*:0]const u8,
|
|
tab_override_: ?[*:0]const u8,
|
|
zoomed_: c_int,
|
|
bell_ringing_: c_int,
|
|
_: *gobject.ParamSpec,
|
|
) callconv(.c) ?[*:0]const u8 {
|
|
const zoomed = zoomed_ != 0;
|
|
const bell_ringing = bell_ringing_ != 0;
|
|
|
|
// Our plain title is the manually tab overridden title if it exists,
|
|
// otherwise the overridden title if it exists, otherwise
|
|
// the terminal title if it exists, otherwise a default string.
|
|
const plain = plain: {
|
|
const default = "Ghostty";
|
|
const config_title: ?[*:0]const u8 = title: {
|
|
const config = config_ orelse break :title null;
|
|
break :title config.get().title orelse null;
|
|
};
|
|
|
|
const plain = tab_override_ orelse
|
|
surface_override_ orelse
|
|
terminal_ orelse
|
|
config_title orelse
|
|
break :plain default;
|
|
break :plain std.mem.span(plain);
|
|
};
|
|
|
|
// We don't need a config in every case, but if we don't have a config
|
|
// let's just assume something went terribly wrong and use our
|
|
// default title. Its easier then guarding on the config existing
|
|
// in every case for something so unlikely.
|
|
const config = if (config_) |v| v.get() else {
|
|
log.warn("config unavailable for computed title, likely bug", .{});
|
|
return glib.ext.dupeZ(u8, plain);
|
|
};
|
|
|
|
// Use an allocator to build up our string as we write it.
|
|
var buf: std.Io.Writer.Allocating = .init(Application.default().allocator());
|
|
defer buf.deinit();
|
|
|
|
// If our bell is ringing, then we prefix the bell icon to the title.
|
|
if (bell_ringing and config.@"bell-features".title) {
|
|
buf.writer.writeAll("🔔 ") catch {};
|
|
}
|
|
|
|
// If we're zoomed, prefix with the magnifying glass emoji.
|
|
if (zoomed) {
|
|
buf.writer.writeAll("🔍 ") catch {};
|
|
}
|
|
|
|
buf.writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain);
|
|
return glib.ext.dupeZ(u8, buf.written());
|
|
}
|
|
|
|
const C = Common(Self, Private);
|
|
pub const as = C.as;
|
|
pub const ref = C.ref;
|
|
pub const unref = C.unref;
|
|
const private = C.private;
|
|
|
|
pub const Class = extern struct {
|
|
parent_class: Parent.Class,
|
|
var parent: *Parent.Class = undefined;
|
|
pub const Instance = Self;
|
|
|
|
fn init(class: *Class) callconv(.c) void {
|
|
gobject.ext.ensureType(SplitTree);
|
|
gobject.ext.ensureType(Surface);
|
|
gtk.Widget.Class.setTemplateFromResource(
|
|
class.as(gtk.Widget.Class),
|
|
comptime gresource.blueprint(.{
|
|
.major = 1,
|
|
.minor = 5,
|
|
.name = "tab",
|
|
}),
|
|
);
|
|
|
|
// Properties
|
|
gobject.ext.registerProperties(class, &.{
|
|
properties.@"active-surface".impl,
|
|
properties.config.impl,
|
|
properties.@"split-tree".impl,
|
|
properties.@"surface-tree".impl,
|
|
properties.title.impl,
|
|
properties.@"title-override".impl,
|
|
properties.tooltip.impl,
|
|
});
|
|
|
|
// Bindings
|
|
class.bindTemplateChildPrivate("split_tree", .{});
|
|
|
|
// Template Callbacks
|
|
class.bindTemplateCallback("computed_title", &closureComputedTitle);
|
|
class.bindTemplateCallback("notify_active_surface", &propActiveSurface);
|
|
class.bindTemplateCallback("notify_tree", &propSplitTree);
|
|
|
|
// Signals
|
|
signals.@"close-request".impl.register(.{});
|
|
|
|
// Virtual methods
|
|
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
|
|
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
|
|
}
|
|
|
|
pub const as = C.Class.as;
|
|
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
|
|
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
|
|
};
|
|
};
|