diff --git a/README.md b/README.md index 5a39a71..8003cfc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Layout.zig b/src/Layout.zig index 4130bbb..85f51fb 100644 --- a/src/Layout.zig +++ b/src/Layout.zig @@ -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 => {}, diff --git a/src/Pane.zig b/src/Pane.zig index 72d2362..d1c1661 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -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); } diff --git a/src/View.zig b/src/View.zig index c3a1300..ca69519 100644 --- a/src/View.zig +++ b/src/View.zig @@ -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 { diff --git a/src/Window.zig b/src/Window.zig index 3f0f699..b053ab4 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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 diff --git a/src/style.css b/src/style.css index 0e2dee6..81f380f 100644 --- a/src/style.css +++ b/src/style.css @@ -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 {