Multiple terminals per tab, rearrangeable by drag or keyboard
A tab is now a view holding an ordered list of panes rather than a single terminal. New panes open side by side; moving one against a top or bottom edge restacks the view, and against a side edge returns it to a row. Layout is a flat list with one orientation per view rather than a split tree. That covers a shell beside an agent without the structure a tree needs, and it can be replaced once mixed layouts actually matter. Panes live in a GtkBox and are reordered in place, so rearranging never unparents a terminal and running processes and scrollback survive the move. Dragging a pane uses its header as the handle so it never competes with the terminal's own mouse handling, and drops go through the same moveRelative path as the Ctrl+Shift+arrow shortcuts. Also fixes an ordering bug this surfaced: View.create used to add its first pane immediately, firing the title callback into a Tab that had not been initialized yet. The view is now created empty and the caller adds the pane once it has finished wiring itself up.
This commit is contained in:
@@ -77,7 +77,9 @@ just the VT core.
|
||||
|
||||
```
|
||||
main.zig AdwApplication, CSS loading
|
||||
Window.zig sidebar + GtkStack of terminals, tab management, shortcuts
|
||||
Window.zig sidebar + GtkStack of views, tab management, shortcuts
|
||||
View.zig one tab's content: an ordered list of panes + their layout
|
||||
Pane.zig a terminal plus its header, drag source, and drop target
|
||||
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
|
||||
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
||||
Pty.zig openpt/fork/exec, controlling terminal setup
|
||||
@@ -85,6 +87,20 @@ key.zig GDK keyval -> libghostty-vt key mapping
|
||||
theme.zig colors libghostty-vt has no opinion about
|
||||
```
|
||||
|
||||
A tab is a **view**, and a view holds one or more terminal **panes**. New panes
|
||||
open side by side; moving a pane to a top or bottom edge restacks the view, and
|
||||
moving it to a side edge puts it back in a row.
|
||||
|
||||
**Layout is a flat list, not a split tree.** A view has one orientation shared
|
||||
by all its panes, so it can express "three side by side" or "three stacked" but
|
||||
not "two beside a stack of three". A tree would handle the general case and is
|
||||
where this goes if mixed layouts turn out to matter; it just brings a lot of
|
||||
structure that a shell-next-to-an-agent view doesn't need yet.
|
||||
|
||||
Panes are held in a `GtkBox`, which can reorder its children in place. Nothing
|
||||
is ever unparented, so rearranging a view never disturbs the running
|
||||
terminals — their scrollback and processes carry straight through the move.
|
||||
|
||||
Two design choices worth calling out:
|
||||
|
||||
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
|
||||
@@ -108,20 +124,29 @@ in principle, but a terminal grid is small.
|
||||
- Block / bar / underline / hollow cursor styles
|
||||
- Scrollback via mouse wheel; typing snaps back to the prompt
|
||||
- Resize reflows the grid and notifies the child
|
||||
- Window title (OSC 0/2) becomes the tab label
|
||||
- Tabs: create, close, switch; closing the last one closes the window;
|
||||
a child exiting closes its own tab
|
||||
- Window title (OSC 0/2) becomes the pane header and tab label; the tab shows
|
||||
its pane count once a view holds more than one
|
||||
- Tabs: create, close, switch; closing the last one closes the window
|
||||
- Multiple terminals per tab, rearranged by keyboard or by dragging a pane's
|
||||
header; closing the last pane in a view closes its tab
|
||||
|
||||
### Shortcuts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `Ctrl+Shift+T` | new tab |
|
||||
| `Ctrl+Shift+W` | close tab |
|
||||
| `Ctrl+Shift+E` | new terminal in the current tab |
|
||||
| `Ctrl+Shift+W` | close the focused terminal (closes the tab with its last one) |
|
||||
| `Ctrl+Shift+←/→/↑/↓` | move the focused terminal within its view |
|
||||
| `Ctrl+Shift+V` | paste (bracketed-paste aware, refuses unsafe pastes) |
|
||||
| `Ctrl+PageUp/PageDown` | previous / next tab |
|
||||
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
|
||||
|
||||
Dragging a pane by its header does the same thing as the arrow shortcuts: the
|
||||
edge you drop against decides both the order and the view's orientation. The
|
||||
header is the drag handle rather than the whole pane so that dragging never
|
||||
competes with the terminal's own mouse handling.
|
||||
|
||||
## Not implemented
|
||||
|
||||
This is a proof of concept, and the following are deliberately absent:
|
||||
@@ -132,7 +157,11 @@ This is a proof of concept, and the following are deliberately absent:
|
||||
- **Ligatures and complex shaping.** Each run is drawn independently at a
|
||||
fixed grid offset, so text that needs shaping across cell boundaries won't
|
||||
look right.
|
||||
- **Kitty graphics, hyperlinks, tab reordering, split panes, config file.**
|
||||
- **Resizable splits.** Panes in a view divide the space evenly; there are no
|
||||
draggable dividers. Moving to `GtkPaned` would add them.
|
||||
- **Mixed layouts and moving panes between tabs.** See the flat-list note
|
||||
above; panes can only be rearranged within their own view.
|
||||
- **Kitty graphics, hyperlinks, tab reordering, config file.**
|
||||
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
|
||||
`ghostty`, since we don't install a terminfo entry.
|
||||
|
||||
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
//! One terminal pane inside a view.
|
||||
//!
|
||||
//! A pane is a terminal plus the chrome needed to tell panes apart and
|
||||
//! rearrange them: a header strip showing the terminal's title, which doubles
|
||||
//! as the drag handle, and a drop target covering the whole pane.
|
||||
//!
|
||||
//! The header exists mainly so dragging a pane never competes with the
|
||||
//! terminal's own mouse handling. Grabbing anywhere in the terminal body
|
||||
//! would collide with text selection as soon as that lands.
|
||||
|
||||
const std = @import("std");
|
||||
const gdk = @import("gdk");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
|
||||
const Pane = @This();
|
||||
|
||||
/// Where a dragged pane would land relative to the pane under the pointer.
|
||||
/// The edge implies the resulting orientation: dropping on a side arranges
|
||||
/// panes left-to-right, dropping on top or bottom stacks them.
|
||||
pub const Zone = enum {
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
|
||||
fn cssClass(self: Zone) [:0]const u8 {
|
||||
return switch (self) {
|
||||
.left => "drop-left",
|
||||
.right => "drop-right",
|
||||
.top => "drop-top",
|
||||
.bottom => "drop-bottom",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn orientation(self: Zone) gtk.Orientation {
|
||||
return switch (self) {
|
||||
.left, .right => .horizontal,
|
||||
.top, .bottom => .vertical,
|
||||
};
|
||||
}
|
||||
|
||||
/// True if the dragged pane belongs before the pane it was dropped on.
|
||||
pub fn isBefore(self: Zone) bool {
|
||||
return self == .left or self == .top;
|
||||
}
|
||||
};
|
||||
|
||||
const all_zones = [_]Zone{ .left, .right, .top, .bottom };
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
view: *View,
|
||||
term: *Terminal,
|
||||
|
||||
/// Vertical box: header strip on top, terminal filling the rest.
|
||||
box: *gtk.Box,
|
||||
|
||||
/// The header doubles as the drag handle, so it's kept for the drag source.
|
||||
header: *gtk.Box,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// The zone currently highlighted by a hovering drag, if any.
|
||||
hint: ?Zone = null,
|
||||
|
||||
/// Latest terminal title, kept NUL-terminated for GTK.
|
||||
title: [128:0]u8 = @splat(0),
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
const self = try alloc.create(Pane);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.view = view,
|
||||
.term = undefined,
|
||||
.box = gtk.Box.new(.vertical, 0),
|
||||
.header = gtk.Box.new(.horizontal, 4),
|
||||
.label = gtk.Label.new("shell"),
|
||||
};
|
||||
setTitle(self, "shell");
|
||||
|
||||
self.term = try .create(alloc, .{
|
||||
.on_title = &onTermTitle,
|
||||
.on_exit = &onTermExit,
|
||||
.on_focus = &onTermFocus,
|
||||
.ctx = self,
|
||||
});
|
||||
errdefer self.term.destroy();
|
||||
|
||||
const box_widget = self.box.as(gtk.Widget);
|
||||
box_widget.addCssClass("vtabs-pane");
|
||||
box_widget.setHexpand(1);
|
||||
box_widget.setVexpand(1);
|
||||
// Clip the terminal to the pane's rounded corners. The terminal paints a
|
||||
// plain rectangle and has no idea it's inside a rounded frame.
|
||||
box_widget.setOverflow(.hidden);
|
||||
|
||||
self.buildHeader();
|
||||
self.box.append(self.header.as(gtk.Widget));
|
||||
self.box.append(self.term.widget());
|
||||
|
||||
self.installDragSource();
|
||||
self.installDropTarget();
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Pane) void {
|
||||
self.term.destroy();
|
||||
self.alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn widget(self: *Pane) *gtk.Widget {
|
||||
return self.box.as(gtk.Widget);
|
||||
}
|
||||
|
||||
pub fn grabFocus(self: *Pane) void {
|
||||
self.term.grabFocus();
|
||||
}
|
||||
|
||||
pub fn titleSlice(self: *const Pane) [:0]const u8 {
|
||||
return std.mem.sliceTo(&self.title, 0);
|
||||
}
|
||||
|
||||
/// Mark this pane as the focused one in its view.
|
||||
pub fn setActive(self: *Pane, active: bool) void {
|
||||
if (active) {
|
||||
self.widget().addCssClass("active");
|
||||
} else {
|
||||
self.widget().removeCssClass("active");
|
||||
}
|
||||
}
|
||||
|
||||
fn buildHeader(self: *Pane) void {
|
||||
const header = self.header;
|
||||
header.as(gtk.Widget).addCssClass("vtabs-pane-header");
|
||||
// The whole strip is the drag handle, so advertise that with the cursor.
|
||||
header.as(gtk.Widget).setCursorFromName("grab");
|
||||
|
||||
self.label.setXalign(0);
|
||||
self.label.setEllipsize(.end);
|
||||
self.label.as(gtk.Widget).setHexpand(1);
|
||||
self.label.as(gtk.Widget).addCssClass("vtabs-pane-title");
|
||||
header.append(self.label.as(gtk.Widget));
|
||||
|
||||
const split = gtk.Button.newFromIconName("list-add-symbolic");
|
||||
split.as(gtk.Widget).addCssClass("flat");
|
||||
split.as(gtk.Widget).addCssClass("vtabs-pane-button");
|
||||
split.as(gtk.Widget).setTooltipText("New terminal in this view (Ctrl+Shift+E)");
|
||||
_ = gtk.Button.signals.clicked.connect(split, *Pane, &onSplitClicked, self, .{});
|
||||
header.append(split.as(gtk.Widget));
|
||||
|
||||
const close = gtk.Button.newFromIconName("window-close-symbolic");
|
||||
close.as(gtk.Widget).addCssClass("flat");
|
||||
close.as(gtk.Widget).addCssClass("vtabs-pane-button");
|
||||
close.as(gtk.Widget).setTooltipText("Close terminal (Ctrl+Shift+W)");
|
||||
_ = gtk.Button.signals.clicked.connect(close, *Pane, &onCloseClicked, self, .{});
|
||||
header.append(close.as(gtk.Widget));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Drag and drop
|
||||
//
|
||||
// The payload is a fixed string purely to satisfy the GTK content API. The
|
||||
// pane being dragged is recorded on the view instead: drags never leave this
|
||||
// process, and passing a raw pointer through a GValue buys nothing.
|
||||
|
||||
const drag_payload = "vtabs-pane";
|
||||
|
||||
fn installDragSource(self: *Pane) void {
|
||||
const source = gtk.DragSource.new();
|
||||
source.setActions(.{ .move = true });
|
||||
_ = gtk.DragSource.signals.prepare.connect(source, *Pane, &onDragPrepare, self, .{});
|
||||
_ = gtk.DragSource.signals.drag_end.connect(source, *Pane, &onDragEnd, self, .{});
|
||||
|
||||
// Attached to the header only, so drags inside the terminal body stay
|
||||
// available for the terminal itself.
|
||||
self.header.as(gtk.Widget).addController(source.as(gtk.EventController));
|
||||
}
|
||||
|
||||
fn onDragPrepare(
|
||||
_: *gtk.DragSource,
|
||||
_: f64,
|
||||
_: f64,
|
||||
self: *Pane,
|
||||
) callconv(.c) ?*gdk.ContentProvider {
|
||||
// A single-pane view has nothing to rearrange.
|
||||
if (self.view.panes.items.len < 2) return null;
|
||||
|
||||
self.view.dragging = self;
|
||||
var value = gobject.ext.Value.newFrom(@as([*:0]const u8, drag_payload));
|
||||
return gdk.ContentProvider.newForValue(&value);
|
||||
}
|
||||
|
||||
fn onDragEnd(_: *gtk.DragSource, _: *gdk.Drag, _: c_int, self: *Pane) callconv(.c) void {
|
||||
self.view.dragging = null;
|
||||
self.view.clearHints();
|
||||
}
|
||||
|
||||
fn installDropTarget(self: *Pane) void {
|
||||
const target = gtk.DropTarget.new(gobject.ext.types.string, .{ .move = true });
|
||||
_ = gtk.DropTarget.signals.motion.connect(target, *Pane, &onDropMotion, self, .{});
|
||||
_ = gtk.DropTarget.signals.leave.connect(target, *Pane, &onDropLeave, self, .{});
|
||||
_ = gtk.DropTarget.signals.drop.connect(target, *Pane, &onDrop, self, .{});
|
||||
self.widget().addController(target.as(gtk.EventController));
|
||||
}
|
||||
|
||||
/// Pick the edge the pointer is nearest to. Nearest-edge beats fixed
|
||||
/// hit-zones here: every point in the pane maps to some edge, so there is no
|
||||
/// dead center where a drop would do nothing.
|
||||
fn zoneAt(self: *Pane, x: f64, y: f64) Zone {
|
||||
const w: f64 = @floatFromInt(self.widget().getWidth());
|
||||
const h: f64 = @floatFromInt(self.widget().getHeight());
|
||||
if (w <= 0 or h <= 0) return .right;
|
||||
|
||||
// Distances are normalized so a wide, short pane doesn't bias every drop
|
||||
// toward its long edges.
|
||||
const distances = [_]f64{
|
||||
x / w, // left
|
||||
1.0 - x / w, // right
|
||||
y / h, // top
|
||||
1.0 - y / h, // bottom
|
||||
};
|
||||
|
||||
var best: usize = 0;
|
||||
for (distances, 0..) |d, i| {
|
||||
if (d < distances[best]) best = i;
|
||||
}
|
||||
return all_zones[best];
|
||||
}
|
||||
|
||||
fn onDropMotion(_: *gtk.DropTarget, x: f64, y: f64, self: *Pane) callconv(.c) gdk.DragAction {
|
||||
const moving = self.view.dragging orelse return .{};
|
||||
if (moving == self) {
|
||||
self.showHint(null);
|
||||
return .{};
|
||||
}
|
||||
|
||||
self.showHint(self.zoneAt(x, y));
|
||||
return .{ .move = true };
|
||||
}
|
||||
|
||||
fn onDropLeave(_: *gtk.DropTarget, self: *Pane) callconv(.c) void {
|
||||
self.showHint(null);
|
||||
}
|
||||
|
||||
fn onDrop(
|
||||
_: *gtk.DropTarget,
|
||||
_: *gobject.Value,
|
||||
x: f64,
|
||||
y: f64,
|
||||
self: *Pane,
|
||||
) callconv(.c) c_int {
|
||||
const moving = self.view.dragging orelse return 0;
|
||||
self.view.clearHints();
|
||||
if (moving == self) return 0;
|
||||
|
||||
self.view.moveRelative(moving, self, self.zoneAt(x, y));
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// Show (or clear) the edge highlight that previews where a drop will land.
|
||||
pub fn showHint(self: *Pane, zone: ?Zone) void {
|
||||
if (self.hint) |old| {
|
||||
if (zone != null and old == zone.?) return;
|
||||
self.widget().removeCssClass(old.cssClass());
|
||||
}
|
||||
self.hint = zone;
|
||||
if (zone) |z| self.widget().addCssClass(z.cssClass());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Callbacks
|
||||
|
||||
fn setTitle(self: *Pane, text: []const u8) void {
|
||||
const n = @min(text.len, self.title.len - 1);
|
||||
@memcpy(self.title[0..n], text[0..n]);
|
||||
@memset(self.title[n..], 0);
|
||||
self.label.setText(self.titleSlice());
|
||||
self.label.as(gtk.Widget).setTooltipText(self.titleSlice());
|
||||
}
|
||||
|
||||
fn onTermTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.setTitle(title);
|
||||
self.view.paneTitleChanged(self);
|
||||
}
|
||||
|
||||
fn onTermExit(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.view.closePane(self);
|
||||
}
|
||||
|
||||
fn onTermFocus(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.view.setFocused(self);
|
||||
}
|
||||
|
||||
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane() catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onCloseClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.closePane(self);
|
||||
}
|
||||
+28
-29
@@ -9,6 +9,7 @@
|
||||
const std = @import("std");
|
||||
const cairo = @import("cairo");
|
||||
const gdk = @import("gdk");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
const pango = @import("pango");
|
||||
const pangocairo = @import("pangocairo");
|
||||
@@ -29,9 +30,6 @@ const font_spec = "monospace 11";
|
||||
/// Padding between the grid and the widget edge, in pixels.
|
||||
const pad: f64 = 8;
|
||||
|
||||
/// Corner rounding of the terminal pane.
|
||||
const corner_radius: f64 = 10;
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
session: *Session,
|
||||
|
||||
@@ -49,6 +47,10 @@ run_buf: std.ArrayListUnmanaged(u8) = .empty,
|
||||
/// Called when the session's title changes, so the owner can retitle the tab.
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
/// Called when this terminal takes keyboard focus.
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(
|
||||
@@ -56,6 +58,7 @@ pub fn create(
|
||||
cbs: struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
},
|
||||
) !*Terminal {
|
||||
@@ -72,6 +75,7 @@ pub fn create(
|
||||
.font = font,
|
||||
.on_title = cbs.on_title,
|
||||
.on_exit = cbs.on_exit,
|
||||
.on_focus = cbs.on_focus,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
@@ -129,6 +133,16 @@ pub fn create(
|
||||
);
|
||||
w.addController(click.as(gtk.EventController));
|
||||
|
||||
// Watching the property rather than just the click gesture means focus
|
||||
// taken programmatically or by keyboard navigation is reported too.
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
area,
|
||||
*Terminal,
|
||||
&onNotifyHasFocus,
|
||||
self,
|
||||
.{ .detail = "has-focus" },
|
||||
);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@@ -175,6 +189,14 @@ fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
self.on_title(self.ctx, title);
|
||||
}
|
||||
|
||||
fn onNotifyHasFocus(
|
||||
_: *gtk.DrawingArea,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *Terminal,
|
||||
) callconv(.c) void {
|
||||
if (self.widget().hasFocus() != 0) self.on_focus(self.ctx);
|
||||
}
|
||||
|
||||
fn onSessionExit(ctx: ?*anyopaque) void {
|
||||
const self: *Terminal = @ptrCast(@alignCast(ctx.?));
|
||||
self.on_exit(self.ctx);
|
||||
@@ -313,27 +335,17 @@ fn drawFunc(
|
||||
};
|
||||
}
|
||||
|
||||
fn render(self: *Terminal, cr: *cairo.Context, width: c_int, height: c_int) !void {
|
||||
fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
|
||||
const term = &self.session.term;
|
||||
const screen = term.screens.active;
|
||||
|
||||
// Background. Clipped to a rounded rectangle so the terminal reads as an
|
||||
// inset pane next to the sidebar, the way Zen insets web content.
|
||||
// Background. The pane frame around us clips to its own rounded corners,
|
||||
// so this just fills.
|
||||
const default_bg: theme.Rgb = if (term.colors.background.get()) |c|
|
||||
.from(c)
|
||||
else
|
||||
theme.bg;
|
||||
{
|
||||
roundedRect(
|
||||
cr,
|
||||
0,
|
||||
0,
|
||||
@floatFromInt(width),
|
||||
@floatFromInt(height),
|
||||
corner_radius,
|
||||
);
|
||||
cr.clip();
|
||||
|
||||
const r, const g, const b = default_bg.cairoRgb();
|
||||
cr.setSourceRgb(r, g, b);
|
||||
cr.paint();
|
||||
@@ -547,19 +559,6 @@ fn drawCursor(
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace a rounded rectangle as the current path.
|
||||
fn roundedRect(cr: *cairo.Context, x: f64, y: f64, w: f64, h: f64, r: f64) void {
|
||||
const radius = @min(r, @min(w, h) / 2);
|
||||
const pi = std.math.pi;
|
||||
|
||||
cr.newSubPath();
|
||||
cr.arc(x + w - radius, y + radius, radius, -pi / 2.0, 0);
|
||||
cr.arc(x + w - radius, y + h - radius, radius, 0, pi / 2.0);
|
||||
cr.arc(x + radius, y + h - radius, radius, pi / 2.0, pi);
|
||||
cr.arc(x + radius, y + radius, radius, pi, 3.0 * pi / 2.0);
|
||||
cr.closePath();
|
||||
}
|
||||
|
||||
/// Resolve a cell's style into concrete colors and attributes.
|
||||
fn appearance(
|
||||
pin: vt.Pin,
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
//! A view: the content of one tab, holding one or more terminal panes.
|
||||
//!
|
||||
//! Layout is deliberately flat. Panes are an ordered list sharing a single
|
||||
//! orientation for the whole view, rather than a recursive split tree. New
|
||||
//! panes are added side by side; dropping a pane on another pane's top or
|
||||
//! bottom edge restacks the view, and dropping on a side edge puts it back
|
||||
//! into a row.
|
||||
//!
|
||||
//! A flat list can't express mixed layouts (two panes beside a stack of
|
||||
//! three). A split tree can, and is where this goes if that turns out to
|
||||
//! matter, but the tree brings a lot of structure that isn't earning its keep
|
||||
//! for "a shell and an agent in the same worktree".
|
||||
|
||||
const std = @import("std");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Pane = @import("Pane.zig");
|
||||
|
||||
const View = @This();
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
|
||||
/// The container holding the panes. Its orientation is the view's layout:
|
||||
/// `.horizontal` lays panes out left to right, `.vertical` stacks them.
|
||||
box: *gtk.Box,
|
||||
|
||||
panes: std.ArrayListUnmanaged(*Pane) = .empty,
|
||||
|
||||
/// The pane that last had keyboard focus, used for "act on the current
|
||||
/// terminal" operations and for the tab's label.
|
||||
focused: ?*Pane = null,
|
||||
|
||||
/// The pane currently being dragged, if any. Drags never leave the process,
|
||||
/// so the pointer lives here instead of being marshalled through GTK.
|
||||
dragging: ?*Pane = null,
|
||||
|
||||
/// Set while the view is being torn down, so a pane's child exiting doesn't
|
||||
/// try to remove it from a list we're already draining.
|
||||
closing: bool = false,
|
||||
|
||||
/// Raised when the last pane goes away and the tab should close with it.
|
||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
/// Raised when the text a tab should display has changed.
|
||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub const Callbacks = struct {
|
||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
|
||||
const self = try alloc.create(View);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
// Side by side is the default: a shell next to an agent is the case
|
||||
// this exists for, and side-by-side keeps both prompts visible.
|
||||
.box = gtk.Box.new(.horizontal, 6),
|
||||
.on_empty = cbs.on_empty,
|
||||
.on_title = cbs.on_title,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
self.box.as(gtk.Widget).addCssClass("vtabs-view");
|
||||
self.box.as(gtk.Widget).setHexpand(1);
|
||||
self.box.as(gtk.Widget).setVexpand(1);
|
||||
|
||||
// Deliberately created empty. Adding a pane fires on_title, and the
|
||||
// caller's context object is not usable until it has finished wiring
|
||||
// itself to this view.
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *View) void {
|
||||
self.closing = true;
|
||||
for (self.panes.items) |pane| pane.destroy();
|
||||
self.panes.deinit(self.alloc);
|
||||
self.alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn widget(self: *View) *gtk.Widget {
|
||||
return self.box.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Text for the tab label: the focused terminal's title, prefixed with the
|
||||
/// pane count once there is more than one so the sidebar shows the shape of
|
||||
/// the view at a glance.
|
||||
pub fn label(self: *View, buf: []u8) []const u8 {
|
||||
const pane = self.focused orelse (if (self.panes.items.len > 0)
|
||||
self.panes.items[0]
|
||||
else
|
||||
return "");
|
||||
|
||||
const title = pane.titleSlice();
|
||||
if (self.panes.items.len < 2) {
|
||||
const n = @min(title.len, buf.len);
|
||||
@memcpy(buf[0..n], title[0..n]);
|
||||
return buf[0..n];
|
||||
}
|
||||
|
||||
return std.fmt.bufPrint(buf, "{d} · {s}", .{ self.panes.items.len, title }) catch title;
|
||||
}
|
||||
|
||||
/// The terminal that input-level actions (paste, close) should act on.
|
||||
pub fn focusedTerminal(self: *View) ?*@import("Terminal.zig") {
|
||||
const pane = self.focused orelse (if (self.panes.items.len > 0)
|
||||
self.panes.items[0]
|
||||
else
|
||||
return null);
|
||||
return pane.term;
|
||||
}
|
||||
|
||||
/// The pane that pane-level actions should act on.
|
||||
pub fn focusedPane(self: *View) ?*Pane {
|
||||
return self.focused orelse (if (self.panes.items.len > 0)
|
||||
self.panes.items[0]
|
||||
else
|
||||
null);
|
||||
}
|
||||
|
||||
pub fn focus(self: *View) void {
|
||||
const pane = self.focused orelse (if (self.panes.items.len > 0)
|
||||
self.panes.items[0]
|
||||
else
|
||||
return);
|
||||
pane.grabFocus();
|
||||
}
|
||||
|
||||
/// Add a terminal, placed just after the focused pane so a split appears
|
||||
/// next to the terminal you were working in rather than at the far end.
|
||||
pub fn addPane(self: *View) !void {
|
||||
const pane = try Pane.create(self.alloc, self);
|
||||
errdefer pane.destroy();
|
||||
|
||||
const index = if (self.focused) |current|
|
||||
(self.indexOf(current) orelse self.panes.items.len -| 1) + 1
|
||||
else
|
||||
self.panes.items.len;
|
||||
|
||||
try self.panes.insert(self.alloc, index, pane);
|
||||
errdefer _ = self.panes.orderedRemove(index);
|
||||
|
||||
self.box.append(pane.widget());
|
||||
self.syncOrder();
|
||||
self.setFocused(pane);
|
||||
pane.grabFocus();
|
||||
self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
pub fn closePane(self: *View, pane: *Pane) void {
|
||||
if (self.closing) return;
|
||||
const index = self.indexOf(pane) orelse return;
|
||||
|
||||
if (self.dragging == pane) self.dragging = null;
|
||||
|
||||
self.box.remove(pane.widget());
|
||||
_ = self.panes.orderedRemove(index);
|
||||
pane.destroy();
|
||||
|
||||
if (self.focused == pane) self.focused = null;
|
||||
|
||||
if (self.panes.items.len == 0) {
|
||||
self.on_empty(self.ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus whatever took its place, else the new last pane.
|
||||
const next = @min(index, self.panes.items.len - 1);
|
||||
self.setFocused(self.panes.items[next]);
|
||||
self.panes.items[next].grabFocus();
|
||||
self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
pub fn setFocused(self: *View, pane: *Pane) void {
|
||||
if (self.focused == pane) return;
|
||||
if (self.focused) |old| old.setActive(false);
|
||||
self.focused = pane;
|
||||
pane.setActive(true);
|
||||
self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
|
||||
// Only the pane shown in the tab label matters to the sidebar.
|
||||
if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx);
|
||||
}
|
||||
|
||||
/// Move the focused pane one place in the given direction.
|
||||
///
|
||||
/// This is the keyboard equivalent of dragging a pane, and deliberately goes
|
||||
/// through the same `moveRelative` path so both routes rearrange the view
|
||||
/// identically. As with a drop, the direction also sets the orientation, so
|
||||
/// pushing a pane down restacks the view even when it is already last.
|
||||
pub fn moveFocused(self: *View, zone: Pane.Zone) void {
|
||||
const pane = self.focusedPane() orelse return;
|
||||
const index = self.indexOf(pane) orelse return;
|
||||
|
||||
const neighbor: ?*Pane = if (zone.isBefore())
|
||||
(if (index > 0) self.panes.items[index - 1] else null)
|
||||
else
|
||||
(if (index + 1 < self.panes.items.len) self.panes.items[index + 1] else null);
|
||||
|
||||
if (neighbor) |target| {
|
||||
self.moveRelative(pane, target, zone);
|
||||
} else {
|
||||
// Already at that end: there is nothing to swap with, but the
|
||||
// requested direction still describes how the view should be laid out.
|
||||
self.setOrientation(zone.orientation());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `moving` next to `target`, on the side named by `zone`. The zone also
|
||||
/// decides the view's orientation, which is what makes dragging a pane to the
|
||||
/// bottom edge restack the whole view.
|
||||
pub fn moveRelative(self: *View, moving: *Pane, target: *Pane, zone: Pane.Zone) void {
|
||||
if (moving == target) return;
|
||||
const from = self.indexOf(moving) orelse return;
|
||||
|
||||
_ = self.panes.orderedRemove(from);
|
||||
|
||||
// Recompute after the removal: taking `moving` out may have shifted it.
|
||||
const target_index = self.indexOf(target) orelse {
|
||||
// Should not happen, but rather than lose the pane, put it back.
|
||||
self.panes.insert(self.alloc, from, moving) catch {};
|
||||
return;
|
||||
};
|
||||
|
||||
const to = if (zone.isBefore()) target_index else target_index + 1;
|
||||
self.panes.insert(self.alloc, to, moving) catch {
|
||||
self.panes.insert(self.alloc, from, moving) catch {};
|
||||
return;
|
||||
};
|
||||
|
||||
self.setOrientation(zone.orientation());
|
||||
self.syncOrder();
|
||||
}
|
||||
|
||||
fn setOrientation(self: *View, orientation: gtk.Orientation) void {
|
||||
self.box.as(gtk.Orientable).setOrientation(orientation);
|
||||
}
|
||||
|
||||
/// Reorder the box's children to match `panes`. GtkBox can reorder in place,
|
||||
/// so panes never get unparented and their terminals keep running untouched.
|
||||
fn syncOrder(self: *View) void {
|
||||
var previous: ?*gtk.Widget = null;
|
||||
for (self.panes.items) |pane| {
|
||||
self.box.reorderChildAfter(pane.widget(), previous);
|
||||
previous = pane.widget();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clearHints(self: *View) void {
|
||||
for (self.panes.items) |pane| pane.showHint(null);
|
||||
}
|
||||
|
||||
fn indexOf(self: *View, pane: *Pane) ?usize {
|
||||
for (self.panes.items, 0..) |p, i| if (p == pane) return i;
|
||||
return null;
|
||||
}
|
||||
+74
-27
@@ -14,7 +14,8 @@ const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
const vt = @import("ghostty-vt");
|
||||
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const Pane = @import("Pane.zig");
|
||||
const View = @import("View.zig");
|
||||
|
||||
const Window = @This();
|
||||
|
||||
@@ -42,10 +43,11 @@ updating: bool = false,
|
||||
/// doesn't try to close a tab we're already destroying.
|
||||
closing: bool = false,
|
||||
|
||||
/// A single tab: the terminal plus the sidebar row that selects it.
|
||||
/// A single tab: a view of one or more terminals, plus the sidebar row that
|
||||
/// selects it.
|
||||
const Tab = struct {
|
||||
window: *Window,
|
||||
term: *Terminal,
|
||||
view: *View,
|
||||
row: *gtk.ListBoxRow,
|
||||
label: *gtk.Label,
|
||||
name: [16]u8,
|
||||
@@ -161,7 +163,7 @@ pub fn present(self: *Window) void {
|
||||
// grabFocus during construction silently does nothing because the
|
||||
// widget is not yet realized, which would send the first keystroke to
|
||||
// the sidebar instead of the terminal.
|
||||
if (self.activeTab()) |tab| tab.term.grabFocus();
|
||||
if (self.activeTab()) |tab| tab.view.focus();
|
||||
}
|
||||
|
||||
/// Open a new tab and switch to it.
|
||||
@@ -169,19 +171,19 @@ pub fn newTab(self: *Window) !void {
|
||||
const tab = try self.alloc.create(Tab);
|
||||
errdefer self.alloc.destroy(tab);
|
||||
|
||||
const term = try Terminal.create(self.alloc, .{
|
||||
.on_title = &onTabTitle,
|
||||
.on_exit = &onTabExit,
|
||||
const view = try View.create(self.alloc, .{
|
||||
.on_empty = &onViewEmpty,
|
||||
.on_title = &onViewTitle,
|
||||
.ctx = tab,
|
||||
});
|
||||
errdefer term.destroy();
|
||||
errdefer view.destroy();
|
||||
|
||||
const id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
tab.* = .{
|
||||
.window = self,
|
||||
.term = term,
|
||||
.view = view,
|
||||
.row = gtk.ListBoxRow.new(),
|
||||
.label = gtk.Label.new("shell"),
|
||||
.name = undefined,
|
||||
@@ -211,9 +213,15 @@ pub fn newTab(self: *Window) !void {
|
||||
tab.row.setChild(row_box.as(gtk.Widget));
|
||||
self.list.append(tab.row.as(gtk.Widget));
|
||||
|
||||
_ = self.stack.addNamed(term.widget(), tab.pageName());
|
||||
_ = self.stack.addNamed(view.widget(), tab.pageName());
|
||||
|
||||
try self.tabs.append(self.alloc, tab);
|
||||
|
||||
// Only now is `tab` complete enough for the view's callbacks to use, so
|
||||
// this is the first safe moment to give the view its terminal.
|
||||
try view.addPane();
|
||||
|
||||
self.refreshLabel(tab);
|
||||
self.select(tab);
|
||||
}
|
||||
|
||||
@@ -224,7 +232,7 @@ fn select(self: *Window, tab: *Tab) void {
|
||||
|
||||
self.stack.setVisibleChildName(tab.pageName());
|
||||
self.list.selectRow(tab.row);
|
||||
tab.term.grabFocus();
|
||||
tab.view.focus();
|
||||
}
|
||||
|
||||
fn indexOf(self: *Window, tab: *Tab) ?usize {
|
||||
@@ -237,11 +245,11 @@ fn closeTab(self: *Window, tab: *Tab) void {
|
||||
if (self.closing) return;
|
||||
const index = self.indexOf(tab) orelse return;
|
||||
|
||||
self.stack.remove(tab.term.widget());
|
||||
self.stack.remove(tab.view.widget());
|
||||
self.list.remove(tab.row.as(gtk.Widget));
|
||||
_ = self.tabs.orderedRemove(index);
|
||||
|
||||
tab.term.destroy();
|
||||
tab.view.destroy();
|
||||
self.alloc.destroy(tab);
|
||||
|
||||
if (self.tabs.items.len == 0) {
|
||||
@@ -279,21 +287,30 @@ fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv
|
||||
}
|
||||
}
|
||||
|
||||
fn onTabTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||
/// Refresh a sidebar row from its view's current state.
|
||||
fn refreshLabel(self: *Window, tab: *Tab) void {
|
||||
_ = self;
|
||||
|
||||
// GTK needs a NUL-terminated string, and titles from the terminal are
|
||||
// arbitrary length, so clamp to something a sidebar row can show.
|
||||
var buf: [128]u8 = undefined;
|
||||
const n = @min(title.len, buf.len - 1);
|
||||
@memcpy(buf[0..n], title[0..n]);
|
||||
buf[n] = 0;
|
||||
// GTK needs a NUL-terminated string, and titles come from the terminal so
|
||||
// they can be any length; clamp to what a sidebar row can show.
|
||||
var scratch: [192]u8 = undefined;
|
||||
const text = tab.view.label(scratch[0 .. scratch.len - 1]);
|
||||
|
||||
tab.label.setText(buf[0..n :0]);
|
||||
tab.label.as(gtk.Widget).setTooltipText(buf[0..n :0]);
|
||||
var buf: [192]u8 = undefined;
|
||||
@memcpy(buf[0..text.len], text);
|
||||
buf[text.len] = 0;
|
||||
|
||||
tab.label.setText(buf[0..text.len :0]);
|
||||
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
|
||||
}
|
||||
|
||||
fn onTabExit(ctx: ?*anyopaque) void {
|
||||
fn onViewTitle(ctx: ?*anyopaque) void {
|
||||
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||
tab.window.refreshLabel(tab);
|
||||
}
|
||||
|
||||
/// The view lost its last pane, so the tab goes with it.
|
||||
fn onViewEmpty(ctx: ?*anyopaque) void {
|
||||
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||
tab.window.closeTab(tab);
|
||||
}
|
||||
@@ -306,7 +323,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
||||
// Each terminal owns a session, which owns a PTY and its child process.
|
||||
// Dropping them here reaps the children rather than orphaning them.
|
||||
for (self.tabs.items) |tab| {
|
||||
tab.term.destroy();
|
||||
tab.view.destroy();
|
||||
self.alloc.destroy(tab);
|
||||
}
|
||||
self.tabs.deinit(self.alloc);
|
||||
@@ -360,7 +377,19 @@ fn onShortcut(
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_W, gdk.KEY_w => {
|
||||
if (self.activeTab()) |tab| self.closeTab(tab);
|
||||
// Closes the focused terminal. The view raises on_empty when
|
||||
// its last pane goes, which is what closes the tab.
|
||||
if (self.activeTab()) |tab| {
|
||||
if (tab.view.focusedPane()) |pane| tab.view.closePane(pane);
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_E, gdk.KEY_e => {
|
||||
if (self.activeTab()) |tab| {
|
||||
tab.view.addPane() catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_V, gdk.KEY_v => {
|
||||
@@ -371,6 +400,23 @@ fn onShortcut(
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+arrows rearrange the focused terminal within its view. This
|
||||
// is the keyboard route to the same rearranging that dragging a pane's
|
||||
// header does.
|
||||
if (ctrl and shift) {
|
||||
const zone: ?Pane.Zone = switch (keyval) {
|
||||
gdk.KEY_Left => .left,
|
||||
gdk.KEY_Right => .right,
|
||||
gdk.KEY_Up => .top,
|
||||
gdk.KEY_Down => .bottom,
|
||||
else => null,
|
||||
};
|
||||
if (zone) |z| {
|
||||
if (self.activeTab()) |tab| tab.view.moveFocused(z);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals.
|
||||
if (ctrl and !shift) {
|
||||
switch (keyval) {
|
||||
@@ -434,7 +480,8 @@ fn onPasteReady(
|
||||
defer glib.free(text);
|
||||
|
||||
const tab = self.activeTab() orelse return;
|
||||
const session = tab.term.session;
|
||||
const terminal = tab.view.focusedTerminal() orelse return;
|
||||
const session = terminal.session;
|
||||
// Coerce to a plain slice: encodePaste dispatches on the exact type.
|
||||
const span: []const u8 = std.mem.span(text);
|
||||
|
||||
|
||||
+58
-1
@@ -60,7 +60,64 @@
|
||||
}
|
||||
|
||||
.vtabs-content {
|
||||
background-color: #0f0d14;
|
||||
}
|
||||
|
||||
/* A view is the container for one tab's terminals. */
|
||||
.vtabs-view {
|
||||
padding: 6px 6px 6px 0;
|
||||
}
|
||||
|
||||
/* Each terminal sits in its own rounded frame so multiple panes in a view
|
||||
read as distinct surfaces. */
|
||||
.vtabs-pane {
|
||||
background-color: #16141c;
|
||||
border-radius: 10px;
|
||||
margin: 6px 6px 6px 0;
|
||||
border: 1px solid #2a2536;
|
||||
}
|
||||
|
||||
.vtabs-pane.active {
|
||||
border-color: #5b4d80;
|
||||
}
|
||||
|
||||
.vtabs-pane-header {
|
||||
padding: 2px 4px 2px 10px;
|
||||
background-color: #1c1926;
|
||||
border-bottom: 1px solid #262133;
|
||||
}
|
||||
|
||||
.vtabs-pane.active .vtabs-pane-header {
|
||||
background-color: #241f33;
|
||||
}
|
||||
|
||||
.vtabs-pane-title {
|
||||
font-size: 0.82em;
|
||||
color: #8f87a3;
|
||||
}
|
||||
|
||||
.vtabs-pane.active .vtabs-pane-title {
|
||||
color: #ded7ef;
|
||||
}
|
||||
|
||||
.vtabs-pane-button {
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.vtabs-pane:hover .vtabs-pane-button,
|
||||
.vtabs-pane.active .vtabs-pane-button {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.vtabs-pane-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Drop indicator: an inset bar on the edge the pane would land against, so
|
||||
the preview also tells you which way the view is about to be arranged. */
|
||||
.vtabs-pane.drop-left { box-shadow: inset 4px 0 0 0 #b29df5; }
|
||||
.vtabs-pane.drop-right { box-shadow: inset -4px 0 0 0 #b29df5; }
|
||||
.vtabs-pane.drop-top { box-shadow: inset 0 4px 0 0 #b29df5; }
|
||||
.vtabs-pane.drop-bottom { box-shadow: inset 0 -4px 0 0 #b29df5; }
|
||||
|
||||
Reference in New Issue
Block a user