Add focus button.

This commit is contained in:
Greyson Parrelli
2026-08-12 08:05:23 -04:00
parent dd60e52005
commit dca5ec0b7d
6 changed files with 218 additions and 7 deletions
+29
View File
@@ -283,6 +283,9 @@ in principle, but a terminal grid is small.
[Agent status](#agent-status)
- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row.
A typed name pins the label; clearing it hands the label back to the panes
- **Zooming a pane** to fill its tab and back, from the header button or
`Ctrl+Shift+Z`. Nothing closes and nothing moves — hidden panes keep running
and the split comes back exactly as it was. See [Zoom](#zoom)
### Shortcuts
@@ -295,6 +298,7 @@ in principle, but a terminal grid is small.
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
| `Ctrl+PageUp/PageDown` | previous / next tab |
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
@@ -306,6 +310,31 @@ competes with the content's own mouse handling.
Both add-pane shortcuts are also buttons in every pane header, which is how you
open a web view without remembering the chord.
## Zoom
A split that was the right shape for watching two things at once is usually the
wrong shape for actually working in one of them. `Ctrl+Shift+Z`, or the
fullscreen button in a pane's header, gives that pane the whole tab; the same
again puts the split back.
This is not the window manager's fullscreen. The window keeps its decorations,
the sidebar stays put, and only the tab's own content area is involved.
Nothing closes and nothing is rearranged. The split tree is left exactly as it
was and only the rendering changes, which is what lets the restore be exact
rather than an approximation — ratios, orientations and ordering all come back
untouched, because they were never taken apart. Hidden panes are unparented but
still alive: their shells keep running, output produced while they were off
screen is waiting when they return, and their grids keep the size they had
rather than reflowing to nothing.
The toggle only appears once a tab holds more than one pane, since zooming the
only pane in a view would be an invisible state change. Splitting a pane or
moving one leaves zoom, because both of those actions are about the arrangement
that zoom is hiding. Closing the zoomed pane leaves zoom; a *different* pane
closing — a background shell exiting, say — does not, since what you are looking
at is still there.
## Agent status
Playpen is mostly used to keep several Claude Code sessions side by side, and
+21
View File
@@ -269,6 +269,27 @@ pub fn materialize(self: *Layout, box: *gtk.Box) void {
box.append(root.widget());
}
/// Mount a single pane filling `box`, hiding the rest of the tree.
///
/// The tree itself is left completely alone — no node is moved, removed or
/// reparented in the model — so this is purely a change of what is on screen
/// and `materialize` puts the original arrangement back exactly. That is the
/// whole reason zoom is done here rather than by restructuring: an arrangement
/// that had to be taken apart and rebuilt would have to preserve split ratios,
/// orientations and ordering by hand, and would drift.
///
/// Detaching the whole tree first is what frees the zoomed pane from the paned
/// holding it. The other panes end up parentless for the duration, which is
/// harmless: every pane holds a strong reference to its own widget, and a
/// terminal's PTY is watched on the main loop rather than driven by its
/// widget, so a hidden pane's shell keeps running and simply redraws from the
/// terminal state it accumulated when it comes back.
pub fn materializeZoom(self: *Layout, box: *gtk.Box, pane: *Pane) void {
if (box.as(gtk.Widget).getFirstChild()) |child| box.remove(child);
if (self.root) |root| detach(root);
box.append(pane.widget());
}
fn detach(node: *Node) void {
switch (node.kind) {
.leaf => {},
+49
View File
@@ -127,6 +127,11 @@ const all_sides = [_]Side{ .left, .right, .top, .bottom };
/// The dot itself. A filled circle at whatever size the context asks for.
pub const status_icon = "media-record-symbolic";
/// Zoom toggle icons. Deliberately not the system-fullscreen pair's meaning:
/// this fills the tab, not the screen, and the window keeps its decorations.
const zoom_icon = "view-fullscreen-symbolic";
const restore_icon = "view-restore-symbolic";
/// Every class a dot can carry. Listed so that applying one can clear the
/// others without the caller having to remember what it set last.
pub const status_classes = [_][:0]const u8{
@@ -182,6 +187,10 @@ box: *gtk.Box,
header: *gtk.Box,
label: *gtk.Label,
/// Zoom toggle, kept so its icon can follow the view's zoom state and so it
/// can be hidden in a view too small for zoom to mean anything.
zoom: *gtk.Button,
/// Status dot in the header, hidden while the pane is idle. This is the
/// per-pane counterpart of the dot on the sidebar row: with several panes in
/// a tab, the row tells you the tab needs attention and this tells you which
@@ -209,6 +218,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
.header = gtk.Box.new(.horizontal, 4),
.label = gtk.Label.new(""),
.dot = gtk.Image.newFromIconName(status_icon),
.zoom = gtk.Button.newFromIconName(zoom_icon),
};
setTitle(self, kind.initialTitle());
@@ -328,6 +338,20 @@ fn buildHeader(self: *Pane) void {
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
header.append(web.as(gtk.Widget));
// Last before close, so the destructive button stays on the end where it
// is expected and the zoom toggle sits with the other view controls.
self.zoom.as(gtk.Widget).addCssClass("flat");
self.zoom.as(gtk.Widget).addCssClass("playpen-pane-button");
_ = gtk.Button.signals.clicked.connect(self.zoom, *Pane, &onZoomClicked, self, .{});
header.append(self.zoom.as(gtk.Widget));
// Starts hidden. A pane is created as the only one in its view often
// enough — a new tab, the first pane of a layout — that showing a control
// which cannot do anything yet would be the common case, not the corner
// one. The view turns it on as soon as there is a second pane.
self.setZoomed(false);
self.setZoomAvailable(false);
const close = gtk.Button.newFromIconName("window-close-symbolic");
close.as(gtk.Widget).addCssClass("flat");
close.as(gtk.Widget).addCssClass("playpen-pane-button");
@@ -336,6 +360,27 @@ fn buildHeader(self: *Pane) void {
header.append(close.as(gtk.Widget));
}
/// Show this pane as the zoomed one, or as an ordinary pane again.
pub fn setZoomed(self: *Pane, on: bool) void {
self.zoom.setIconName(if (on) restore_icon else zoom_icon);
self.zoom.as(gtk.Widget).setTooltipText(if (on)
"Restore the other panes (Ctrl+Shift+Z)"
else
"Fill the tab with this pane (Ctrl+Shift+Z)");
if (on) {
self.widget().addCssClass("zoomed");
} else {
self.widget().removeCssClass("zoomed");
}
}
/// Hide the toggle in a view holding only this pane, where it would have
/// nothing to do.
pub fn setZoomAvailable(self: *Pane, available: bool) void {
self.zoom.as(gtk.Widget).setVisible(if (available) 1 else 0);
}
// -------------------------------------------------------------------------
// Drag and drop
//
@@ -512,6 +557,10 @@ fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
};
}
fn onZoomClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.toggleZoom(self);
}
fn onCloseClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
self.view.closePane(self);
}
+104 -7
View File
@@ -65,6 +65,13 @@ panes: std.ArrayListUnmanaged(*Pane) = .empty,
focused: ?*Pane = null,
/// The pane temporarily filling the view, if any.
///
/// This is a display state and nothing more — the split tree is untouched
/// while it is set, so leaving zoom restores the arrangement exactly rather
/// than reconstructing it.
zoomed: ?*Pane = null,
drag: ?Drag = null,
/// Set while the view is being torn down, so a pane's child exiting doesn't
@@ -119,6 +126,70 @@ pub fn widget(self: *View) *gtk.Widget {
return self.box.as(gtk.Widget);
}
// -------------------------------------------------------------------------
// Zoom
//
// One pane temporarily filling the tab, for when a split is too cramped to
// work in. Nothing closes and nothing moves: the tree is left exactly as it
// was and only the rendering changes, so leaving zoom restores the previous
// arrangement rather than rebuilding an approximation of it.
/// Put the view on screen. Every structural change goes through here rather
/// than calling the layout directly, so zoom is honoured from one place
/// instead of being re-checked at each call site.
fn render(self: *View) void {
if (self.zoomed) |pane| {
self.layout.materializeZoom(self.box, pane);
} else {
self.layout.materialize(self.box);
}
}
/// Toggle `pane` filling the view.
///
/// A single-pane view has nothing to hide, so zooming it would be an
/// invisible state change that leaves the button looking wrong; refuse
/// instead.
pub fn toggleZoom(self: *View, pane: *Pane) void {
if (self.zoomed == pane) {
self.zoomed = null;
} else {
if (self.panes.items.len < 2) return;
self.zoomed = pane;
}
self.render();
self.refreshZoomChrome();
// Re-rendering reparents the pane, which drops keyboard focus on the way
// through, so it has to be taken again afterwards.
pane.grabFocus();
self.on_title(self.ctx);
}
pub fn toggleZoomFocused(self: *View) void {
if (self.focusedPane()) |pane| self.toggleZoom(pane);
}
/// Leave zoom, if we are in it. Used by everything that changes the shape of
/// the view: the point of those actions is the arrangement, and staying zoomed
/// would hide the very thing the user just asked for.
fn clearZoom(self: *View) void {
if (self.zoomed == null) return;
self.zoomed = null;
self.refreshZoomChrome();
}
/// Point every pane's zoom button at the current state: which pane (if any) is
/// zoomed, and whether zooming means anything in a view this size.
fn refreshZoomChrome(self: *View) void {
const available = self.panes.items.len > 1;
for (self.panes.items) |pane| {
pane.setZoomAvailable(available);
pane.setZoomed(self.zoomed == pane);
}
}
/// Text for the tab label: the focused pane's title, prefixed with the pane
/// count once there is more than one.
pub fn label(self: *View, buf: []u8) []const u8 {
@@ -172,7 +243,12 @@ pub fn addPane(self: *View, spec: Pane.Spec) !void {
}
try self.panes.append(self.alloc, pane);
self.layout.materialize(self.box);
// Splitting while zoomed would put the new pane somewhere you cannot see,
// so opening one leaves zoom.
self.zoomed = null;
self.render();
self.refreshZoomChrome();
self.setFocused(pane);
pane.grabFocus();
@@ -203,6 +279,11 @@ pub fn closePane(self: *View, pane: *Pane) void {
_ = self.panes.orderedRemove(index);
if (self.focused == pane) self.focused = null;
// The zoomed pane going away takes the zoom with it. A different pane
// closing — a background shell exiting, say — leaves it standing, since
// what you are looking at is still there and still what you asked for.
if (self.zoomed == pane) self.zoomed = null;
// Detach the pane's widget before destroying it so materialize doesn't
// walk into a half-freed subtree.
if (pane.widget().getParent() != null) pane.widget().unparent();
@@ -213,7 +294,8 @@ pub fn closePane(self: *View, pane: *Pane) void {
return;
}
self.layout.materialize(self.box);
self.render();
self.refreshZoomChrome();
const next = @min(index, self.panes.items.len - 1);
self.setFocused(self.panes.items[next]);
@@ -283,7 +365,7 @@ pub fn applyLayout(
const root = try self.buildNode(spec, bindings);
self.layout.root = root;
root.parent = null;
self.layout.materialize(self.box);
self.render();
// The first pane in tree order is the top-left one, which is where you
// would start reading the tab and so where focus belongs.
@@ -291,6 +373,10 @@ pub fn applyLayout(
self.setFocused(self.panes.items[0]);
self.panes.items[0].grabFocus();
}
// Panes are built one at a time and each starts assuming it is alone, so
// a multi-pane layout has to be told once it is fully assembled.
self.refreshZoomChrome();
self.on_title(self.ctx);
}
@@ -426,7 +512,7 @@ pub fn moveTo(self: *View, moving: *Pane, target: Target, ratio: f64) void {
(self.layout.root orelse {
self.layout.root = node;
node.parent = null;
self.layout.materialize(self.box);
self.render();
return;
});
@@ -435,7 +521,7 @@ pub fn moveTo(self: *View, moving: *Pane, target: Target, ratio: f64) void {
return;
};
self.layout.materialize(self.box);
self.render();
}
/// Last-resort reattachment so a pane can never be orphaned by a failed move.
@@ -451,7 +537,7 @@ fn reattachAtRoot(self: *View, node: *Layout.Node) void {
self.layout.root = node;
node.parent = null;
}
self.layout.materialize(self.box);
self.render();
}
/// Move the focused pane one step in a direction: the keyboard equivalent of
@@ -460,6 +546,12 @@ pub fn moveFocused(self: *View, side: Side) void {
const pane = self.focusedPane() orelse return;
if (self.panes.items.len < 2) return;
// Rearranging is about where panes sit relative to each other, which is
// exactly what zoom is hiding. Leave it so the move can be seen — and so
// `neighbor` below has laid-out panes to probe for in the first place.
self.clearZoom();
self.render();
const target = self.neighbor(pane, side) orelse {
// Nothing that way, so place it against the view edge instead and let
// it span the layout on that side.
@@ -508,6 +600,11 @@ fn neighbor(self: *View, pane: *Pane, side: Side) ?*Pane {
pub fn beginDrag(self: *View, pane: *Pane) bool {
if (self.panes.items.len < 2) return false;
// Only the zoomed pane is on screen, so there is nothing to drop against
// and no preview to show. Refusing the drag is better than starting one
// that can only ever be cancelled.
if (self.zoomed != null) return false;
const node = self.layout.find(pane) orelse return false;
const position = Layout.positionOf(node) orelse return false;
@@ -555,7 +652,7 @@ pub fn endDrag(self: *View) void {
self.reattachAtRoot(node);
return;
};
self.layout.materialize(self.box);
self.render();
}
fn indexOf(self: *View, pane: *Pane) ?usize {
+4
View File
@@ -953,6 +953,10 @@ fn onShortcut(
if (self.activeTab()) |tab| self.beginRename(tab);
return 1;
},
gdk.KEY_Z, gdk.KEY_z => {
if (self.activeTab()) |tab| tab.view.toggleZoomFocused();
return 1;
},
gdk.KEY_V, gdk.KEY_v => {
// Only a terminal needs us to encode a paste for it. A web
// pane has its own clipboard handling, so the key is left
+11
View File
@@ -219,6 +219,17 @@
border-color: #5b4d80;
}
/* The pane currently filling its tab. The other panes are still open and still
running, just not drawn, so this is marked quietly — a lit border rather than
anything alarming. The header's toggle icon carries the rest of the message. */
.playpen-pane.zoomed {
border-color: #7a68a8;
}
.playpen-pane.zoomed .playpen-pane-button {
opacity: 0.55;
}
/* The pane being dragged. There is no separate drop indicator: the layout
rearranges live during the drag, so the view itself is the preview. */
.playpen-pane.dragging {