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
- Lab 15 complete.
- Panes, Windows, and Layout read.
Predict First
- An 80-column window split vertically. How wide is each pane?
- You split a 15-column window vertically with a 10-column minimum. What should happen?
- A pane is resized from 80 to 40 columns while
vimis running. What doesvimdo, and how did it find out? - 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
- Zoom (
prefix z): one pane full-window, remembering the layout, with a status indicator. - Preset layouts plus a serializable layout string, so a session can be saved and restored.
- Divider dragging with the mouse, debounced so a drag does not storm
SIGWINCH. - Pane titles in the border from OSC 0/2, truncated with an ellipsis.
- Incremental compositing: track per-pane damage and only re-copy changed rows into the composited screen. Measure the improvement at 20 panes.
- 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.
-
vimandtoprunning correctly in adjacent panes at different sizes. -
The
stty sizeexperiment, with predictions and results.
Validation / Self-check
- Why derive the second pane's size from the first rather than computing both?
- What does the border cost, and where does the status line's row come from?
- Why refuse a split rather than clamp it?
- State the per-pane resize order and the bug from reversing it.
- Why skip no-op resizes? Describe the storm.
- Why does directional focus need a perpendicular-overlap filter?
- Why are mouse coordinates translated to pane-local?
- What does the
debug_assertincompositeprotect against? - Why must an empty
Splitcollapse when a pane closes? - A pane reports
22 28fromstty size. Where did those numbers come from, and does the program know it is in a pane?