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
+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 {