diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig new file mode 100644 index 000000000..7d6030c9b --- /dev/null +++ b/src/apprt/gtk/Split.zig @@ -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); +} diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 66ccb2047..266c67215 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -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(>kRealize), self, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(opts.gl_area, "unrealize", c.G_CALLBACK(>kUnrealize), self, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(opts.gl_area, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(opts.gl_area, "render", c.G_CALLBACK(>kRender), self, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(opts.gl_area, "resize", c.G_CALLBACK(>kResize), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gl_area, "realize", c.G_CALLBACK(>kRealize), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gl_area, "unrealize", c.G_CALLBACK(>kUnrealize), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gl_area, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gl_area, "render", c.G_CALLBACK(>kRender), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gl_area, "resize", c.G_CALLBACK(>kResize), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(>kKeyPressed), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_key_press, "key-released", c.G_CALLBACK(>kKeyReleased), 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(); } } diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig new file mode 100644 index 000000000..fff5b9519 --- /dev/null +++ b/src/apprt/gtk/Tab.zig @@ -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(>kTabCloseClick), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(>kDestroy), 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); +} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index e06ea5fbd..3fe96a498 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -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(>kTabCloseClick), 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 {