Panes, Windows, and Layout
A pane is a PTY plus a terminal plus a rectangle. A window is a layout tree of panes. A session is a list of windows. This chapter covers the tree, the geometry, compositing, and — the genuinely hard part — resize propagation when multiple clients of different sizes are watching.
The Hierarchy
SESSION "work" ← survives detach; has a name
├── WINDOW 0 "editor" ← one full-screen layout; like a tab
│ └── LAYOUT TREE
│ └── Split{ Vertical, 0.6 }
│ ├── Leaf(Pane 0) ← 60% of the width
│ └── Split{ Horizontal, 0.5 }
│ ├── Leaf(Pane 1) ← 40% wide, 50% tall
│ └── Leaf(Pane 2)
└── WINDOW 1 "logs"
└── Leaf(Pane 3)
PANE = { PTY master, child pid, Terminal (headless), rectangle, id }
#![allow(unused)] fn main() { pub enum Layout { Leaf(PaneId), Split { direction: SplitDirection, // Vertical splits LEFT|RIGHT; Horizontal splits TOP/BOTTOM /// Fraction of the parent given to the FIRST child. Storing a ratio /// rather than absolute sizes means the layout survives a resize /// proportionally, which is what users expect. ratio: f32, children: Box<(Layout, Layout)>, }, } }
Note: The naming trips everyone. tmux's
split-window -hproduces a horizontal split meaning the panes sit side by side — the divider is vertical. This book usesSplitDirection::Verticalto mean "the divider is vertical, so panes are left/right." Pick one convention, write it in a doc comment, and never change it.
Geometry: The Off-By-One Farm
#![allow(unused)] fn main() { #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct Rect { pub row: usize, pub col: usize, pub rows: usize, pub cols: usize } impl Layout { /// Assign a rectangle to every pane. The BORDER is what makes this fiddly: /// a 1-cell divider between panes must come out of the total, and integer /// division must not lose or duplicate a cell. pub fn compute(&self, area: Rect, out: &mut HashMap<PaneId, Rect>) { match self { Layout::Leaf(id) => { out.insert(*id, area); } Layout::Split { direction, ratio, children } => { let (a, b) = match direction { SplitDirection::Vertical => { // One column of border between the two panes. let usable = area.cols.saturating_sub(1); let first = ((usable as f32 * ratio).round() as usize) .clamp(MIN_PANE_COLS, usable.saturating_sub(MIN_PANE_COLS)); // second = usable - first, so first + border + second == area.cols // EXACTLY. Computing `second` independently loses a cell. let second = usable - first; (Rect { cols: first, ..area }, Rect { col: area.col + first + 1, cols: second, ..area }) } SplitDirection::Horizontal => { let usable = area.rows.saturating_sub(1); let first = ((usable as f32 * ratio).round() as usize) .clamp(MIN_PANE_ROWS, usable.saturating_sub(MIN_PANE_ROWS)); let second = usable - first; (Rect { rows: first, ..area }, Rect { row: area.row + first + 1, rows: second, ..area }) } }; children.0.compute(a, out); children.1.compute(b, out); } } } } /// A pane smaller than this is useless and breaks programs (`vim` refuses to /// start below a few rows). Refuse the split rather than creating one. const MIN_PANE_ROWS: usize = 3; const MIN_PANE_COLS: usize = 10; }
The invariants to test:
| Invariant | Why |
|---|---|
| The pane rectangles plus borders exactly tile the area — no gaps, no overlaps | A gap renders as garbage; an overlap corrupts |
Every pane has rows >= MIN and cols >= MIN | Programs break below a floor |
| A split that cannot satisfy the minimum is refused, not clamped into nonsense | Clamping produces a layout the user did not ask for |
| Resizing the window preserves ratios | Users expect proportional behavior |
compute is deterministic and total | The same tree and area always give the same rectangles |
#![allow(unused)] fn main() { #[test] fn pane_rectangles_exactly_tile_the_area() { // The single most valuable layout test. Run it over many random trees. for tree in random_layouts(1000) { let area = Rect { row: 0, col: 0, rows: 50, cols: 200 }; let mut rects = HashMap::new(); tree.compute(area, &mut rects); let covered: usize = rects.values().map(|r| r.rows * r.cols).sum(); let borders = count_borders(&tree, area); assert_eq!(covered + borders, area.rows * area.cols, "layout must tile exactly: {tree:?}"); assert!(no_overlaps(&rects)); } } }
Resize Propagation
When a layout changes, every affected pane needs three things updated, in this order:
1. pane.rect = the new rectangle from Layout::compute
2. pane.terminal.resize(rect.rows, rect.cols) ← the grid, FIRST
3. pane.pty.resize(rect.rows, rect.cols) ← the kernel, SECOND
→ the kernel sends SIGWINCH to the pane's foreground process group
→ the program inside re-queries TIOCGWINSZ and redraws
The ordering is the same rule as the GUI's resize: resize the grid before telling the program, or the program's redraw lands in a stale grid and the first post-resize frame is corrupt.
#![allow(unused)] fn main() { fn apply_layout(&mut self, window: &mut Window) { let mut rects = HashMap::new(); window.layout.compute(window.area, &mut rects); for (pane_id, rect) in rects { let pane = self.panes.get_mut(&pane_id).unwrap(); if pane.rect == rect { continue; } // no-op resizes are common; skip them pane.rect = rect; pane.terminal.resize(rect.rows, rect.cols); let _ = pane.pty.resize(PtySize::new(rect.rows as u16, rect.cols as u16)); } self.broadcast(Event::LayoutChanged { /* ... */ }); } }
Tip: The
if pane.rect == rect { continue; }guard matters more than it looks. Without it, every layout recomputation sendsSIGWINCHto every pane, and every full-screen program in every pane redraws its entire screen. Dragging a divider then produces a storm.
Multi-Client Resize: The Hard Problem
Two clients attach to one session. Client A's terminal is 200×50. Client B's is 80×24. What size is the pane?
There is no correct answer, only policies:
| Policy | Behavior | Cost |
|---|---|---|
| Smallest wins (tmux's default) | Every pane is sized to the smallest attached client | The big client sees a small session with unused space |
Per-window sizing (tmux aggressive-resize) | Each window is sized to the smallest client currently viewing it | Better; more bookkeeping |
| Latest wins | The most recently attached client dictates | The other client sees corruption — content beyond its width wraps wrongly |
| Per-client viewport | The pane is large; each client renders a scrollable sub-view | Most flexible, hardest; the user must scroll to see everything |
| Refuse | Only one client at a time | Simple and defensible, but you lose pair programming |
#![allow(unused)] fn main() { fn effective_size(&self, session: &Session) -> Size { let clients: Vec<&ClientConn> = self.clients_attached_to(session.id).collect(); match self.resize_policy { // Smallest-wins. Simple, predictable, and it guarantees every client can // display everything — which is the property that actually matters. ResizePolicy::SmallestWins => clients.iter() .map(|c| c.size) .fold(Size::MAX, |a, b| Size { rows: a.rows.min(b.rows), cols: a.cols.min(b.cols) }), ResizePolicy::LatestWins => clients.last().map(|c| c.size).unwrap_or(Size::DEFAULT), // No clients attached: KEEP the current size. Resizing to zero would // make every program in the session redraw at 0x0 and many would crash. _ if clients.is_empty() => session.size, _ => session.size, } } }
Warning: The zero-client case is the one that bites. When the last client detaches, do not resize the session. Keep the last known size. A pane resized to 0×0 makes
topdivide by zero,vimcomplain, andlessproduce nothing — and the damage persists after reattach.
Recommendation for this curriculum: implement smallest-wins, document it, and note in your write-up what the alternatives cost. That is what a real engineer does with an unsolvable trade-off.
Compositing
The server holds one Terminal per pane. A client needs one screen. Two designs:
Design A — the server composites (recommended)
Server builds a single virtual screen of the client's size and copies each
pane's cells into its rectangle, adding borders.
✓ The client is trivial: draw one grid.
✓ Borders, titles, and the status line are the server's business.
✓ Clients of different capabilities all get correct output.
✗ The server does per-client work — but it is cheap, and bounded by the
frame rate.
#![allow(unused)] fn main() { fn composite(&self, window: &Window, client_size: Size) -> ScreenSnapshot { let mut screen = ScreenSnapshot::blank(client_size); for (pane_id, rect) in &window.pane_rects { let pane = &self.panes[pane_id]; // The pane's terminal is EXACTLY rect-sized, so this is a straight copy. // If it is not, you have a resize bug — assert it in debug. debug_assert_eq!((pane.terminal.rows(), pane.terminal.cols()), (rect.rows, rect.cols)); for r in 0..rect.rows { screen.rows[rect.row + r].copy_run(rect.col, pane.terminal.row_runs(r)); } } self.draw_borders(&mut screen, window); self.draw_status_line(&mut screen, window); screen } }
Design B — the client composites
The server sends per-pane updates plus the layout; the client places them. Less server work, more client complexity, and the client must then know about borders and status lines. It also makes a "dumb client" impossible.
Choose A. It keeps the client replaceable, which is the property that let you reuse your Section 3 GUI and write a terminal-based client.
Borders and the status line
┌─ pane 0 ────────────┬─ pane 1 ─────────┐ ← titles in the border
│ $ vim main.rs │ $ top │
│ │ │
├─────────────────────┴──────────────────┤
│ $ tail -f log │
└────────────────────────────────────────┘
[work] 0:editor* 1:logs 12:34 host ← status line: 1 row, always
Rules:
• The status line costs one ROW from the layout area. Compute the layout
against `area.rows - 1`.
• Border characters come from the DEC graphics set or Unicode box drawing.
Offer an ASCII fallback for terminals that mangle them.
• The FOCUSED pane's border is highlighted — usually a different color.
• A pane title comes from its OSC 0/2 title, truncated to fit.
Input Routing
client keystroke
│
▼
┌─────────────────────────────────────────────────┐
│ CLIENT: prefix state machine │
│ Is this the prefix key (Ctrl+B)? │
│ → enter PREFIX state, send NOTHING │
│ In PREFIX state? │
│ → interpret as a command, or │
│ → if it is the prefix again, send ONE │
│ literal prefix byte (the escape hatch) │
│ Otherwise → Request::Input { bytes } │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ SERVER: route to the FOCUSED pane of the │
│ client's attached session │
│ write(pane.pty_master, bytes) │
└─────────────────────────────────────────────────┘
Rules:
| Rule | Reason |
|---|---|
| Input goes only to the focused pane | Everything else would be chaos |
| Each client has its own focus... or the session does | A policy choice; tmux uses per-session focus so both clients see the same thing. Pick one. |
| The prefix is parsed client-side | So it works even if the server is busy, and so the latency is zero |
prefix prefix sends one literal prefix | Otherwise you can never send Ctrl+B to the program |
| Mouse events are routed by position, not focus | Clicking pane 1 should reach pane 1 — and usually also focus it |
Experiment
CLAIM. A pane's program genuinely believes it is on a terminal of the pane's size, and this is directly observable.
METHOD.
# 1. In a real tmux, split into two panes and check each:
tmux new-session -d -s demo
tmux split-window -h
tmux send-keys -t demo.0 'stty size; tty' Enter
tmux send-keys -t demo.1 'stty size; tty' Enter
tmux attach -t demo
# Each pane reports its OWN size and its OWN pts device.
# 2. Watch SIGWINCH storms while dragging a divider:
# In one pane:
trap 'echo WINCH $(stty size)' WINCH
# Now resize the outer window slowly. Count the lines.
# 3. Two clients of different sizes:
# Terminal A (make it wide):
tmux attach -t demo
# Terminal B (make it narrow):
tmux attach -t demo
# In a pane: stty size
# → the SMALLEST client's dimensions. Now detach B and watch A's panes grow.
PREDICTION. Before step 3: what size does the pane report with both attached? What happens the
instant B detaches? Does A get a SIGWINCH?
Test
#![allow(unused)] fn main() { #[test] fn each_pane_gets_its_own_size() { let mut w = Window::new(Rect { row: 0, col: 0, rows: 24, cols: 80 }); let p0 = w.first_pane(); let p1 = w.split(p0, SplitDirection::Vertical, 0.5); let r0 = w.rect_of(p0); let r1 = w.rect_of(p1); // 80 columns = 39 + 1 border + 40, or 40 + 1 + 39 depending on rounding. assert_eq!(r0.cols + 1 + r1.cols, 80); assert_eq!(r0.rows, 24); } #[test] fn splits_refuse_below_the_minimum() { let mut w = Window::new(Rect { row: 0, col: 0, rows: 24, cols: 15 }); let p0 = w.first_pane(); // 15 columns cannot hold two panes of >= 10 plus a border. assert!(w.try_split(p0, SplitDirection::Vertical, 0.5).is_err()); } #[test] fn layout_survives_a_window_resize_proportionally() { let mut w = Window::new(Rect { row: 0, col: 0, rows: 24, cols: 100 }); let p0 = w.first_pane(); let p1 = w.split(p0, SplitDirection::Vertical, 0.7); w.set_area(Rect { row: 0, col: 0, rows: 24, cols: 200 }); let r0 = w.rect_of(p0); // ~70% of 199 usable columns. assert!((r0.cols as f32 / 199.0 - 0.7).abs() < 0.02); } #[test] fn resize_updates_terminal_before_pty() { let mut mux = TestMux::new(); let pane = mux.new_pane(); mux.record_calls(); mux.resize_pane(pane, 30, 100); let calls = mux.calls(); assert!(calls.iter().position(|c| c == "terminal.resize").unwrap() < calls.iter().position(|c| c == "pty.resize").unwrap()); } #[test] fn no_op_resizes_send_no_sigwinch() { // Without this guard, every layout recomputation storms every pane. let mut mux = TestMux::new(); let pane = mux.new_pane(); mux.resize_pane(pane, 24, 80); let before = mux.sigwinch_count(pane); mux.apply_layout(); // same geometry assert_eq!(mux.sigwinch_count(pane), before); } #[test] fn smallest_wins_across_clients() { let mut mux = TestMux::new(); let s = mux.new_session(); mux.attach_client(s, Size { rows: 50, cols: 200 }); mux.attach_client(s, Size { rows: 24, cols: 80 }); assert_eq!(mux.session_size(s), Size { rows: 24, cols: 80 }); } #[test] fn detaching_the_last_client_does_not_resize_to_zero() { // A 0x0 pane makes `top` divide by zero and `vim` refuse to draw, and the // damage persists after reattach. let mut mux = TestMux::new(); let s = mux.new_session(); let c = mux.attach_client(s, Size { rows: 24, cols: 80 }); mux.detach_client(c); assert_eq!(mux.session_size(s), Size { rows: 24, cols: 80 }); } #[test] fn composited_screen_has_no_gaps_or_overlaps() { let mux = TestMux::with_layout(complex_layout()); let screen = mux.composite(Size { rows: 24, cols: 80 }); for row in &screen.rows { assert_eq!(row.total_cols(), 80, "every composited row must be exactly full"); } } }
Challenge Extensions
- Zoom (tmux's
prefix z): temporarily make one pane full-window, remembering the layout. - Preset layouts: even-horizontal, even-vertical, main-vertical, tiled — and a serializable layout string so a session can be restored.
- Divider dragging with the mouse, with resize debouncing so a drag does not storm
SIGWINCH. - Per-client viewports as an alternative resize policy, and a written comparison with smallest-wins based on actually using both.
- Pane synchronization (tmux's
synchronize-panes): send input to every pane at once. - A property test over 10,000 random layout trees asserting exact tiling, minimum sizes, and determinism.
Validation / Self-check
- Draw the session/window/pane hierarchy with a nested split.
- Why store a ratio rather than absolute sizes?
- What does the border cost in the geometry, and how do you avoid losing a cell to rounding?
- State the resize order for a pane and the bug from reversing it.
- Why skip no-op resizes? What does the storm look like?
- Name five multi-client resize policies and one cost of each. Which did you implement, and why?
- Why must detaching the last client not resize the session?
- Compare server-side and client-side compositing. Which keeps the client replaceable?
- Where is the prefix key parsed, and why there rather than in the server?
- How are mouse events routed, and why differently from keyboard events?
- What size does a program in a 40-column pane think its terminal is, and how did it learn that?
Next: Input Routing and Copy Mode.