Add focus button.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 => {},
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user