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 -h produces a horizontal split meaning the panes sit side by side — the divider is vertical. This book uses SplitDirection::Vertical to 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:

InvariantWhy
The pane rectangles plus borders exactly tile the area — no gaps, no overlapsA gap renders as garbage; an overlap corrupts
Every pane has rows >= MIN and cols >= MINPrograms break below a floor
A split that cannot satisfy the minimum is refused, not clamped into nonsenseClamping produces a layout the user did not ask for
Resizing the window preserves ratiosUsers expect proportional behavior
compute is deterministic and totalThe 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 sends SIGWINCH to 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:

PolicyBehaviorCost
Smallest wins (tmux's default)Every pane is sized to the smallest attached clientThe 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 itBetter; more bookkeeping
Latest winsThe most recently attached client dictatesThe other client sees corruption — content beyond its width wraps wrongly
Per-client viewportThe pane is large; each client renders a scrollable sub-viewMost flexible, hardest; the user must scroll to see everything
RefuseOnly one client at a timeSimple 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 top divide by zero, vim complain, and less produce 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:

   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:

RuleReason
Input goes only to the focused paneEverything else would be chaos
Each client has its own focus... or the session doesA policy choice; tmux uses per-session focus so both clients see the same thing. Pick one.
The prefix is parsed client-sideSo it works even if the server is busy, and so the latency is zero
prefix prefix sends one literal prefixOtherwise you can never send Ctrl+B to the program
Mouse events are routed by position, not focusClicking 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

  1. Zoom (tmux's prefix z): temporarily make one pane full-window, remembering the layout.
  2. Preset layouts: even-horizontal, even-vertical, main-vertical, tiled — and a serializable layout string so a session can be restored.
  3. Divider dragging with the mouse, with resize debouncing so a drag does not storm SIGWINCH.
  4. Per-client viewports as an alternative resize policy, and a written comparison with smallest-wins based on actually using both.
  5. Pane synchronization (tmux's synchronize-panes): send input to every pane at once.
  6. A property test over 10,000 random layout trees asserting exact tiling, minimum sizes, and determinism.

Validation / Self-check

  1. Draw the session/window/pane hierarchy with a nested split.
  2. Why store a ratio rather than absolute sizes?
  3. What does the border cost in the geometry, and how do you avoid losing a cell to rounding?
  4. State the resize order for a pane and the bug from reversing it.
  5. Why skip no-op resizes? What does the storm look like?
  6. Name five multi-client resize policies and one cost of each. Which did you implement, and why?
  7. Why must detaching the last client not resize the session?
  8. Compare server-side and client-side compositing. Which keeps the client replaceable?
  9. Where is the prefix key parsed, and why there rather than in the server?
  10. How are mouse events routed, and why differently from keyboard events?
  11. 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.