Merge pull request #778 from mitchellh/mrn/gtk-tabs-splits

gtk: add support for splits
This commit is contained in:
Mitchell Hashimoto
2023-12-01 13:46:19 -08:00
committed by GitHub
4 changed files with 848 additions and 200 deletions

263
src/apprt/gtk/Split.zig Normal file
View File

@ -0,0 +1,263 @@
/// Split represents a surface split where two surfaces are shown side-by-side
/// within the same window either vertically or horizontally.
const Split = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Surface = @import("Surface.zig");
const Tab = @import("Tab.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
/// Our actual GtkPaned widget
paned: *c.GtkPaned,
/// The container for this split panel.
container: Surface.Container,
/// The elements of this split panel.
top_left: Surface.Container.Elem,
bottom_right: Surface.Container.Elem,
/// Create a new split panel with the given sibling surface in the given
/// direction. The direction is where the new surface will be initialized.
///
/// The sibling surface can be in a split already or it can be within a
/// tab. This properly handles updating the surface container so that
/// it represents the new split.
pub fn create(
alloc: Allocator,
sibling: *Surface,
direction: input.SplitDirection,
) !*Split {
var split = try alloc.create(Split);
errdefer alloc.destroy(split);
try split.init(sibling, direction);
return split;
}
pub fn init(
self: *Split,
sibling: *Surface,
direction: input.SplitDirection,
) !void {
// Create the new child surface for the other direction.
const alloc = sibling.app.core_app.alloc;
var surface = try Surface.create(alloc, sibling.app, .{
.parent = &sibling.core_surface,
});
errdefer surface.destroy(alloc);
// Create the actual GTKPaned, attach the proper children.
const orientation: c_uint = switch (direction) {
.right => c.GTK_ORIENTATION_HORIZONTAL,
.down => c.GTK_ORIENTATION_VERTICAL,
};
const paned = c.gtk_paned_new(orientation);
errdefer c.g_object_unref(paned);
// Keep a long-lived reference, which we unref in destroy.
_ = c.g_object_ref(paned);
// Update all of our containers to point to the right place.
// The split has to point to where the sibling pointed to because
// we're inheriting its parent. The sibling points to its location
// in the split, and the surface points to the other location.
const container = sibling.container;
sibling.container = .{ .split_tl = &self.top_left };
surface.container = .{ .split_br = &self.bottom_right };
self.* = .{
.paned = @ptrCast(paned),
.container = container,
.top_left = .{ .surface = sibling },
.bottom_right = .{ .surface = surface },
};
// Replace the previous containers element with our split.
// This allows a non-split to become a split, a split to
// become a nested split, etc.
container.replace(.{ .split = self });
// Update our children so that our GL area is properly
// added to the paned.
self.updateChildren();
// The new surface should always grab focus
surface.grabFocus();
}
pub fn destroy(self: *Split, alloc: Allocator) void {
self.top_left.deinit(alloc);
self.bottom_right.deinit(alloc);
// Clean up our GTK reference. This will trigger all the destroy callbacks
// that are necessary for the surfaces to clean up.
c.g_object_unref(self.paned);
alloc.destroy(self);
}
/// Remove the top left child.
pub fn removeTopLeft(self: *Split) void {
self.removeChild(self.top_left, self.bottom_right);
}
/// Remove the top left child.
pub fn removeBottomRight(self: *Split) void {
self.removeChild(self.bottom_right, self.top_left);
}
fn removeChild(
self: *Split,
remove: Surface.Container.Elem,
keep: Surface.Container.Elem,
) void {
const window = self.container.window() orelse return;
const alloc = window.app.core_app.alloc;
// Remove our children since we are going to no longer be
// a split anyways. This prevents widgets with multiple parents.
self.removeChildren();
// Our container must become whatever our top left is
self.container.replace(keep);
// Grab focus of the left-over side
keep.grabFocus();
// When a child is removed we are no longer a split, so destroy ourself
remove.deinit(alloc);
alloc.destroy(self);
}
// This replaces the element at the given pointer with a new element.
// The ptr must be either top_left or bottom_right (asserted in debug).
// The memory of the old element must be freed or otherwise handled by
// the caller.
pub fn replace(
self: *Split,
ptr: *Surface.Container.Elem,
new: Surface.Container.Elem,
) void {
// We can write our element directly. There's nothing special.
assert(&self.top_left == ptr or &self.bottom_right == ptr);
ptr.* = new;
// Update our paned children. This will reset the divider
// position but we want to keep it in place so save and restore it.
const pos = c.gtk_paned_get_position(self.paned);
defer c.gtk_paned_set_position(self.paned, pos);
self.updateChildren();
}
// grabFocus grabs the focus of the top-left element.
pub fn grabFocus(self: *Split) void {
self.top_left.grabFocus();
}
/// Update the paned children to represent the current state.
/// This should be called anytime the top/left or bottom/right
/// element is changed.
fn updateChildren(self: *const Split) void {
// We have to set both to null. If we overwrite the pane with
// the same value, then GTK bugs out (the GL area unrealizes
// and never rerealizes).
self.removeChildren();
// Set our current children
c.gtk_paned_set_start_child(
@ptrCast(self.paned),
self.top_left.widget(),
);
c.gtk_paned_set_end_child(
@ptrCast(self.paned),
self.bottom_right.widget(),
);
}
/// A mapping of direction to the element (if any) in that direction.
pub const DirectionMap = std.EnumMap(
input.SplitFocusDirection,
?*Surface,
);
pub const Side = enum { top_left, bottom_right };
/// Returns the map that can be used to determine elements in various
/// directions (primarily for gotoSplit).
pub fn directionMap(self: *const Split, from: Side) DirectionMap {
var result = DirectionMap.initFull(null);
if (self.directionPrevious(from)) |prev| {
result.put(.previous, prev);
// This behavior matches the behavior of macOS at the time of writing
// this. There is an open issue (#524) to make this depend on the
// actual physical location of the current split.
result.put(.top, prev);
result.put(.left, prev);
}
if (self.directionNext(from)) |next| {
result.put(.next, next);
result.put(.bottom, next);
result.put(.right, next);
}
return result;
}
fn directionPrevious(self: *const Split, from: Side) ?*Surface {
switch (from) {
// From the bottom right, our previous is the deepest surface
// in the top-left of our own split.
.bottom_right => return self.top_left.deepestSurface(.bottom_right),
// From the top left its more complicated. It is the de
.top_left => {
// If we have no parent split then there can be no previous.
const parent = self.container.split() orelse return null;
const side = self.container.splitSide() orelse return null;
// The previous value is the previous of the side that we are.
return switch (side) {
.top_left => parent.directionPrevious(.top_left),
.bottom_right => parent.directionPrevious(.bottom_right),
};
},
}
}
fn directionNext(self: *const Split, from: Side) ?*Surface {
switch (from) {
// From the top left, our next is the earliest surface in the
// top-left direction of the bottom-right side of our split. Fun!
.top_left => return self.bottom_right.deepestSurface(.top_left),
// From the bottom right is more compliated. It is the deepest
// (last) surface in the
.bottom_right => {
// If we have no parent split then there can be no next.
const parent = self.container.split() orelse return null;
const side = self.container.splitSide() orelse return null;
// The previous value is the previous of the side that we are.
return switch (side) {
.top_left => parent.directionNext(.top_left),
.bottom_right => parent.directionNext(.bottom_right),
};
},
}
}
fn removeChildren(self: *const Split) void {
c.gtk_paned_set_start_child(@ptrCast(self.paned), null);
c.gtk_paned_set_end_child(@ptrCast(self.paned), null);
}

View File

@ -4,6 +4,7 @@
const Surface = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
@ -12,44 +13,174 @@ const terminal = @import("../../terminal/main.zig");
const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig");
const Split = @import("Split.zig");
const Tab = @import("Tab.zig");
const Window = @import("Window.zig");
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
const inspector = @import("inspector.zig");
const gtk_key = @import("key.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
const log = std.log.scoped(.gtk_surface);
/// This is detected by the OpenGL renderer to move to a single-threaded
/// draw operation. This basically puts locks around our draw path.
pub const opengl_single_threaded_draw = true;
pub const Options = struct {
/// The window that this surface is attached to.
window: *Window,
/// The GL area that this surface should draw to.
gl_area: *c.GtkGLArea,
/// The label to use as the title of this surface. This will be
/// modified with setTitle.
title_label: ?*c.GtkLabel = null,
/// A font size to set on the surface once it is initialized.
font_size: ?font.face.DesiredSize = null,
/// True if this surface has a parent. This is a bit of a hack currently
/// to work around newConfig unconditinally inheriting the working
/// directory. The proper long term fix is to have the working directory
/// inherited upstream likely at the point where this field would be set,
/// then remove this field.
parent: bool = false,
/// The parent surface to inherit settings such as font size, working
/// directory, etc. from.
parent: ?*CoreSurface = null,
};
/// Where the title of this surface will go.
const Title = union(enum) {
/// The container that this surface is directly attached to.
pub const Container = union(enum) {
/// The surface is not currently attached to anything. This means
/// that the GLArea has been created and potentially initialized
/// but the widget is currently floating and not part of any parent.
none: void,
label: *c.GtkLabel,
/// Directly attached to a tab. (i.e. no splits)
tab_: *Tab,
/// A split within a split hierarchy. The key determines the
/// position of the split within the parent split.
split_tl: *Elem,
split_br: *Elem,
/// The side of the split.
pub const SplitSide = enum { top_left, bottom_right };
/// Elem is the possible element of any container. A container can
/// hold both a surface and a split. Any valid container should
/// have an Elem value so that it can be properly used with
/// splits.
pub const Elem = union(enum) {
/// A surface is a leaf element of the split -- a terminal
/// surface.
surface: *Surface,
/// A split is a nested split within a split. This lets you
/// for example have a horizontal split with a vertical split
/// on the left side (amongst all other possible
/// combinations).
split: *Split,
/// Returns the GTK widget to add to the paned for the given
/// element
pub fn widget(self: Elem) *c.GtkWidget {
return switch (self) {
.surface => |s| @ptrCast(s.gl_area),
.split => |s| @ptrCast(@alignCast(s.paned)),
};
}
pub fn containerPtr(self: Elem) *Container {
return switch (self) {
.surface => |s| &s.container,
.split => |s| &s.container,
};
}
pub fn deinit(self: Elem, alloc: Allocator) void {
switch (self) {
.surface => |s| s.unref(),
.split => |s| s.destroy(alloc),
}
}
pub fn grabFocus(self: Elem) void {
switch (self) {
.surface => |s| s.grabFocus(),
.split => |s| s.grabFocus(),
}
}
/// The last surface in this container in the direction specified.
/// Direction must be "top_left" or "bottom_right".
pub fn deepestSurface(self: Elem, side: SplitSide) ?*Surface {
return switch (self) {
.surface => |s| s,
.split => |s| (switch (side) {
.top_left => s.top_left,
.bottom_right => s.bottom_right,
}).deepestSurface(side),
};
}
};
/// Returns the window that this surface is attached to.
pub fn window(self: Container) ?*Window {
return switch (self) {
.none => null,
.tab_ => |v| v.window,
.split_tl, .split_br => split: {
const s = self.split() orelse break :split null;
break :split s.container.window();
},
};
}
/// Returns the tab container if it exists.
pub fn tab(self: Container) ?*Tab {
return switch (self) {
.none => null,
.tab_ => |v| v,
.split_tl, .split_br => split: {
const s = self.split() orelse break :split null;
break :split s.container.tab();
},
};
}
/// Returns the split containing this surface (if any).
pub fn split(self: Container) ?*Split {
return switch (self) {
.none, .tab_ => null,
.split_tl => |ptr| @fieldParentPtr(Split, "top_left", ptr),
.split_br => |ptr| @fieldParentPtr(Split, "bottom_right", ptr),
};
}
/// The side that we are in the split.
pub fn splitSide(self: Container) ?SplitSide {
return switch (self) {
.none, .tab_ => null,
.split_tl => .top_left,
.split_br => .bottom_right,
};
}
/// Replace the container's element with this element. This is
/// used by children to modify their parents to for example change
/// from a surface to a split or a split back to a surface or
/// a split to a nested split and so on.
pub fn replace(self: Container, elem: Elem) void {
// Move the element into the container
switch (self) {
.none => {},
.tab_ => |t| t.replaceElem(elem),
inline .split_tl, .split_br => |ptr| {
const s = self.split().?;
s.replace(ptr, elem);
},
}
// Update the reverse reference to the container
elem.containerPtr().* = self;
}
/// Remove ourselves from the container. This is used by
/// children to effectively notify they're container that
/// all children at this level are exiting.
pub fn remove(self: Container) void {
switch (self) {
.none => {},
.tab_ => |t| t.remove(),
.split_tl => self.split().?.removeTopLeft(),
.split_br => self.split().?.removeBottomRight(),
}
}
};
/// Whether the surface has been realized or not yet. When a surface is
@ -57,23 +188,27 @@ const Title = union(enum) {
/// surface has been initialized.
realized: bool = false,
/// See Options.parent
parent: bool = false,
/// True if this surface had a parent to start with.
parent_surface: bool = false,
/// The GUI container that this surface has been attached to. This
/// dictates some behaviors such as new splits, etc.
container: Container = .{ .none = {} },
/// The app we're part of
app: *App,
/// The window we're part of
window: *Window,
/// Our GTK area
gl_area: *c.GtkGLArea,
/// Any active cursor we may have
cursor: ?*c.GdkCursor = null,
/// Our title label (if there is one).
title: Title,
/// Our title. The raw value of the title. This will be kept up to date and
/// .title will be updated if we have focus.
/// When set the text in this buf will be null-terminated, because we need to
/// pass it to GTK.
title_text: ?[:0]const u8 = null,
/// The core surface backing this surface
core_surface: CoreSurface,
@ -95,12 +230,37 @@ im_composing: bool = false,
im_buf: [128]u8 = undefined,
im_len: u7 = 0,
pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface {
var surface = try alloc.create(Surface);
errdefer alloc.destroy(surface);
try surface.init(app, opts);
return surface;
}
pub fn init(self: *Surface, app: *App, opts: Options) !void {
const widget = @as(*c.GtkWidget, @ptrCast(opts.gl_area));
c.gtk_gl_area_set_required_version(opts.gl_area, 3, 3);
c.gtk_gl_area_set_has_stencil_buffer(opts.gl_area, 0);
c.gtk_gl_area_set_has_depth_buffer(opts.gl_area, 0);
c.gtk_gl_area_set_use_es(opts.gl_area, 0);
const widget: *c.GtkWidget = c.gtk_gl_area_new();
const gl_area: *c.GtkGLArea = @ptrCast(widget);
// We grab the floating reference to GL area. This lets the
// GL area be moved around i.e. between a split, a tab, etc.
// without having to be really careful about ordering to
// prevent a destroy.
//
// This is unref'd in the unref() method that's called by the
// self.container through Elem.deinit.
_ = c.g_object_ref_sink(@ptrCast(gl_area));
errdefer c.g_object_unref(@ptrCast(gl_area));
// We want the gl area to expand to fill the parent container.
c.gtk_widget_set_hexpand(widget, 1);
c.gtk_widget_set_vexpand(widget, 1);
// Various other GL properties
c.gtk_widget_set_cursor_from_name(@ptrCast(gl_area), "text");
c.gtk_gl_area_set_required_version(gl_area, 3, 3);
c.gtk_gl_area_set_has_stencil_buffer(gl_area, 0);
c.gtk_gl_area_set_has_depth_buffer(gl_area, 0);
c.gtk_gl_area_set_use_es(gl_area, 0);
// Key event controller will tell us about raw keypress events.
const ec_key = c.gtk_event_controller_key_new();
@ -150,17 +310,22 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
c.gtk_widget_set_focusable(widget, 1);
c.gtk_widget_set_focus_on_click(widget, 1);
// Inherit the parent's font size if we have a parent.
const font_size: ?font.face.DesiredSize = font_size: {
if (!app.config.@"window-inherit-font-size") break :font_size null;
const parent = opts.parent orelse break :font_size null;
break :font_size parent.font_size;
};
// Build our result
self.* = .{
.app = app,
.window = opts.window,
.gl_area = opts.gl_area,
.title = if (opts.title_label) |label| .{
.label = label,
} else .{ .none = {} },
.container = .{ .none = {} },
.gl_area = gl_area,
.title_text = null,
.core_surface = undefined,
.font_size = opts.font_size,
.parent = opts.parent,
.font_size = font_size,
.parent_surface = opts.parent != null,
.size = .{ .width = 800, .height = 600 },
.cursor_pos = .{ .x = 0, .y = 0 },
.im_context = im_context,
@ -171,11 +336,11 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
try self.setMouseShape(.text);
// GL events
_ = c.g_signal_connect_data(opts.gl_area, "realize", c.G_CALLBACK(&gtkRealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(opts.gl_area, "unrealize", c.G_CALLBACK(&gtkUnrealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(opts.gl_area, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(opts.gl_area, "render", c.G_CALLBACK(&gtkRender), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(opts.gl_area, "resize", c.G_CALLBACK(&gtkResize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "realize", c.G_CALLBACK(&gtkRealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "unrealize", c.G_CALLBACK(&gtkUnrealize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "render", c.G_CALLBACK(&gtkRender), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gl_area, "resize", c.G_CALLBACK(&gtkResize), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(&gtkKeyPressed), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(ec_key_press, "key-released", c.G_CALLBACK(&gtkKeyReleased), self, null, c.G_CONNECT_DEFAULT);
@ -209,8 +374,8 @@ fn realize(self: *Surface) !void {
// Get our new surface config
var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config);
defer config.deinit();
if (!self.parent) {
// A hack, see the "parent" field for more information.
if (!self.parent_surface) {
// A hack, see the "parent_surface" field for more information.
config.@"working-directory" = self.app.config.@"working-directory";
}
@ -234,6 +399,8 @@ fn realize(self: *Surface) !void {
}
pub fn deinit(self: *Surface) void {
if (self.title_text) |title| self.app.core_app.alloc.free(title);
// We don't allocate anything if we aren't realized.
if (!self.realized) return;
@ -253,6 +420,17 @@ pub fn deinit(self: *Surface) void {
if (self.cursor) |cursor| c.g_object_unref(cursor);
}
// unref removes the long-held reference to the gl_area and kicks off the
// deinit/destroy process for this surface.
pub fn unref(self: *Surface) void {
c.g_object_unref(self.gl_area);
}
pub fn destroy(self: *Surface, alloc: Allocator) void {
self.deinit();
alloc.destroy(self);
}
fn render(self: *Surface) !void {
try self.core_surface.renderer.drawFrame(self);
}
@ -269,14 +447,22 @@ pub fn redraw(self: *Surface) void {
/// Close this surface.
pub fn close(self: *Surface, processActive: bool) void {
// If we're not part of a window hierarchy, we never confirm
// so we can just directly remove ourselves and exit.
const window = self.container.window() orelse {
self.container.remove();
return;
};
// If we have no process active we can just exit immediately.
if (!processActive) {
self.window.closeSurface(self);
self.container.remove();
return;
}
// Setup our basic message
const alert = c.gtk_message_dialog_new(
self.window.window,
window.window,
c.GTK_DIALOG_MODAL,
c.GTK_MESSAGE_QUESTION,
c.GTK_BUTTONS_YES_NO,
@ -336,27 +522,91 @@ pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void {
}
pub fn toggleFullscreen(self: *Surface, mac_non_native: configpkg.NonNativeFullscreen) void {
self.window.toggleFullscreen(mac_non_native);
const window = self.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.toggleFullscreen(mac_non_native);
}
pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget {
switch (self.title) {
.none => return null,
.label => |label| {
const widget = @as(*c.GtkWidget, @ptrCast(@alignCast(label)));
return widget;
},
}
}
pub fn newSplit(self: *Surface, direction: input.SplitDirection) !void {
const alloc = self.app.core_app.alloc;
_ = try Split.create(alloc, self, direction);
}
pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void {
const s = self.container.split() orelse return;
const map = s.directionMap(switch (self.container) {
.split_tl => .top_left,
.split_br => .bottom_right,
.none, .tab_ => unreachable,
});
const surface_ = map.get(direction) orelse return;
if (surface_) |surface| surface.grabFocus();
}
pub fn newTab(self: *Surface) !void {
try self.window.newTab(&self.core_surface);
const window = self.container.window() orelse {
log.info("surface cannot create new tab when not attached to a window", .{});
return;
};
try window.newTab(&self.core_surface);
}
pub fn hasTabs(self: *const Surface) bool {
return self.window.hasTabs();
const window = self.container.window() orelse return false;
return window.hasTabs();
}
pub fn gotoPreviousTab(self: *Surface) void {
self.window.gotoPreviousTab(self);
const window = self.container.window() orelse {
log.info(
"gotoPreviousTab invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.gotoPreviousTab(self);
}
pub fn gotoNextTab(self: *Surface) void {
self.window.gotoNextTab(self);
const window = self.container.window() orelse {
log.info(
"gotoNextTab invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.gotoNextTab(self);
}
pub fn gotoTab(self: *Surface, n: usize) void {
self.window.gotoTab(n);
const window = self.container.window() orelse {
log.info(
"gotoTab invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.gotoTab(n);
}
pub fn setShouldClose(self: *Surface) void {
@ -380,10 +630,13 @@ pub fn getSize(self: *const Surface) !apprt.SurfaceSize {
}
pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void {
// This operation only makes sense if we're within a window view hierarchy.
const window = self.container.window() orelse return;
// Note: this doesn't properly take into account the window decorations.
// I'm not currently sure how to do that.
c.gtk_window_set_default_size(
@ptrCast(self.window.window),
@ptrCast(window.window),
@intCast(width),
@intCast(height),
);
@ -401,24 +654,39 @@ pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.Surfac
_ = max_;
}
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
switch (self.title) {
.none => {},
pub fn grabFocus(self: *Surface) void {
if (self.container.tab()) |tab| tab.focus_child = self;
.label => |label| {
c.gtk_label_set_text(label, slice.ptr);
self.updateTitleLabels();
const widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
_ = c.gtk_widget_grab_focus(widget);
}
const widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
if (c.gtk_widget_is_focus(widget) == 1) {
c.gtk_window_set_title(self.window.window, c.gtk_label_get_text(label));
}
},
fn updateTitleLabels(self: *Surface) void {
// If we have no title, then we have nothing to update.
const title = self.title_text orelse return;
// If we have a tab, then we have to update the tab
if (self.container.tab()) |tab| {
c.gtk_label_set_text(tab.label_text, title.ptr);
}
// const root = c.gtk_widget_get_root(@ptrCast(
// *c.GtkWidget,
// self.gl_area,
// ));
// If we have a window, then we have to update the window title.
if (self.container.window()) |window| {
c.gtk_window_set_title(window.window, title.ptr);
}
}
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
const alloc = self.app.core_app.alloc;
const copy = try alloc.dupeZ(u8, slice);
errdefer alloc.free(copy);
if (self.title_text) |old| alloc.free(old);
self.title_text = copy;
const widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
if (c.gtk_widget_is_focus(widget) == 1) self.updateTitleLabels();
}
pub fn setMouseShape(
@ -672,6 +940,7 @@ fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
fn gtkUnrealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
_ = area;
log.debug("gl surface unrealized", .{});
const self = userdataSelf(ud.?);
self.core_surface.renderer.displayUnrealized();
@ -705,8 +974,8 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque)
};
const window_scale_factor = scale: {
const window = @as(*c.GtkNative, @ptrCast(self.window.window));
const gdk_surface = c.gtk_native_get_surface(window);
const window = self.container.window() orelse break :scale 0;
const gdk_surface = c.gtk_native_get_surface(@ptrCast(window.window));
break :scale c.gdk_surface_get_scale_factor(gdk_surface);
};
@ -788,7 +1057,7 @@ fn gtkMouseDown(
// If we don't have focus, grab it.
const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area));
if (c.gtk_widget_has_focus(gl_widget) == 0) {
_ = c.gtk_widget_grab_focus(gl_widget);
self.grabFocus();
}
self.core_surface.mouseButtonCallback(.press, button, mods) catch |err| {
@ -1249,7 +1518,7 @@ fn gtkCloseConfirmation(
c.gtk_window_destroy(@ptrCast(alert));
if (response == c.GTK_RESPONSE_YES) {
const self = userdataSelf(ud.?);
self.window.closeSurface(self);
self.container.remove();
}
}

187
src/apprt/gtk/Tab.zig Normal file
View File

@ -0,0 +1,187 @@
/// The state associated with a single tab in the window.
///
/// A tab can contain one or more terminals due to splits.
const Tab = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
pub const GHOSTTY_TAB = "ghostty_tab";
/// The window that owns this tab.
window: *Window,
/// The tab label. The tab label is the text that appears on the tab.
label_text: *c.GtkLabel,
/// We'll put our children into this box instead of packing them
/// directly, so that we can send the box into `c.g_signal_connect_data`
/// for the close button
box: *c.GtkBox,
/// The element of this tab so that we can handle splits and so on.
elem: Surface.Container.Elem,
// We'll update this every time a Surface gains focus, so that we have it
// when we switch to another Tab. Then when we switch back to this tab, we
// can easily re-focus that terminal.
focus_child: *Surface,
pub fn create(alloc: Allocator, window: *Window, parent_: ?*CoreSurface) !*Tab {
var tab = try alloc.create(Tab);
errdefer alloc.destroy(tab);
try tab.init(window, parent_);
return tab;
}
/// Initialize the tab, create a surface, and add it to the window. "self"
/// needs to be a stable pointer, since it is used for GTK events.
pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void {
self.* = .{
.window = window,
.label_text = undefined,
.box = undefined,
.elem = undefined,
.focus_child = undefined,
};
// Build the tab label
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
const label_text_widget = c.gtk_label_new("Ghostty");
const label_text: *c.GtkLabel = @ptrCast(label_text_widget);
c.gtk_box_append(label_box, label_text_widget);
self.label_text = label_text;
// Build the close button for the tab
const label_close_widget = c.gtk_button_new_from_icon_name("window-close");
const label_close: *c.GtkButton = @ptrCast(label_close_widget);
c.gtk_button_set_has_frame(label_close, 0);
c.gtk_box_append(label_box, label_close_widget);
// Wide style GTK tabs
if (window.app.config.@"gtk-wide-tabs") {
c.gtk_widget_set_hexpand(label_box_widget, 1);
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
c.gtk_widget_set_hexpand(label_text_widget, 1);
c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL);
// This ensures that tabs are always equal width. If they're too
// long, they'll be truncated with an ellipsis.
c.gtk_label_set_max_width_chars(label_text, 1);
c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END);
// We need to set a minimum width so that at a certain point
// the notebook will have an arrow button rather than shrinking tabs
// to an unreadably small size.
c.gtk_widget_set_size_request(label_text_widget, 100, 1);
}
// Create a Box in which we'll later keep either Surface or Split.
// Using a box makes it easier to maintain the tab contents because
// we never need to change the root widget of the notebook page (tab).
const box_widget = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
c.gtk_widget_set_hexpand(box_widget, 1);
c.gtk_widget_set_vexpand(box_widget, 1);
self.box = @ptrCast(box_widget);
// Create the initial surface since all tabs start as a single non-split
var surface = try Surface.create(window.app.core_app.alloc, window.app, .{
.parent = parent_,
});
errdefer surface.unref();
surface.container = .{ .tab_ = self };
self.elem = .{ .surface = surface };
// Add Surface to the Tab
const gl_area_widget = @as(*c.GtkWidget, @ptrCast(surface.gl_area));
c.gtk_box_append(self.box, gl_area_widget);
// Add the notebook page (create tab). We create the tab after our
// current selected tab if we have one.
const page_idx = c.gtk_notebook_insert_page(
window.notebook,
box_widget,
label_box_widget,
c.gtk_notebook_get_current_page(window.notebook) + 1,
);
if (page_idx < 0) {
log.warn("failed to add page to notebook", .{});
return error.GtkAppendPageFailed;
}
// Tab settings
c.gtk_notebook_set_tab_reorderable(window.notebook, box_widget, 1);
c.gtk_notebook_set_tab_detachable(window.notebook, box_widget, 1);
// If we have multiple tabs, show the tab bar.
if (c.gtk_notebook_get_n_pages(window.notebook) > 1) {
c.gtk_notebook_set_show_tabs(window.notebook, 1);
}
// Set the userdata of the box to point to this tab.
c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self);
// Attach all events
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&gtkTabCloseClick), self, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(&gtkDestroy), self, null, c.G_CONNECT_DEFAULT);
// Switch to the new tab
c.gtk_notebook_set_current_page(window.notebook, page_idx);
// We need to grab focus after Surface and Tab is added to the window. When
// creating a Tab we want to always focus on the widget.
surface.grabFocus();
}
/// Deinits tab by deiniting child elem.
pub fn deinit(self: *Tab, alloc: Allocator) void {
self.elem.deinit(alloc);
}
/// Deinit and deallocate the tab.
pub fn destroy(self: *Tab, alloc: Allocator) void {
self.deinit(alloc);
alloc.destroy(self);
}
// TODO: move this
/// Replace the surface element that this tab is showing.
pub fn replaceElem(self: *Tab, elem: Surface.Container.Elem) void {
// Remove our previous widget
c.gtk_box_remove(self.box, self.elem.widget());
// Add our new one
c.gtk_box_append(self.box, elem.widget());
self.elem = elem;
}
/// Remove this tab from the window.
pub fn remove(self: *Tab) void {
self.window.closeTab(self);
}
fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
const tab: *Tab = @ptrCast(@alignCast(ud));
const window = tab.window;
window.closeTab(tab);
}
fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
_ = v;
log.debug("tab box destroy", .{});
// When our box is destroyed, we want to destroy our tab, too.
const tab: *Tab = @ptrCast(@alignCast(ud));
tab.destroy(tab.window.app.core_app.alloc);
}

View File

@ -1,4 +1,8 @@
/// A Window is a single, real GTK window.
/// A Window is a single, real GTK window that holds terminal surfaces.
///
/// A Window always contains a notebook (what GTK calls a tabbed container)
/// even while no tabs are in use, because a notebook without a tab bar has
/// no visible UI chrome.
const Window = @This();
const std = @import("std");
@ -8,17 +12,17 @@ const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const configpkg = @import("../../config.zig");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const App = @import("App.zig");
const Surface = @import("Surface.zig");
const Tab = @import("Tab.zig");
const icon = @import("icon.zig");
const c = @import("c.zig");
const log = std.log.scoped(.gtk);
const GL_AREA_SURFACE = "gl_area_surface";
app: *App,
/// Our window
@ -184,102 +188,25 @@ pub fn deinit(self: *Window) void {
}
/// Add a new tab to this window.
pub fn newTab(self: *Window, parent_: ?*CoreSurface) !void {
// Grab a surface allocation we'll need it later.
var surface = try self.app.core_app.alloc.create(Surface);
errdefer self.app.core_app.alloc.destroy(surface);
pub fn newTab(self: *Window, parent: ?*CoreSurface) !void {
const alloc = self.app.core_app.alloc;
_ = try Tab.create(alloc, self, parent);
// Inherit the parent's font size if we are configured to.
const font_size: ?font.face.DesiredSize = font_size: {
if (!self.app.config.@"window-inherit-font-size") break :font_size null;
const parent = parent_ orelse break :font_size null;
break :font_size parent.font_size;
};
// Build our tab label
const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0);
const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget));
const label_text = c.gtk_label_new("Ghostty");
c.gtk_box_append(label_box, label_text);
// Wide style GTK tabs
if (self.app.config.@"gtk-wide-tabs") {
c.gtk_widget_set_hexpand(label_box_widget, 1);
c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL);
c.gtk_widget_set_hexpand(label_text, 1);
c.gtk_widget_set_halign(label_text, c.GTK_ALIGN_FILL);
// This ensures that tabs are always equal width. If they're too
// long, they'll be truncated with an ellipsis.
c.gtk_label_set_max_width_chars(@ptrCast(label_text), 1);
c.gtk_label_set_ellipsize(@ptrCast(label_text), c.PANGO_ELLIPSIZE_END);
// We need to set a minimum width so that at a certain point
// the notebook will have an arrow button rather than shrinking tabs
// to an unreadably small size.
c.gtk_widget_set_size_request(@ptrCast(label_text), 100, 1);
}
const label_close_widget = c.gtk_button_new_from_icon_name("window-close");
const label_close = @as(*c.GtkButton, @ptrCast(label_close_widget));
c.gtk_button_set_has_frame(label_close, 0);
c.gtk_box_append(label_box, label_close_widget);
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&gtkTabCloseClick), surface, null, c.G_CONNECT_DEFAULT);
// Initialize the GtkGLArea and attach it to our surface.
// The surface starts in the "unrealized" state because we have to
// wait for the "realize" callback from GTK to know that the OpenGL
// context is ready. See Surface docs for more info.
const gl_area = c.gtk_gl_area_new();
c.gtk_widget_set_cursor_from_name(gl_area, "text");
try surface.init(self.app, .{
.window = self,
.gl_area = @ptrCast(gl_area),
.title_label = @ptrCast(label_text),
.font_size = font_size,
.parent = parent_ != null,
});
errdefer surface.deinit();
// Add the notebook page (create tab). We create the tab after our
// current selected tab if we have one.
const page_idx = c.gtk_notebook_insert_page(
self.notebook,
gl_area,
label_box_widget,
c.gtk_notebook_get_current_page(self.notebook) + 1,
);
if (page_idx < 0) {
log.warn("failed to add surface to notebook", .{});
return error.GtkAppendPageFailed;
}
// Tab settings
c.gtk_notebook_set_tab_reorderable(self.notebook, gl_area, 1);
c.gtk_notebook_set_tab_detachable(self.notebook, gl_area, 1);
// If we have multiple tabs, show the tab bar.
if (c.gtk_notebook_get_n_pages(self.notebook) > 1) {
c.gtk_notebook_set_show_tabs(self.notebook, 1);
}
// Set the userdata of the close button so it points to this page.
c.g_object_set_data(@ptrCast(gl_area), GL_AREA_SURFACE, surface);
// Switch to the new tab
c.gtk_notebook_set_current_page(self.notebook, page_idx);
// We need to grab focus after it is added to the window. When
// creating a window we want to always focus on the widget.
const widget = @as(*c.GtkWidget, @ptrCast(gl_area));
_ = c.gtk_widget_grab_focus(widget);
// TODO: When this is triggered through a GTK action, the new surface
// redraws correctly. When it's triggered through keyboard shortcuts, it
// does not (cursor doesn't blink) unless reactivated by refocusing.
}
/// Close the tab for the given notebook page. This will automatically
/// handle closing the window if there are no more tabs.
fn closeTab(self: *Window, page: *c.GtkNotebookPage) void {
// Remove the page
pub fn closeTab(self: *Window, tab: *Tab) void {
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return;
// Find page and tab which we're closing
const page_idx = getNotebookPageIndex(page);
// Remove the page. This will destroy the GTK widgets in the page which
// will trigger Tab cleanup.
c.gtk_notebook_remove_page(self.notebook, page_idx);
const remaining = c.gtk_notebook_get_n_pages(self.notebook);
@ -297,14 +224,6 @@ fn closeTab(self: *Window, page: *c.GtkNotebookPage) void {
if (remaining > 0) self.focusCurrentTab();
}
/// Close the surface. This surface must be definitely part of this window.
pub fn closeSurface(self: *Window, surface: *Surface) void {
assert(surface.window == self);
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(surface.gl_area)) orelse return;
self.closeTab(page);
}
/// Returns true if this window has any tabs.
pub fn hasTabs(self: *const Window) bool {
return c.gtk_notebook_get_n_pages(self.notebook) > 1;
@ -312,7 +231,12 @@ pub fn hasTabs(self: *const Window) bool {
/// Go to the previous tab for a surface.
pub fn gotoPreviousTab(self: *Window, surface: *Surface) void {
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(surface.gl_area)) orelse return;
const tab = surface.container.tab() orelse {
log.info("surface is not attached to a tab bar, cannot navigate", .{});
return;
};
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return;
const page_idx = getNotebookPageIndex(page);
// The next index is the previous or we wrap around.
@ -330,7 +254,12 @@ pub fn gotoPreviousTab(self: *Window, surface: *Surface) void {
/// Go to the next tab for a surface.
pub fn gotoNextTab(self: *Window, surface: *Surface) void {
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(surface.gl_area)) orelse return;
const tab = surface.container.tab() orelse {
log.info("surface is not attached to a tab bar, cannot navigate", .{});
return;
};
const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return;
const page_idx = getNotebookPageIndex(page);
const max = c.gtk_notebook_get_n_pages(self.notebook) -| 1;
const next_idx = if (page_idx < max) page_idx + 1 else 0;
@ -364,13 +293,12 @@ pub fn toggleFullscreen(self: *Window, _: configpkg.NonNativeFullscreen) void {
/// Grabs focus on the currently selected tab.
fn focusCurrentTab(self: *Window) void {
const page_idx = c.gtk_notebook_get_current_page(self.notebook);
const widget = c.gtk_notebook_get_nth_page(self.notebook, page_idx);
_ = c.gtk_widget_grab_focus(widget);
}
fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void {
const surface: *Surface = @ptrCast(@alignCast(ud));
surface.core_surface.close();
const page = c.gtk_notebook_get_nth_page(self.notebook, page_idx);
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return,
));
const gl_area = @as(*c.GtkWidget, @ptrCast(tab.focus_child.gl_area));
_ = c.gtk_widget_grab_focus(gl_area);
}
// Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab
@ -423,23 +351,23 @@ fn gtkNotebookCreateWindow(
page: *c.GtkWidget,
ud: ?*anyopaque,
) callconv(.C) ?*c.GtkNotebook {
// The surface for the page is stored in the widget data.
const surface: *Surface = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), GL_AREA_SURFACE) orelse return null,
// The tab for the page is stored in the widget data.
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
));
const self = userdataSelf(ud.?);
const alloc = self.app.core_app.alloc;
const currentWindow = userdataSelf(ud.?);
const alloc = currentWindow.app.core_app.alloc;
const app = currentWindow.app;
// Create a new window
const window = Window.create(alloc, self.app) catch |err| {
const window = Window.create(alloc, app) catch |err| {
log.warn("error creating new window error={}", .{err});
return null;
};
// We need to update our surface to point to the new window so that
// events such as new tab go to the right window.
surface.window = window;
// And add it to the new window.
tab.window = window;
return window.notebook;
}
@ -451,8 +379,9 @@ fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool {
// If none of our surfaces need confirmation, we can just exit.
for (self.app.core_app.surfaces.items) |surface| {
if (surface.window == self) {
if (surface.core_surface.needsConfirmQuit()) break;
if (surface.container.window()) |window| {
if (window == self and
surface.core_surface.needsConfirmQuit()) break;
}
} else {
c.gtk_window_destroy(self.window);
@ -602,10 +531,10 @@ fn gtkActionToggleInspector(
fn actionSurface(self: *Window) ?*CoreSurface {
const page_idx = c.gtk_notebook_get_current_page(self.notebook);
const page = c.gtk_notebook_get_nth_page(self.notebook, page_idx);
const surface: *Surface = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), GL_AREA_SURFACE) orelse return null,
const tab: *Tab = @ptrCast(@alignCast(
c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null,
));
return &surface.core_surface;
return &tab.focus_child.core_surface;
}
fn userdataSelf(ud: *anyopaque) *Window {