Lab 16: Panes and Layout (Milestone 10)

Background

Lab 15 gave you N sessions, one visible at a time. This lab makes several visible at once: a layout tree, per-pane rectangles, compositing into one screen, borders, and input routed to the focused pane.

The concepts are simple. The bugs are all off-by-one, and they are all visible.

Why This Lab Matters

  • Compositing is where "each pane is its own terminal" becomes concrete: a pane is 40 columns wide because its PTY says so, and the program inside has no idea it is sharing a screen.
  • The tiling invariant is one property test that eliminates a whole class of rendering corruption.

Prerequisites


Predict First

  1. An 80-column window split vertically. How wide is each pane?
  2. You split a 15-column window vertically with a 10-column minimum. What should happen?
  3. A pane is resized from 80 to 40 columns while vim is running. What does vim do, and how did it find out?
  4. You drag a divider one column at a time across the screen. How many SIGWINCHs does each pane receive?

Step 1: The Layout Tree

#![allow(unused)]
fn main() {
pub enum Layout {
    Leaf(PaneId),
    Split { direction: SplitDirection, ratio: f32, children: Box<(Layout, Layout)> },
}

impl Layout {
    /// Split the leaf holding `target`, replacing it with a Split node.
    /// Returns Err if the resulting panes would be below the minimum — REFUSING
    /// is correct; clamping produces a layout the user did not ask for.
    pub fn split(&mut self, target: PaneId, new: PaneId,
                 dir: SplitDirection, area: Rect) -> Result<(), LayoutError> {
        let target_rect = self.rect_of(target, area)
            .ok_or(LayoutError::NoSuchPane)?;
        let (min_a, min_b) = match dir {
            SplitDirection::Vertical =>
                (MIN_PANE_COLS * 2 + 1, target_rect.cols),
            SplitDirection::Horizontal =>
                (MIN_PANE_ROWS * 2 + 1, target_rect.rows),
        };
        if min_b < min_a { return Err(LayoutError::TooSmall); }

        self.replace_leaf(target, Layout::Split {
            direction: dir,
            ratio: 0.5,
            children: Box::new((Layout::Leaf(target), Layout::Leaf(new))),
        });
        Ok(())
    }

    /// Remove a pane, collapsing its parent Split into the sibling. Without the
    /// collapse you accumulate Splits with one child and the geometry drifts.
    pub fn remove(&mut self, pane: PaneId) -> Option<PaneId> { /* ... */ }
}
}

Step 2: Geometry

#![allow(unused)]
fn main() {
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. `usable` is what the panes share.
                    let usable = area.cols.saturating_sub(1);
                    let first = ((usable as f32 * ratio).round() as usize)
                        .clamp(MIN_PANE_COLS.min(usable),
                               usable.saturating_sub(MIN_PANE_COLS).max(1));
                    // Derive the SECOND from the first, so
                    //   first + 1 + second == area.cols  EXACTLY.
                    // Computing it independently loses a column to rounding, and
                    // that lost column renders as a vertical stripe of garbage.
                    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.min(usable),
                               usable.saturating_sub(MIN_PANE_ROWS).max(1));
                    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);
        }
    }
}
}

Step 3: Applying the Layout

#![allow(unused)]
fn main() {
fn apply_layout(&mut self, window_id: WindowId) {
    let window = &mut self.windows[&window_id];
    // The status line costs one row. Compute against what is left.
    let area = Rect { rows: window.area.rows - 1, ..window.area };
    let mut rects = HashMap::new();
    window.layout.compute(area, &mut rects);

    for (pane_id, rect) in rects {
        let pane = self.panes.get_mut(&pane_id).unwrap();
        // Skip no-op resizes. Without this guard, every layout recomputation
        // SIGWINCHes every pane, and every full-screen program redraws.
        if pane.rect == rect { continue; }
        pane.rect = rect;
        // Order: grid first, kernel second. Reversed, the child redraws into a
        // grid that is still the old size and the first frame is corrupt.
        pane.terminal.resize(rect.rows, rect.cols);
        let _ = pane.pty.resize(PtySize::new(rect.rows as u16, rect.cols as u16));
    }
    window.pane_rects = rects;
    self.damage_all();
}
}

Step 4: Compositing

#![allow(unused)]
fn main() {
fn composite(&self, window: &Window) -> ScreenSnapshot {
    let mut screen = ScreenSnapshot::blank(window.area);

    for (pane_id, rect) in &window.pane_rects {
        let pane = &self.panes[pane_id];
        // If this ever trips, a resize was missed and the copy below would
        // either overflow the rectangle or leave a gap.
        debug_assert_eq!((pane.terminal.rows(), pane.terminal.cols()),
                         (rect.rows, rect.cols),
                         "pane {pane_id} terminal size does not match its rect");
        for r in 0..rect.rows {
            screen.copy_row_into(rect.row + r, rect.col, pane.terminal.row_runs(r));
        }
    }

    self.draw_borders(&mut screen, window);
    self.draw_status_line(&mut screen, window);

    // The composited cursor is the FOCUSED pane's cursor, offset into its rect —
    // and only if that pane has its cursor visible.
    if let Some(focused) = window.focused {
        let pane = &self.panes[&focused];
        if pane.terminal.modes().contains(Mode::CURSOR_VISIBLE) {
            let c = pane.terminal.cursor();
            let r = window.pane_rects[&focused];
            screen.cursor = Some(CursorInfo {
                row: r.row + c.row, col: r.col + c.col, shape: pane.cursor_shape,
            });
        }
    }
    screen
}

fn draw_borders(&self, screen: &mut ScreenSnapshot, window: &Window) {
    // Walk the tree, drawing a divider at each Split's boundary. Deriving the
    // dividers from the TREE rather than from pane edges avoids double-drawing
    // and gets the junction characters (┬ ┴ ├ ┤ ┼) right.
    self.draw_dividers(&window.layout, window.content_area(), screen, window.focused);
}
}

Step 5: Focus and Input Routing

#![allow(unused)]
fn main() {
/// Directional focus: pick the nearest pane whose rectangle lies in `dir` and
/// overlaps the current pane's perpendicular span. Overlap matters — without it
/// "focus right" from a tall left pane picks whichever right-hand pane happens
/// to be first in the map, which feels random.
fn focus_direction(&mut self, window: &mut Window, dir: Direction) {
    let Some(cur) = window.focused else { return };
    let c = window.pane_rects[&cur];
    let best = window.pane_rects.iter()
        .filter(|(id, _)| **id != cur)
        .filter(|(_, r)| match dir {
            Direction::Left  => r.col + r.cols <= c.col,
            Direction::Right => r.col >= c.col + c.cols,
            Direction::Up    => r.row + r.rows <= c.row,
            Direction::Down  => r.row >= c.row + c.rows,
        })
        .filter(|(_, r)| match dir {
            Direction::Left | Direction::Right =>
                r.row < c.row + c.rows && c.row < r.row + r.rows,   // vertical overlap
            Direction::Up | Direction::Down =>
                r.col < c.col + c.cols && c.col < r.col + r.cols,   // horizontal overlap
        })
        .min_by_key(|(_, r)| distance(c, **r))
        .map(|(id, _)| *id);
    if let Some(id) = best { window.focused = Some(id); self.damage_all(); }
}

/// Mouse events route by POSITION, not focus — clicking pane 1 must reach pane 1.
fn route_mouse(&mut self, window: &Window, row: usize, col: usize, ev: MouseEvent) {
    let Some((&pane_id, rect)) = window.pane_rects.iter()
        .find(|(_, r)| r.contains(row, col)) else { return };     // a border: ignore
    let pane = &mut self.panes.get_mut(&pane_id).unwrap();
    // Translate to PANE-LOCAL coordinates. The program believes its terminal
    // starts at (0,0), and it is right — its PTY says so.
    let local = MouseEvent { row: row - rect.row, col: col - rect.col, ..ev };
    if let Some(bytes) = encode_mouse(local, pane.terminal.modes()) {
        pane.pty.queue_write(&bytes);
    }
}
}

Expected Output

┌─ 0: bash ──────────────────┬─ 1: top ──────────────────┐
│ $ ls                       │ top - 14:32:01 up 3 days  │
│ Cargo.toml  src  target    │ Tasks: 312 total          │
│ $ stty size                │   PID USER  %CPU  COMMAND │
│ 22 28                      │  1234 you    2.1  cargo   │
│ $ █                        │  5678 you    0.8  top     │
├────────────────────────────┴───────────────────────────┤
│ $ tail -f /var/log/syslog                              │
│ Jan  1 14:31:55 host systemd[1]: Started foo.service    │
└─────────────────────────────────────────────────────────┘
 [work] 0:editor* 1:logs                    14:32 hostname

Note: pane 0 reports "22 28" from `stty size` — its OWN dimensions.
The program inside believes it is on a 22×28 terminal, and it is right.

Debugging Steps

A vertical stripe of garbage between panes

Rounding lost a column. Derive second from usable - first.

Panes overlap

A rectangle is computed from the wrong parent area, or the border was not subtracted.

vim in a pane draws outside its rectangle

pane.terminal was not resized to the rectangle. The debug_assert in composite catches this.

Dragging a divider makes everything flicker and thrash

No no-op guard, and no resize debouncing. Every pane is redrawing on every pixel of the drag.

Focus movement picks a random pane

Missing the perpendicular-overlap filter.

Clicking a pane focuses the wrong one

Coordinates not translated, or the status line row is not being excluded from the content area.

vim refuses to start in a small pane

Correct behavior below its minimum. Enforce MIN_PANE_ROWS/MIN_PANE_COLS at split time.


Experiment

CLAIM. Each pane's program genuinely believes it is on a terminal of the pane's size.

METHOD.

cargo run -p terminal-mux --bin mux-panes
#   <prefix> %                      split vertically
#   in pane 0:  stty size; tty; echo $COLUMNS
#   <prefix> o                      focus pane 1
#   in pane 1:  stty size; tty
#   → different sizes, different pts devices

#   Now resize the outer window and re-run `stty size` in each.

#   And watch SIGWINCH arrive:
#   in a pane:  trap 'echo WINCH $(stty size)' WINCH
#   then drag a divider with <prefix> Ctrl-Left/Right

PREDICTION. Before running: will the two panes have the same pts device? Will $COLUMNS match stty size? How many WINCH lines will one divider drag produce?

RESULT. Note whether $COLUMNS matches. Bash updates it via checkwinsize, but only between commands — which is exactly why you unset COLUMNS in the child environment back in Lab 2.


Test

#![allow(unused)]
fn main() {
#[test]
fn layout_tiles_exactly_over_random_trees() {
    // THE layout test. One property, one thousand cases, a whole bug class gone.
    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);
        assert!(no_overlaps(&rects), "overlap in {tree:?}");
        assert!(no_gaps_except_borders(&rects, area, &tree), "gap in {tree:?}");
        for r in rects.values() {
            assert!(r.rows >= MIN_PANE_ROWS && r.cols >= MIN_PANE_COLS,
                    "pane below minimum: {r:?}");
        }
    }
}

#[test]
fn vertical_split_of_eighty_columns_accounts_for_the_border() {
    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).unwrap();
    let (a, b) = (w.rect_of(p0), w.rect_of(p1));
    assert_eq!(a.cols + 1 + b.cols, 80);
    assert_eq!(b.col, a.col + a.cols + 1);
}

#[test]
fn split_is_refused_below_the_minimum() {
    let mut w = Window::new(Rect { row: 0, col: 0, rows: 24, cols: 15 });
    let p0 = w.first_pane();
    assert!(matches!(w.split(p0, SplitDirection::Vertical), Err(LayoutError::TooSmall)));
}

#[test]
fn closing_a_pane_collapses_its_split() {
    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).unwrap();
    w.close(p1);
    assert_eq!(w.rect_of(p0).cols, 80, "the survivor must reclaim the full area");
    assert!(matches!(w.layout, Layout::Leaf(_)), "the empty Split must collapse");
}

#[test]
fn pane_terminal_size_always_matches_its_rect() {
    // Enforced by the debug_assert in composite; asserted here directly.
    let mut mux = TestMux::new();
    let w = mux.new_window(Rect { row: 0, col: 0, rows: 24, cols: 80 });
    mux.split(w, SplitDirection::Vertical);
    mux.split(w, SplitDirection::Horizontal);
    mux.resize_window(w, Rect { row: 0, col: 0, rows: 40, cols: 120 });
    for (id, rect) in mux.pane_rects(w) {
        let t = mux.pane_terminal(id);
        assert_eq!((t.rows(), t.cols()), (rect.rows, rect.cols));
    }
}

#[test]
fn no_op_layout_recompute_sends_no_sigwinch() {
    let mut mux = TestMux::new();
    let w = mux.new_window(Rect { row: 0, col: 0, rows: 24, cols: 80 });
    let p = mux.split(w, SplitDirection::Vertical);
    let before = mux.sigwinch_count(p);
    mux.apply_layout(w);
    mux.apply_layout(w);
    assert_eq!(mux.sigwinch_count(p), before);
}

#[test]
fn composited_rows_are_exactly_full_width() {
    let mux = TestMux::with_complex_layout();
    let screen = mux.composite_window(0);
    for (i, row) in screen.rows.iter().enumerate() {
        assert_eq!(row.total_cols(), 80, "row {i} is not exactly full");
    }
}

#[test]
fn mouse_routes_to_the_pane_under_the_pointer() {
    let mut mux = TestMux::new();
    let w = mux.new_window(Rect { row: 0, col: 0, rows: 24, cols: 80 });
    let p0 = mux.first_pane(w);
    let p1 = mux.split(w, SplitDirection::Vertical);
    mux.enable_mouse_reporting(p1);
    mux.mouse_click(5, 60);                    // inside pane 1
    let got = mux.pane_input(p1);
    assert!(!got.is_empty(), "the click must reach pane 1");
    assert!(mux.pane_input(p0).is_empty());
    // And the coordinates must be PANE-LOCAL.
    let r1 = mux.rect_of(p1);
    assert!(String::from_utf8_lossy(&got).contains(&format!(";{};", 60 - r1.col + 1)));
}
}

Challenge Extensions

  1. Zoom (prefix z): one pane full-window, remembering the layout, with a status indicator.
  2. Preset layouts plus a serializable layout string, so a session can be saved and restored.
  3. Divider dragging with the mouse, debounced so a drag does not storm SIGWINCH.
  4. Pane titles in the border from OSC 0/2, truncated with an ellipsis.
  5. Incremental compositing: track per-pane damage and only re-copy changed rows into the composited screen. Measure the improvement at 20 panes.
  6. A property test over 10,000 trees including splits, closes, and resizes in random order, asserting the tiling invariant holds throughout.

Deliverables

  • A layout tree with split, close-and-collapse, and ratio-preserving resize.
  • Exact tiling, verified by the property test over 1,000 random trees.
  • Minimum pane sizes enforced by refusing splits.
  • Per-pane resize in the correct order, with the no-op guard.
  • Compositing with borders, junction characters, and a status line.
  • Directional focus with the overlap rule.
  • Mouse routing by position, with pane-local coordinates.
  • vim and top running correctly in adjacent panes at different sizes.
  • The stty size experiment, with predictions and results.

Validation / Self-check

  1. Why derive the second pane's size from the first rather than computing both?
  2. What does the border cost, and where does the status line's row come from?
  3. Why refuse a split rather than clamp it?
  4. State the per-pane resize order and the bug from reversing it.
  5. Why skip no-op resizes? Describe the storm.
  6. Why does directional focus need a perpendicular-overlap filter?
  7. Why are mouse coordinates translated to pane-local?
  8. What does the debug_assert in composite protect against?
  9. Why must an empty Split collapse when a pane closes?
  10. A pane reports 22 28 from stty size. Where did those numbers come from, and does the program know it is in a pane?

Next: Lab 17 — The Multiplexer Server.