Design for replacing the two-scalar layout (pty_pct + roster_width) with a binary space-partition pane tree so every pane is resizable on both axes, fixing the "only roster width adjusts" limitation. Rolls a small in-repo tree (no new dep) and drives the PTY grid from the terminal pane's actual Rect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.1 KiB
Spec: BSP pane layout (resize every pane in both dimensions)
Status: draft / in progress · Branch: feat/bsp-pane-layout
Motivation
Today the window layout is two scalars over a fixed 3-pane arrangement
(layout::Layout { pty_pct, roster_width, zoom }):
pty_pct— sandbox terminal's share of body height (chat takes the rest).roster_width— roster column width in cells.
Consequences the user hit:
- No sandbox → almost nothing adjustable.
body_areasonly creates the chat/terminal vertical splitif app.sandbox.is_some(). Without a sandbox the chat owns the whole body, so the only live dimension is the roster width. The↑/↓keys still callgrow_pty/shrink_pty, but with no terminal pane to trade height against, nothing moves. - Each pane is locked to one axis. The terminal only changes height (always full width); the roster only changes width (always full height). You can't, e.g., make the terminal narrower or give the roster its own height.
Goal: let the user resize each pane in both width and height, using the
standard BSP (binary space-partition) pane-tree model (tmux/i3-style).
Researched in chat: ratatui core has no interactive-resize/BSP primitive (only
the Cassowary/kasuari solver + the "store-percent, mutate, re-solve" pattern);
the ratkit crate's ResizableGrid proves the BSP-tree approach. We roll a
small in-repo tree (option 2 from the research) so the PTY-resync integration
stays first-class and we take no new dependency.
Model
Replace the two scalars with a binary tree. Leaves are the three semantic panes; internal nodes are splits with an axis + ratio.
pub enum PaneKind { Chat, Roster, Terminal }
pub enum Axis { Horizontal, Vertical } // H: side-by-side (|), V: stacked (—)
pub enum Node {
Leaf(PaneKind),
Split { axis: Axis, ratio: u16, a: Box<Node>, b: Box<Node> },
// `ratio` = percent of the split given to `a` (10..=90).
}
pub struct Layout {
root: Node,
zoom: Zoom, // unchanged: Normal | Term | Chat (F4 fullscreen)
roster_seed: u16, // theme-provided default roster width, for rebuilds
}
Default arrangement (reproduces today's look)
With a sandbox present:
Split(H, ratio=roster_split, // left column | roster (right, full height)
a = Split(V, ratio=chat_pct, // chat (top) / terminal (bottom)
a = Leaf(Chat),
b = Leaf(Terminal)),
b = Leaf(Roster))
Without a sandbox the Terminal leaf is absent, so the tree collapses to:
Split(H, ratio=roster_split, a = Leaf(Chat), b = Leaf(Roster))
The tree is rebuilt (preserving ratios where possible) when the Terminal pane
appears (/sbx launch) or disappears (/sbx stop), and when the roster is
hidden/shown. Ratios are stored as percentages and clamped to [10, 90].
Geometry (single source of truth)
layout.rs owns recursive area computation; ui.rs::body_areas becomes a thin
wrapper so draw and pane_at keep sharing one geometry.
pub struct Regions { // computed for a given body Rect
pub panes: Vec<(PaneKind, Rect)>,// one per visible leaf
pub dividers: Vec<Divider>, // for mouse hit-testing (phase 5)
}
pub fn regions(&self, body: Rect) -> Regions;
pub fn rect_of(&self, body: Rect, kind: PaneKind) -> Option<Rect>;
Zoom::Term/Zoom::Chat short-circuit regions exactly like today (one pane
fills the body; the other leaves are omitted).
Resize semantics (keyboard)
A pane is selected via F5 (cycle) or mouse click (pane_at). Then arrows
resize the nearest ancestor split on the matching axis:
↑/↓→ nearest ancestorSplit{axis: Vertical}of the focused leaf; grow/shrink that leaf's side of the split.←/→→ nearest ancestorSplit{axis: Horizontal}.
This makes every pane adjustable on both axes wherever such an ancestor
exists. If no ancestor on that axis exists (e.g. only Chat+Roster, press ↑),
emit a one-line hint instead of silently doing nothing — fixes consequence (1).
pub fn resize_focused(&mut self, pane: PaneKind, dir: Dir, step: u16) -> ResizeOutcome;
// ResizeOutcome::Moved | ::NoAxisHere (caller turns the latter into a sys hint)
PTY integration (the critical wrinkle)
Today sbx_dims(term_w, term_h, pty_pct) derives the PTY grid from the height
scalar and assumes full width. In the tree model the terminal can be any
rect, so the grid must come from the Terminal leaf's actual Rect:
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> Option<(u16, u16)> {
let body = body_rect(term_w, term_h); // minus top bar + input
layout.rect_of(body, PaneKind::Terminal)
.map(|r| (r.height.saturating_sub(2).max(1), r.width.saturating_sub(2).max(1)))
}
Every existing sbx_dims(...) call site (run loop ~926, command handlers
~1865/1971) switches to sbx_grid, and announced_dims = None still forces a
re-sync after any resize — so the shared PTY rebroadcasts new dims to the room
exactly as before. This now also lets terminal width changes propagate,
which the old code couldn't express.
Persistence (presets)
/layout save|load|list|rm|reset keep working; the on-disk TOML now serializes
the tree instead of two scalars (layouts/<slug>.toml). Old single-scalar
preset files are not migrated (presets are throwaway; reset rebuilds default).
serde derives on Node/Axis/PaneKind handle (de)serialization.
Out of scope (phase 5, optional, follow-up)
- Mouse-drag dividers.
Regions.dividers+hit_thresholdare scaffolded now; drag handling (hover highlight,dragging_split) lands later. Keyboard resize is the phase-1..4 deliverable. - Arbitrary user-created splits / moving a pane to a new position. The tree supports it, but the UI only exposes the three named panes for now.
Work plan
- layout.rs — tree types, default/rebuild builders,
regions/rect_of,resize_focused, clamps, zoom,describe, serde, presets. Unit tests (geometry sums to body, resize honors clamps, no-axis case, serde round-trip, sandbox add/remove rebuild). - ui.rs —
body_areas→treeregions;pane_atviaregions; draw each leaf;edit_decormarker on the focused pane. - app.rs —
sbx_gridreplacessbx_dims; key handler resizes both axes viaresize_focused(+ hint onNoAxisHere);cycle_focusover present panes; F4 zoom +announced_dimsre-sync unchanged. - docs — in-app
/helpLAYOUT cluster + README "Window layout" updated to describe both-axis resize and the no-axis hint. - (optional) mouse-drag dividers.
Acceptance
cargo testgreen;cargo buildwarning-free.- No sandbox: selecting Chat and pressing
←/→resizes vs. roster;↑/↓prints the "no pane to trade height with — launch a sandbox" hint. - Sandbox up: Chat/Terminal resize in both axes; roster width still works; terminal width changes now re-sync the PTY grid to the room.
/layout save+/layout loadround-trips the new tree.- F4 fullscreen +
Ctrl+Rreconnect re-announce unchanged.