diff --git a/Cargo.toml b/Cargo.toml index 054d1f0..3ea69b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amux" -version = "0.1.1" +version = "0.2.0" edition = "2021" rust-version = "1.70" description = "tmux for agents: a multi-agent terminal that hosts your own CLIs in switchable panes with agent-aware chrome. 100% nativelite - zero third-party dependencies." @@ -14,6 +14,7 @@ categories = ["command-line-utilities"] pty = { git = "https://github.com/nativelite/pty-rs", branch = "main" } rawterm = { git = "https://github.com/nativelite/rawterm-rs", branch = "main" } ansi = { git = "https://github.com/nativelite/ansi-rs", branch = "main" } +vterm = { git = "https://github.com/nativelite/vterm-rs", branch = "main" } [lib] name = "amux" diff --git a/src/bar.rs b/src/bar.rs index d5bba40..a98386c 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -32,7 +32,7 @@ pub fn bar_text(panes: &[PaneInfo], cols: usize, note: &str) -> String { s.push_str(&format!("| {}:{}{} ", i + 1, p.title, mark)); } if note.is_empty() { - s.push_str("| ^A c:new n/p:cycle 1-9:go x:kill q:quit"); + s.push_str("| ^A c:win \":% split hjkl:focus z:zoom x:kill q:quit"); } else { s.push_str(&format!("| {note}")); } diff --git a/src/input.rs b/src/input.rs index 4b8879d..edf930d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -5,10 +5,26 @@ //! sends a literal `0x01` to the pane. The armed state survives chunk //! boundaries, so a prefix arriving at the end of one read and its command //! at the start of the next behave identically to both in one read. +//! +//! 0.2 adds the tiling commands (splits, focus movement, zoom) on top of the +//! 0.1 window commands, all additive — every 0.1 key still means what it did. +//! Focus movement accepts both `h/j/k/l` and the arrow keys; an arrow arrives +//! as the three-byte `ESC [ A/B/C/D`, so after the prefix arms, an `ESC` puts +//! the scanner into a short "arrow pending" state that consumes the `[` and the +//! final letter before emitting the move. /// The prefix byte: Ctrl+A. pub const PREFIX: u8 = 0x01; +/// A focus-movement direction (arrows or `h/j/k/l`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dir { + Left, + Right, + Up, + Down, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { /// Bytes to forward to the active pane verbatim. @@ -19,12 +35,33 @@ pub enum Action { KillPane, /// Switch to pane index (0-based; from digits 1-9). SwitchTo(usize), + /// Split the focused pane horizontally (stacked) — `Ctrl+A "`. + SplitH, + /// Split the focused pane vertically (side by side) — `Ctrl+A %`. + SplitV, + /// Move focus between tiled panes — `Ctrl+A` + arrow or `h/j/k/l`. + MoveFocus(Dir), + /// Toggle the focused pane to/from full-screen passthrough — `Ctrl+A z`. + Zoom, Quit, } +/// The scanner's arming state: idle, prefix seen (command pending), or an +/// arrow escape mid-parse after a prefixed `ESC`. +#[derive(Debug, Default, PartialEq, Eq)] +enum State { + #[default] + Idle, + Armed, + /// Prefix + `ESC` seen; expecting `[`. + ArrowEsc, + /// Prefix + `ESC [` seen; expecting the final letter `A`/`B`/`C`/`D`. + ArrowCsi, +} + #[derive(Debug, Default)] pub struct PrefixScanner { - armed: bool, + state: State, } impl PrefixScanner { @@ -32,9 +69,9 @@ impl PrefixScanner { Self::default() } - /// True when a prefix has been seen and its command byte is pending. + /// True when a prefix has been seen and a command (or arrow) is pending. pub fn armed(&self) -> bool { - self.armed + self.state != State::Idle } pub fn feed(&mut self, bytes: &[u8]) -> Vec { @@ -46,45 +83,108 @@ impl PrefixScanner { } }; for &b in bytes { - if self.armed { - self.armed = false; - match b { - PREFIX => run.push(PREFIX), // literal Ctrl+A - b'n' => { - flush(&mut run, &mut actions); - actions.push(Action::NextPane); - } - b'p' => { - flush(&mut run, &mut actions); - actions.push(Action::PrevPane); - } - b'c' => { - flush(&mut run, &mut actions); - actions.push(Action::NewPane); - } - b'x' => { + match self.state { + State::ArrowEsc => { + // After `Ctrl+A ESC`, a `[` continues the arrow sequence; + // anything else aborts (swallowed — the prefix is spent). + self.state = if b == b'[' { + State::ArrowCsi + } else { + State::Idle + }; + continue; + } + State::ArrowCsi => { + self.state = State::Idle; + if let Some(dir) = arrow_dir(b) { flush(&mut run, &mut actions); - actions.push(Action::KillPane); + actions.push(Action::MoveFocus(dir)); } - b'q' => { - flush(&mut run, &mut actions); - actions.push(Action::Quit); + continue; + } + State::Armed => { + self.state = State::Idle; + match b { + PREFIX => run.push(PREFIX), // literal Ctrl+A + 0x1b => { + // Prefixed ESC: begin an arrow sequence. + self.state = State::ArrowEsc; + } + b'n' => { + flush(&mut run, &mut actions); + actions.push(Action::NextPane); + } + b'p' => { + flush(&mut run, &mut actions); + actions.push(Action::PrevPane); + } + b'c' => { + flush(&mut run, &mut actions); + actions.push(Action::NewPane); + } + b'x' => { + flush(&mut run, &mut actions); + actions.push(Action::KillPane); + } + b'q' => { + flush(&mut run, &mut actions); + actions.push(Action::Quit); + } + b'"' => { + flush(&mut run, &mut actions); + actions.push(Action::SplitH); + } + b'%' => { + flush(&mut run, &mut actions); + actions.push(Action::SplitV); + } + b'z' => { + flush(&mut run, &mut actions); + actions.push(Action::Zoom); + } + b'h' => push_move(Dir::Left, &mut run, &mut actions, &mut flush), + b'j' => push_move(Dir::Down, &mut run, &mut actions, &mut flush), + b'k' => push_move(Dir::Up, &mut run, &mut actions, &mut flush), + b'l' => push_move(Dir::Right, &mut run, &mut actions, &mut flush), + d @ b'1'..=b'9' => { + flush(&mut run, &mut actions); + actions.push(Action::SwitchTo((d - b'1') as usize)); + } + _ => {} // unknown command: swallow prefix and byte } - d @ b'1'..=b'9' => { - flush(&mut run, &mut actions); - actions.push(Action::SwitchTo((d - b'1') as usize)); + } + State::Idle => { + if b == PREFIX { + self.state = State::Armed; + } else { + run.push(b); } - _ => {} // unknown command: swallow prefix and byte } - continue; - } - if b == PREFIX { - self.armed = true; - continue; } - run.push(b); } flush(&mut run, &mut actions); actions } } + +/// Emit a focus-move action, flushing any pending forward run first. +fn push_move( + dir: Dir, + run: &mut Vec, + actions: &mut Vec, + flush: &mut impl FnMut(&mut Vec, &mut Vec), +) { + flush(run, actions); + actions.push(Action::MoveFocus(dir)); +} + +/// Map an arrow CSI final byte to a direction. +fn arrow_dir(b: u8) -> Option { + match b { + b'A' => Some(Dir::Up), + b'B' => Some(Dir::Down), + b'C' => Some(Dir::Right), + b'D' => Some(Dir::Left), + _ => None, + } +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..95acb2d --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,454 @@ +//! The split tree: a binary tree of panes and H/V splits that resolves to a +//! rect per pane. This is the tiled-mode layout engine — pure geometry, no +//! terminal, no pty, unit-testable on its own. +//! +//! Leaves hold a pane id (an index into the app's pane vector); internal nodes +//! are horizontal (stacked) or vertical (side-by-side) splits with two +//! children. [`Tree::rects`] walks the tree over an outer rect and hands back +//! `(pane_id, Rect)` for every leaf, reserving a 1-cell divider between the two +//! children of each split (that gutter is where the compositor draws its rule). +//! +//! MVP splits are **equal** — a split halves the focused pane — which is all +//! the 2×2 "four agents in a square" case needs: split vertical, then split +//! each side horizontally. Proportional drag-resize is deferred (see the 0.2 +//! design doc, §4 "out of scope"). + +/// A sub-rectangle of the screen, 0-based `(row, col)` origin with a size. All +/// coordinates are in the master (outer) grid the compositor paints into. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rect { + pub row: usize, + pub col: usize, + pub rows: usize, + pub cols: usize, +} + +impl Rect { + /// The cell coordinates of the rect's center, used by focus movement to + /// pick the nearest pane in a direction. + fn center(&self) -> (usize, usize) { + (self.row + self.rows / 2, self.col + self.cols / 2) + } +} + +/// Split orientation. `Horizontal` stacks its children (a divider *row* +/// between them); `Vertical` places them side by side (a divider *column*). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dir { + Horizontal, + Vertical, +} + +/// A node in the split tree: either a leaf pane or an even split of two +/// subtrees. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Node { + Leaf(usize), + Split { + dir: Dir, + first: Box, + second: Box, + }, +} + +/// Which way to move focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Move { + Left, + Right, + Up, + Down, +} + +/// The layout tree plus which pane currently has focus. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Tree { + root: Node, + focus: usize, +} + +impl Tree { + /// A single-pane tree (pane `id`, focused). + pub fn new(id: usize) -> Self { + Tree { + root: Node::Leaf(id), + focus: id, + } + } + + /// The focused pane id. + pub fn focus(&self) -> usize { + self.focus + } + + /// Number of panes (leaves) in the tree. + pub fn len(&self) -> usize { + Self::count(&self.root) + } + + pub fn is_empty(&self) -> bool { + false // a tree always holds at least one leaf + } + + fn count(node: &Node) -> usize { + match node { + Node::Leaf(_) => 1, + Node::Split { first, second, .. } => Self::count(first) + Self::count(second), + } + } + + /// Every pane id in the tree, in left-to-right leaf order. + pub fn ids(&self) -> Vec { + let mut out = Vec::new(); + Self::collect_ids(&self.root, &mut out); + out + } + + fn collect_ids(node: &Node, out: &mut Vec) { + match node { + Node::Leaf(id) => out.push(*id), + Node::Split { first, second, .. } => { + Self::collect_ids(first, out); + Self::collect_ids(second, out); + } + } + } + + /// True once the tree is a single pane again (tiled mode collapses back to + /// passthrough here). + pub fn is_single(&self) -> bool { + matches!(self.root, Node::Leaf(_)) + } + + /// Split the focused pane in `dir`, giving the new half pane id `new_id`, + /// and move focus to the new pane. The focused leaf becomes a split whose + /// first child is the old pane and whose second child is the new one. + pub fn split(&mut self, dir: Dir, new_id: usize) { + let focus = self.focus; + Self::split_at(&mut self.root, focus, dir, new_id); + self.focus = new_id; + } + + fn split_at(node: &mut Node, target: usize, dir: Dir, new_id: usize) -> bool { + match node { + Node::Leaf(id) if *id == target => { + let old = *id; + *node = Node::Split { + dir, + first: Box::new(Node::Leaf(old)), + second: Box::new(Node::Leaf(new_id)), + }; + true + } + Node::Leaf(_) => false, + Node::Split { first, second, .. } => { + Self::split_at(first, target, dir, new_id) + || Self::split_at(second, target, dir, new_id) + } + } + } + + /// Remove pane `id` from the tree, collapsing its parent split into the + /// surviving sibling. Returns `false` if `id` is the last pane (the caller + /// then tears the whole tree down / quits). Focus, if it was on the removed + /// pane, moves to the first remaining leaf. + pub fn close(&mut self, id: usize) -> bool { + if self.is_single() { + return false; + } + Self::close_at(&mut self.root, id); + if !self.ids().contains(&self.focus) { + self.focus = self.ids()[0]; + } + true + } + + /// Replace a parent split with its surviving child when one child is the + /// leaf to remove. Recurses into splits. Returns true if a removal + /// happened in this subtree. + fn close_at(node: &mut Node, id: usize) -> bool { + if let Node::Split { first, second, .. } = node { + // Direct child is the target leaf → collapse to the sibling. + if matches!(**first, Node::Leaf(x) if x == id) { + let survivor = std::mem::replace(second.as_mut(), Node::Leaf(usize::MAX)); + *node = survivor; + return true; + } + if matches!(**second, Node::Leaf(x) if x == id) { + let survivor = std::mem::replace(first.as_mut(), Node::Leaf(usize::MAX)); + *node = survivor; + return true; + } + return Self::close_at(first, id) || Self::close_at(second, id); + } + false + } + + /// Resolve the tree to `(pane_id, Rect)` for every pane, laid out inside + /// the outer rect. A split reserves one cell (a row for horizontal, a + /// column for vertical) as the divider between its halves; the remaining + /// space is halved. Panes that would be zero-sized are still emitted with + /// a 1×1 minimum so no pane silently vanishes. + pub fn rects(&self, outer: Rect) -> Vec<(usize, Rect)> { + let mut out = Vec::new(); + Self::layout(&self.root, outer, &mut out); + out + } + + fn layout(node: &Node, r: Rect, out: &mut Vec<(usize, Rect)>) { + match node { + Node::Leaf(id) => out.push((*id, r)), + Node::Split { dir, first, second } => match dir { + Dir::Horizontal => { + // Stacked: divider is a row between top and bottom. + let avail = r.rows.saturating_sub(1).max(2); + let top_rows = (avail / 2).max(1); + let bot_rows = (avail - top_rows).max(1); + let top = Rect { + row: r.row, + col: r.col, + rows: top_rows, + cols: r.cols, + }; + let bottom = Rect { + row: r.row + top_rows + 1, + col: r.col, + rows: bot_rows, + cols: r.cols, + }; + Self::layout(first, top, out); + Self::layout(second, bottom, out); + } + Dir::Vertical => { + // Side by side: divider is a column between left and right. + let avail = r.cols.saturating_sub(1).max(2); + let left_cols = (avail / 2).max(1); + let right_cols = (avail - left_cols).max(1); + let left = Rect { + row: r.row, + col: r.col, + rows: r.rows, + cols: left_cols, + }; + let right = Rect { + row: r.row, + col: r.col + left_cols + 1, + rows: r.rows, + cols: right_cols, + }; + Self::layout(first, left, out); + Self::layout(second, right, out); + } + }, + } + } + + /// Move focus to the nearest pane in direction `m`, measured from the + /// focused pane's center against the other panes' centers inside `outer`. + /// Geometric rather than tree-structural so it does the intuitive thing on + /// a 2×2 grid regardless of split nesting order. No-op if nothing lies that + /// way. Returns the new focus. + pub fn move_focus(&mut self, m: Move, outer: Rect) -> usize { + let rects = self.rects(outer); + let Some(cur) = rects + .iter() + .find(|(id, _)| *id == self.focus) + .map(|(_, r)| *r) + else { + return self.focus; + }; + let (cr, cc) = cur.center(); + let mut best: Option<(usize, usize)> = None; // (distance, id) + for (id, rect) in &rects { + if *id == self.focus { + continue; + } + let (r, c) = rect.center(); + // The candidate must lie in the requested half-plane. + let ok = match m { + Move::Left => c < cc, + Move::Right => c > cc, + Move::Up => r < cr, + Move::Down => r > cr, + }; + if !ok { + continue; + } + // Manhattan distance, biased so the primary axis dominates ties. + let dist = cr.abs_diff(r) + cc.abs_diff(c); + if best.map(|(d, _)| dist < d).unwrap_or(true) { + best = Some((dist, *id)); + } + } + if let Some((_, id)) = best { + self.focus = id; + } + self.focus + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const OUTER: Rect = Rect { + row: 0, + col: 0, + rows: 24, + cols: 80, + }; + + #[test] + fn single_pane_fills_the_outer_rect() { + let t = Tree::new(0); + assert!(t.is_single()); + assert_eq!(t.rects(OUTER), vec![(0, OUTER)]); + assert_eq!(t.focus(), 0); + assert_eq!(t.len(), 1); + } + + #[test] + fn vertical_split_makes_two_side_by_side_with_a_divider_column() { + let mut t = Tree::new(0); + t.split(Dir::Vertical, 1); + assert!(!t.is_single()); + assert_eq!(t.focus(), 1); // focus follows the new pane + let rects = t.rects(OUTER); + assert_eq!(rects.len(), 2); + let (_, left) = rects[0]; + let (_, right) = rects[1]; + assert_eq!(left.row, 0); + assert_eq!(left.rows, 24); + // 80 cols - 1 divider = 79, halved to 39 / 40. + assert_eq!(left.cols, 39); + assert_eq!(right.col, 40); // 39 + 1 divider + assert_eq!(right.cols, 40); + // The gap between them is exactly one column (the divider). + assert_eq!(right.col, left.col + left.cols + 1); + } + + #[test] + fn horizontal_split_stacks_with_a_divider_row() { + let mut t = Tree::new(0); + t.split(Dir::Horizontal, 1); + let rects = t.rects(OUTER); + let (_, top) = rects[0]; + let (_, bottom) = rects[1]; + assert_eq!(top.col, 0); + assert_eq!(top.cols, 80); + // 24 rows - 1 divider = 23, halved to 11 / 12. + assert_eq!(top.rows, 11); + assert_eq!(bottom.row, 12); // 11 + 1 divider + assert_eq!(bottom.rows, 12); + assert_eq!(bottom.row, top.row + top.rows + 1); + } + + #[test] + fn two_by_two_grid_has_four_nonoverlapping_panes() { + // Split vertical, then split each side horizontally: the founder's + // headline "four agents in a square". + let mut t = Tree::new(0); + t.split(Dir::Vertical, 1); // focus -> 1 (right) + t.split(Dir::Horizontal, 2); // splits right column -> focus 2 + // move focus back to the left column and split it + t.focus = 0; + t.split(Dir::Horizontal, 3); + assert_eq!(t.len(), 4); + let rects = t.rects(OUTER); + assert_eq!(rects.len(), 4); + // All four ids present. + let ids: Vec = rects.iter().map(|(id, _)| *id).collect(); + for id in 0..4 { + assert!(ids.contains(&id), "missing pane {id} in {ids:?}"); + } + // No two rects overlap. + for i in 0..rects.len() { + for j in (i + 1)..rects.len() { + assert!( + !overlaps(rects[i].1, rects[j].1), + "{:?} vs {:?}", + rects[i], + rects[j] + ); + } + } + } + + fn overlaps(a: Rect, b: Rect) -> bool { + let ax2 = a.col + a.cols; + let ay2 = a.row + a.rows; + let bx2 = b.col + b.cols; + let by2 = b.row + b.rows; + a.col < bx2 && b.col < ax2 && a.row < by2 && b.row < ay2 + } + + #[test] + fn focus_moves_geometrically_across_a_2x2_grid() { + let mut t = two_by_two(); + // Find the top-left pane and focus it. + let rects = t.rects(OUTER); + let top_left = rects + .iter() + .min_by_key(|(_, r)| (r.row, r.col)) + .map(|(id, _)| *id) + .unwrap(); + t.focus = top_left; + // Right then down then left then up returns to top-left. + let r = t.move_focus(Move::Right, OUTER); + assert_ne!(r, top_left, "right should move to another pane"); + let d = t.move_focus(Move::Down, OUTER); + assert_ne!(d, r, "down should move again"); + t.move_focus(Move::Left, OUTER); + let back = t.move_focus(Move::Up, OUTER); + assert_eq!(back, top_left, "up+left should return to the top-left pane"); + } + + #[test] + fn move_focus_is_a_noop_off_the_edge() { + let mut t = Tree::new(0); + t.split(Dir::Vertical, 1); + t.focus = 0; // left pane + // Nothing to the left of the left pane. + assert_eq!(t.move_focus(Move::Left, OUTER), 0); + // Right reaches pane 1. + assert_eq!(t.move_focus(Move::Right, OUTER), 1); + } + + #[test] + fn closing_a_pane_collapses_to_the_sibling() { + let mut t = Tree::new(0); + t.split(Dir::Vertical, 1); // now [0 | 1], focus 1 + assert!(t.close(1)); + assert!(t.is_single()); + assert_eq!(t.ids(), vec![0]); + assert_eq!(t.focus(), 0); // focus fell back to the survivor + } + + #[test] + fn closing_re_tiles_a_2x2_into_three() { + let mut t = two_by_two(); + let victim = t.ids()[0]; + assert!(t.close(victim)); + assert_eq!(t.len(), 3); + assert!(!t.ids().contains(&victim)); + // Remaining three still tile without overlap. + let rects = t.rects(OUTER); + assert_eq!(rects.len(), 3); + } + + #[test] + fn last_pane_cannot_be_closed() { + let mut t = Tree::new(0); + assert!(!t.close(0)); + assert!(t.is_single()); + } + + fn two_by_two() -> Tree { + let mut t = Tree::new(0); + t.split(Dir::Vertical, 1); + t.split(Dir::Horizontal, 2); + t.focus = 0; + t.split(Dir::Horizontal, 3); + t + } +} diff --git a/src/lib.rs b/src/lib.rs index 685db3b..34a5dc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,4 +18,6 @@ pub mod bar; pub mod filter; pub mod input; +pub mod layout; pub mod resolve; +pub mod tile; diff --git a/src/main.rs b/src/main.rs index 4d15b6c..78c1443 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,76 @@ -//! The amux binary: the passthrough loop. +//! The amux binary: a dual-mode run loop — passthrough (0.1) and tiled (0.2). //! //! amux # panes run your shell (COMSPEC / $SHELL) //! amux claude # panes run `claude` //! amux claude --continue # any command + args //! -//! Ctrl+A then: c new pane, n/p cycle, 1-9 switch, x kill, q quit. +//! Ctrl+A then: c new window, n/p cycle, 1-9 switch windows, x kill focused +//! pane, q quit; `"`/`%` split the focused pane, h/j/k/l or arrows move focus, +//! z zoom (full-screen passthrough) the focused pane. +//! +//! ## The two rendering modes +//! +//! A window whose split tree is a single pane — or any window with its focused +//! pane *zoomed* — renders in **passthrough**: the pane's raw VT bytes go +//! straight to the real terminal, zero emulation, perfect fidelity. This is the +//! 0.1 path, untouched. +//! +//! The moment a window holds two or more visible panes it renders **tiled**: +//! each pane drives a `vterm::Term` sized to its rect, every pane's screen is +//! composited into one master `ansi::Screen`, and `Screen::diff` writes only the +//! changed bytes. Zoom is the escape hatch back to perfect fidelity for a TUI +//! that the emulator can't render pixel-exact (wide glyphs, sixel, mouse). use amux::bar::{bar_paint, PaneInfo}; -use amux::input::{Action, PrefixScanner}; +use amux::input::{Action, Dir, PrefixScanner}; +use amux::layout::{self, Rect, Tree}; +use amux::tile::{compose, PaneView}; use std::io::Write; use std::process::ExitCode; use std::time::{Duration, Instant}; +/// One hosted terminal: a pty, its emulator (for tiled compositing), its +/// passthrough filter (for the passthrough / zoom path), and bar metadata. Each +/// pane has a stable `id` the window's split tree refers to. struct Pane { + id: usize, pty: pty::Pty, + term: vterm::Term, + filter: amux::filter::Passthrough, title: String, activity: bool, exited: bool, - filter: amux::filter::Passthrough, +} + +/// One window: a split tree over a set of panes, plus a zoom flag. Windows are +/// the 0.1 "switchable full-screen" concept; each can now itself be a tiled +/// split tree (design doc §3: "windows and splits coexist"). +struct Window { + panes: Vec, + tree: Tree, + zoomed: bool, + next_id: usize, +} + +impl Window { + fn pane(&self, id: usize) -> Option<&Pane> { + self.panes.iter().find(|p| p.id == id) + } + + fn pane_mut(&mut self, id: usize) -> Option<&mut Pane> { + self.panes.iter_mut().find(|p| p.id == id) + } + + fn focused_mut(&mut self) -> Option<&mut Pane> { + let f = self.tree.focus(); + self.pane_mut(f) + } + + /// Tiled iff more than one pane and not zoomed. A single pane, or a zoomed + /// pane, is passthrough. + fn tiled(&self) -> bool { + self.panes.len() > 1 && !self.zoomed + } } fn main() -> ExitCode { @@ -41,39 +94,44 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let code = run(&mut term, &command); - // rawterm's Drop restores modes after our own screen cleanup ran. - code + run(&mut term, &command) } fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { let mut out = std::io::stdout(); let (mut rows, mut cols) = term.size().unwrap_or((24, 80)); - // Alt screen; scroll region above the bar so bottom-line newlines from - // the passthrough stream can never push the bar away. + // Alt screen; scroll region above the bar so bottom-line newlines from the + // passthrough stream can never push the bar away. let _ = write!(out, "\x1b[?1049h\x1b[2J\x1b[H\x1b[1;{}r", rows - 1); let _ = out.flush(); - let mut panes: Vec = Vec::new(); - match spawn_pane(command, rows, cols) { - Ok(p) => panes.push(p), + let mut windows: Vec = Vec::new(); + match spawn_window(command, rows, cols, 0) { + Ok(w) => windows.push(w), Err(e) => { cleanup_screen(&mut out); eprintln!("amux: cannot start {:?}: {e}", command[0]); return ExitCode::FAILURE; } } - let mut active = 0usize; + let mut active = 0usize; // active window index let mut scanner = PrefixScanner::new(); let mut buf = [0u8; 8192]; - let mut force_bar = true; + let mut force_repaint = true; let mut last_bar_paint = Instant::now(); let mut last_size_check = Instant::now(); let mut last_bar = String::new(); let mut flash: Option<(String, Instant)> = None; + // The previous composited master, kept per-frame so tiled mode diffs. Reset + // to None (full repaint) on mode/layout/window changes. + let mut prev_master: Option = None; + // The read at the top can fail (terminal gone) *and* commands deep inside + // `break 'outer`; a labeled `loop` expresses both. clippy's while-let + // rewrite can't host the labeled break, so allow it here. + #[allow(clippy::while_let_loop)] 'outer: loop { - // 1. keystrokes -> scanner -> pane / commands + // 1. keystrokes -> scanner -> focused pane / commands let bytes = match term.read_bytes(Duration::from_millis(15)) { Ok(b) => b, Err(_) => break, @@ -85,70 +143,109 @@ fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { for action in scanner.feed(&bytes) { match action { Action::Forward(b) => { - let _ = panes[active].pty.write(&b); + if let Some(p) = windows[active].focused_mut() { + let _ = p.pty.write(&b); + } } Action::NextPane => { - let next = (active + 1) % panes.len(); - switch( - &mut panes, - &mut active, - next, - rows, - cols, - &mut out, - &mut force_bar, - ); + let next = (active + 1) % windows.len(); + switch_window(&mut windows, &mut active, next, rows, cols, &mut out); + prev_master = None; + force_repaint = true; } Action::PrevPane => { - let prev = (active + panes.len() - 1) % panes.len(); - switch( - &mut panes, - &mut active, - prev, - rows, - cols, - &mut out, - &mut force_bar, - ); + let prev = (active + windows.len() - 1) % windows.len(); + switch_window(&mut windows, &mut active, prev, rows, cols, &mut out); + prev_master = None; + force_repaint = true; } Action::SwitchTo(i) => { - if i < panes.len() { - switch( - &mut panes, - &mut active, - i, - rows, - cols, - &mut out, - &mut force_bar, - ); + if i < windows.len() { + switch_window(&mut windows, &mut active, i, rows, cols, &mut out); + prev_master = None; + force_repaint = true; } } - Action::NewPane => match spawn_pane(command, rows, cols) { - Ok(p) => { - panes.push(p); - let last = panes.len() - 1; - switch( - &mut panes, - &mut active, - last, - rows, - cols, - &mut out, - &mut force_bar, - ); + Action::NewPane => match spawn_window(command, rows, cols, windows.len()) { + Ok(w) => { + windows.push(w); + let last = windows.len() - 1; + switch_window(&mut windows, &mut active, last, rows, cols, &mut out); + prev_master = None; + force_repaint = true; } Err(e) => { - // Never fail silently: put the reason in the bar. flash = Some(( format!("cannot start {:?}: {e}", command[0]), Instant::now(), )); - force_bar = true; + force_repaint = true; } }, + Action::SplitH => { + split_focused( + &mut windows[active], + layout::Dir::Horizontal, + command, + rows, + cols, + &mut flash, + ); + resize_window(&mut windows[active], rows, cols); + prev_master = None; + force_repaint = true; + } + Action::SplitV => { + split_focused( + &mut windows[active], + layout::Dir::Vertical, + command, + rows, + cols, + &mut flash, + ); + resize_window(&mut windows[active], rows, cols); + prev_master = None; + force_repaint = true; + } + Action::MoveFocus(d) => { + let outer = tiled_outer(rows, cols); + windows[active].tree.move_focus(to_move(d), outer); + prev_master = None; + force_repaint = true; + } + Action::Zoom => { + let w = &mut windows[active]; + // Zoom only means something with more than one pane. + if w.panes.len() > 1 { + w.zoomed = !w.zoomed; + resize_window(w, rows, cols); + prev_master = None; + force_repaint = true; + } + } Action::KillPane => { - let _ = panes[active].pty.kill(); + let w = &mut windows[active]; + let victim = w.tree.focus(); + if w.panes.len() > 1 { + // Multi-pane window: close synchronously so focus leaves + // the dying pane at once (the next keystrokes must route + // to the survivor, not the corpse) and the frame re-tiles + // without waiting for the async reap. + if let Some(p) = w.pane_mut(victim) { + let _ = p.pty.kill(); + } + w.tree.close(victim); + w.panes.retain(|p| p.id != victim); + w.zoomed = w.zoomed && w.panes.len() > 1; + resize_window(w, rows, cols); + prev_master = None; + force_repaint = true; + } else if let Some(p) = w.pane_mut(victim) { + // Sole pane: async kill; the reap step drops the window + // and the app exits when the last window is gone (0.1). + let _ = p.pty.kill(); + } } Action::Quit => { if std::env::var_os("AMUX_DEBUG").is_some() { @@ -159,70 +256,96 @@ fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { } } - // 2. active pane output: passthrough, draining bursts - for i in 0..8 { - let wait = if i == 0 { - Duration::from_millis(5) - } else { - Duration::ZERO - }; - match panes[active].pty.read_timeout(&mut buf, wait) { - Ok(Some(n)) if n > 0 => { - if std::env::var_os("AMUX_DEBUG").is_some() { - eprint!("[amux-dbg pane-out {n}]\r\n"); + // 2. drain every pane in the active window (all panes are live in + // tiled mode); feed the emulator, and in passthrough also write the + // focused pane's cleaned bytes straight through. + let tiled = windows[active].tiled(); + let focus = windows[active].tree.focus(); + for pane in windows[active].panes.iter_mut() { + for i in 0..8 { + let wait = if i == 0 { + Duration::from_millis(5) + } else { + Duration::ZERO + }; + match pane.pty.read_timeout(&mut buf, wait) { + Ok(Some(n)) if n > 0 => { + // Always feed the emulator so a later switch/split/zoom + // renders the current screen without a repaint nudge. + pane.term.feed(&buf[..n]); + if !tiled && pane.id == focus { + let cleaned = pane.filter.feed(&buf[..n]); + let _ = out.write_all(&cleaned); + } else if pane.id != focus { + pane.activity = true; + } } - let cleaned = panes[active].filter.feed(&buf[..n]); - let _ = out.write_all(&cleaned); - } - Ok(Some(_)) => { - panes[active].exited = true; - break; + Ok(Some(_)) => { + pane.exited = true; + break; + } + _ => break, } - _ => break, } } - let _ = out.flush(); + if !tiled { + let _ = out.flush(); + } - // 3. background panes: drain (discarded — ConPTY repaints on - // switch) and flag activity - for (i, pane) in panes.iter_mut().enumerate() { + // 3. background windows: drain (discarded — emulator/ConPTY keep the + // screen) and flag activity so the bar shows it. + for (i, w) in windows.iter_mut().enumerate() { if i == active { continue; } - while let Ok(Some(n)) = pane.pty.read_timeout(&mut buf, Duration::ZERO) { - if n == 0 { - pane.exited = true; - break; + for pane in w.panes.iter_mut() { + while let Ok(Some(n)) = pane.pty.read_timeout(&mut buf, Duration::ZERO) { + if n == 0 { + pane.exited = true; + break; + } + pane.term.feed(&buf[..n]); + pane.activity = true; } - pane.activity = true; } } - // 4. reap exits; drop dead panes - for pane in panes.iter_mut() { - if let Ok(Some(_)) = pane.pty.try_wait() { - pane.exited = true; + // 4. reap exits; drop dead panes and empty windows, re-tiling. + let mut layout_changed = false; + for w in windows.iter_mut() { + for pane in w.panes.iter_mut() { + if let Ok(Some(_)) = pane.pty.try_wait() { + pane.exited = true; + } + } + if w.panes.iter().any(|p| p.exited) { + let dead: Vec = w.panes.iter().filter(|p| p.exited).map(|p| p.id).collect(); + for id in dead { + // Collapse the tree first (keeps focus valid), then drop the + // pane. A last-pane close leaves the tree single; the window + // itself is removed below when its panes go empty. + let _ = w.tree.close(id); + } + w.panes.retain(|p| !p.exited); + w.zoomed = w.zoomed && w.panes.len() > 1; + layout_changed = true; } } - if panes.iter().any(|p| p.exited) { - let was_active_title = panes[active].title.clone(); - let old_active_alive = !panes[active].exited; - panes.retain(|p| !p.exited); - if panes.is_empty() { + if layout_changed { + let empty_before_active = windows[..active] + .iter() + .filter(|w| w.panes.is_empty()) + .count(); + windows.retain(|w| !w.panes.is_empty()); + if windows.is_empty() { break; } - if old_active_alive { - active = panes - .iter() - .position(|p| p.title == was_active_title) - .unwrap_or(0) - .min(panes.len() - 1); - } else { - let target = active.min(panes.len() - 1); - active = target; - repaint_pane(&mut panes[target], rows, cols, &mut out); - } - force_bar = true; + active = active + .saturating_sub(empty_before_active) + .min(windows.len() - 1); + resize_window(&mut windows[active], rows, cols); + prev_master = None; + force_repaint = true; } // 5. resize propagation @@ -233,23 +356,43 @@ fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { rows = r; cols = c; let _ = write!(out, "\x1b[1;{}r", rows - 1); - for pane in panes.iter_mut() { - let _ = pane.pty.resize(rows - 1, cols); + for w in windows.iter_mut() { + resize_window(w, rows, cols); } - force_bar = true; + prev_master = None; + force_repaint = true; } } } - // 6. the bar (periodic repaint survives a pane's own clear-screen) - let infos: Vec = panes + // 6. render the active window (tiled compose+diff, else passthrough is + // already written above) then paint the bar. + if windows[active].tiled() { + let master = render_tiled(&windows[active], rows, cols); + let bytes = match &prev_master { + Some(prev) => prev.diff(&master), + None => master.render_full(), + }; + if !bytes.is_empty() { + let _ = out.write_all(&bytes); + let _ = out.flush(); + } + prev_master = Some(master); + } else if force_repaint { + // Passthrough (single pane or zoomed): nudge the focused pane's pty + // so it repaints in full, the same trick 0.1 uses on window switch. + repaint_focused(&mut windows[active], rows, cols, &mut out); + } + + // 7. the bar (windows, with the active one starred) + let infos: Vec = windows .iter() .enumerate() - .map(|(i, p)| PaneInfo { - title: p.title.clone(), + .map(|(i, w)| PaneInfo { + title: w.panes.first().map(|p| p.title.clone()).unwrap_or_default(), active: i == active, - activity: p.activity, - exited: p.exited, + activity: w.panes.iter().any(|p| p.activity), + exited: w.panes.iter().all(|p| p.exited), }) .collect(); if flash @@ -257,11 +400,11 @@ fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { .is_some_and(|(_, at)| at.elapsed() > Duration::from_secs(5)) { flash = None; - force_bar = true; + force_repaint = true; } let note = flash.as_ref().map(|(m, _)| m.as_str()).unwrap_or(""); let painted = bar_paint(&infos, rows, cols as usize, note); - if force_bar + if force_repaint || painted != last_bar || last_bar_paint.elapsed() >= Duration::from_millis(500) { @@ -269,13 +412,15 @@ fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { let _ = out.flush(); last_bar = painted; last_bar_paint = Instant::now(); - force_bar = false; } + force_repaint = false; } let dbg = std::env::var_os("AMUX_DEBUG").is_some(); - for pane in panes.iter_mut() { - let _ = pane.pty.kill(); + for w in windows.iter_mut() { + for pane in w.panes.iter_mut() { + let _ = pane.pty.kill(); + } } if dbg { eprint!("[amux-dbg killed]\r\n"); @@ -284,56 +429,174 @@ fn run(term: &mut rawterm::Terminal, command: &[String]) -> ExitCode { if dbg { eprint!("[amux-dbg cleaned]\r\n"); } - drop(panes); + drop(windows); if dbg { eprint!("[amux-dbg panes-dropped]\r\n"); } ExitCode::SUCCESS } -fn spawn_pane(command: &[String], rows: u16, cols: u16) -> std::io::Result { - let title = std::path::Path::new(&command[0]) - .file_stem() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| command[0].clone()); - let effective = effective_command(command); - let argrefs: Vec<&str> = effective[1..].iter().map(String::as_str).collect(); - let pty = pty::Pty::spawn(&effective[0], &argrefs, rows.saturating_sub(1).max(1), cols)?; - Ok(Pane { - pty, - title, - activity: false, - exited: false, - filter: amux::filter::Passthrough::new(), - }) +/// The tiled drawing area: the whole terminal minus the bar row (last line). +fn tiled_outer(rows: u16, cols: u16) -> Rect { + Rect { + row: 0, + col: 0, + rows: (rows as usize).saturating_sub(1).max(1), + cols: cols as usize, + } } -fn switch( - panes: &mut [Pane], +fn to_move(d: Dir) -> layout::Move { + match d { + Dir::Left => layout::Move::Left, + Dir::Right => layout::Move::Right, + Dir::Up => layout::Move::Up, + Dir::Down => layout::Move::Down, + } +} + +/// Compose the active window's panes into a master screen (content + dividers + +/// focus highlight); the caller diffs it to the terminal. +fn render_tiled(w: &Window, rows: u16, cols: u16) -> ansi::Screen { + let outer = tiled_outer(rows, cols); + let rects = w.tree.rects(outer); + let focus = w.tree.focus(); + let views: Vec = rects + .iter() + .filter_map(|(id, rect)| { + w.pane(*id).map(|p| PaneView { + screen: p.term.screen(), + rect: *rect, + focused: *id == focus, + }) + }) + .collect(); + // Compose over the full terminal (rows), leaving the bar row untouched; the + // bar is painted separately after the diff, exactly as in passthrough. + compose(rows as usize, cols as usize, &views) +} + +/// Split the focused pane, spawning a new pane sized to what its half will be. +/// On spawn failure the split is abandoned and the reason lands in the bar. +fn split_focused( + w: &mut Window, + dir: layout::Dir, + command: &[String], + rows: u16, + cols: u16, + flash: &mut Option<(String, Instant)>, +) { + let new_id = w.next_id; + // Size the new pane roughly to a half; resize_window fixes it exactly after. + let (pr, pc) = (rows.saturating_sub(1).max(1) / 2, cols / 2); + match spawn_pane(command, pr.max(1), pc.max(1), new_id) { + Ok(pane) => { + w.panes.push(pane); + w.next_id += 1; + w.tree.split(dir, new_id); + w.zoomed = false; // a fresh split is always tiled + } + Err(e) => { + *flash = Some(( + format!("cannot start {:?}: {e}", command[0]), + Instant::now(), + )); + let _ = rows; + let _ = cols; + } + } +} + +/// Recompute every pane's rect and push the size to its pty and emulator. In +/// passthrough (single/zoomed) the focused pane owns the whole area; in tiled +/// mode each pane gets its rect. +fn resize_window(w: &mut Window, rows: u16, cols: u16) { + if w.tiled() { + let outer = tiled_outer(rows, cols); + let rects = w.tree.rects(outer); + for (id, rect) in rects { + if let Some(p) = w.pane_mut(id) { + let _ = p.pty.resize(rect.rows as u16, rect.cols as u16); + p.term.resize(rect.rows, rect.cols); + } + } + } else { + // Passthrough: the focused (or sole) pane fills the area above the bar. + let ar = rows.saturating_sub(1).max(1); + let focus = w.tree.focus(); + let sole = w.panes.len() == 1; + for p in w.panes.iter_mut() { + if p.id == focus || sole { + let _ = p.pty.resize(ar, cols); + p.term.resize(ar as usize, cols as usize); + } + } + } +} + +fn switch_window( + windows: &mut [Window], active: &mut usize, to: usize, rows: u16, cols: u16, out: &mut impl Write, - force_bar: &mut bool, ) { if to == *active { return; } *active = to; - panes[to].activity = false; - repaint_pane(&mut panes[to], rows, cols, out); - *force_bar = true; + for p in windows[to].panes.iter_mut() { + p.activity = false; + } + let _ = write!(out, "\x1b[2J\x1b[H"); + let _ = out.flush(); + resize_window(&mut windows[to], rows, cols); } -/// Bring a pane's screen back: clear our display, then nudge the pane's -/// size so its terminal repaints in full (ConPTY always does; Unix -/// full-screen apps redraw on SIGWINCH). -fn repaint_pane(pane: &mut Pane, rows: u16, cols: u16, out: &mut impl Write) { +/// Passthrough repaint: nudge the focused pane's pty size so its terminal +/// repaints in full (ConPTY always does; Unix full-screen apps redraw on +/// SIGWINCH). Used on window switch and mode changes into passthrough. +fn repaint_focused(w: &mut Window, rows: u16, cols: u16, out: &mut impl Write) { let _ = write!(out, "\x1b[2J\x1b[H"); let _ = out.flush(); - let _ = pane.pty.resize(rows.saturating_sub(2).max(1), cols); - let _ = pane.pty.resize(rows.saturating_sub(1).max(1), cols); + let ar = rows.saturating_sub(1).max(1); + let focus = w.tree.focus(); + if let Some(p) = w.pane_mut(focus) { + let _ = p.pty.resize(ar.saturating_sub(1).max(1), cols); + let _ = p.pty.resize(ar, cols); + } +} + +fn spawn_window(command: &[String], rows: u16, cols: u16, _idx: usize) -> std::io::Result { + let pane = spawn_pane(command, rows.saturating_sub(1).max(1), cols, 0)?; + Ok(Window { + panes: vec![pane], + tree: Tree::new(0), + zoomed: false, + next_id: 1, + }) +} + +fn spawn_pane(command: &[String], rows: u16, cols: u16, id: usize) -> std::io::Result { + let title = std::path::Path::new(&command[0]) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| command[0].clone()); + let effective = effective_command(command); + let argrefs: Vec<&str> = effective[1..].iter().map(String::as_str).collect(); + let r = rows.max(1); + let c = cols.max(1); + let pty = pty::Pty::spawn(&effective[0], &argrefs, r, c)?; + Ok(Pane { + id, + pty, + term: vterm::Term::new(r as usize, c as usize), + filter: amux::filter::Passthrough::new(), + title, + activity: false, + exited: false, + }) } fn cleanup_screen(out: &mut impl Write) { @@ -342,9 +605,9 @@ fn cleanup_screen(out: &mut impl Write) { let _ = out.flush(); } -/// Diagnostic mode: print the hex of every raw byte stdin delivers for a -/// few seconds. Answers "what does my terminal actually send?" — including -/// through nested consoles — without guessing. +/// Diagnostic mode: print the hex of every raw byte stdin delivers for a few +/// seconds. Answers "what does my terminal actually send?" — including through +/// nested consoles — without guessing. fn stdin_probe() -> ExitCode { let mut term = match rawterm::Terminal::raw() { Ok(t) => t, @@ -379,10 +642,9 @@ fn default_shell() -> String { } } -/// On Windows, resolve the command the way the shell would (PATH x -/// PATHEXT) and host `.cmd`/`.bat` shims under `cmd /C` — npm-installed -/// CLIs (Claude Code included) are such shims, and `CreateProcessW` -/// cannot launch them directly. +/// On Windows, resolve the command the way the shell would (PATH x PATHEXT) and +/// host `.cmd`/`.bat` shims under `cmd /C` — npm-installed CLIs (Claude Code +/// included) are such shims, and `CreateProcessW` cannot launch them directly. #[cfg(windows)] fn effective_command(command: &[String]) -> Vec { use amux::resolve; diff --git a/src/tile.rs b/src/tile.rs new file mode 100644 index 0000000..d4e3b9d --- /dev/null +++ b/src/tile.rs @@ -0,0 +1,306 @@ +//! The tiled-mode compositor: blit every pane's emulated [`ansi::Screen`] into +//! one master screen at its rect offset, draw the dividers that sit in the +//! gutters the layout reserved, highlight the focused pane's edges, and report +//! where the real cursor should be parked. +//! +//! This is pure grid math — screens in, a master screen out — so it is +//! unit-testable without a terminal or a pty. The run loop diffs the master +//! against the previous frame ([`ansi::Screen::diff`]) and writes only the +//! changed bytes, the same discipline the status bar already uses. +//! +//! Fidelity note: tiling is the *emulated* path. A pane that needs pixel-exact +//! rendering (a TUI mid-redraw, wide/CJK glyphs, sixel) takes the escape hatch +//! — `Ctrl+A z` zoom drops it back to raw passthrough. See the 0.2 design doc, +//! §1 and the vterm README's fidelity boundaries. + +use crate::layout::Rect; +use ansi::{Cell, Color, Screen, Style}; + +/// The character painted in divider gutters between panes. +const DIVIDER: char = '│'; +const DIVIDER_H: char = '─'; + +/// One pane to composite: its emulated screen, its rect in master coords, and +/// whether it holds focus. +pub struct PaneView<'a> { + pub screen: &'a Screen, + pub rect: Rect, + pub focused: bool, +} + +/// Compose `panes` into a fresh master [`Screen`] of `rows x cols`. Cells +/// outside every pane rect are divider gutters (drawn where a divider column / +/// row sits between two panes, blank elsewhere). The focused pane's gutter +/// edges are drawn reversed to mark focus. `bar_row` (0-based) is left blank — +/// the caller paints the existing status bar there after diffing, exactly as in +/// passthrough mode. +/// +/// Returns the master screen with its `cursor` set to the focused pane's +/// cursor, translated into master coordinates (clamped into the pane rect). +pub fn compose(rows: usize, cols: usize, panes: &[PaneView]) -> Screen { + let mut master = Screen::new(rows, cols); + + // 1. Blit each pane's content at its offset. + for p in panes { + blit(&mut master, p.screen, p.rect); + } + + // 2. Draw dividers in the gutters. A gutter cell is any master cell that is + // directly between two pane rects — we detect it structurally by drawing + // a divider on the column just right of every pane that has a right + // neighbor, and the row just below every pane that has a bottom + // neighbor. Focused-pane edges are reversed to highlight focus. + for p in panes { + draw_edges(&mut master, p, panes, rows, cols); + } + + // 3. Park the cursor at the focused pane's cursor in master coords. + if let Some(f) = panes.iter().find(|p| p.focused) { + let (cr, cc) = f.screen.cursor; + let r = (f.rect.row + cr).min(f.rect.row + f.rect.rows.saturating_sub(1)); + let c = (f.rect.col + cc).min(f.rect.col + f.rect.cols.saturating_sub(1)); + master.cursor = (r.min(rows.saturating_sub(1)), c.min(cols.saturating_sub(1))); + } + + master +} + +/// Copy a pane's screen into the master at `rect`, truncating anything past the +/// rect's bounds (a pane should already be sized to its rect, but a resize race +/// could leave it momentarily larger — clip rather than overwrite neighbors). +fn blit(master: &mut Screen, src: &Screen, rect: Rect) { + let rows = rect.rows.min(src.rows()); + let cols = rect.cols.min(src.cols()); + for r in 0..rows { + for c in 0..cols { + master.set(rect.row + r, rect.col + c, src.cell(r, c)); + } + } +} + +/// Draw the divider gutters on a pane's right and bottom edges when a neighbor +/// abuts across the reserved gap. The focused pane draws its dividers reversed +/// so the eye finds the active tile. +fn draw_edges(master: &mut Screen, p: &PaneView, all: &[PaneView], rows: usize, cols: usize) { + let style = if p.focused { + Style { + reverse: true, + fg: Color::Default, + ..Style::default() + } + } else { + Style::default() + }; + + // Right divider column: at col = rect.col + rect.cols, spanning the pane's + // rows, iff there is any pane whose left edge is one past that column. + let right_col = p.rect.col + p.rect.cols; + if right_col < cols && has_neighbor_right(p, all) { + for r in p.rect.row..(p.rect.row + p.rect.rows).min(rows) { + master.set(r, right_col, Cell { ch: DIVIDER, style }); + } + } + // Bottom divider row. + let bottom_row = p.rect.row + p.rect.rows; + if bottom_row < rows && has_neighbor_below(p, all) { + for c in p.rect.col..(p.rect.col + p.rect.cols).min(cols) { + master.set( + bottom_row, + c, + Cell { + ch: DIVIDER_H, + style, + }, + ); + } + } +} + +/// True if some other pane's left edge sits exactly one column right of `p` +/// (i.e. across `p`'s reserved right gutter) and their rows overlap. +fn has_neighbor_right(p: &PaneView, all: &[PaneView]) -> bool { + let gutter = p.rect.col + p.rect.cols; + all.iter().any(|o| { + !std::ptr::eq(o.screen, p.screen) + && o.rect.col == gutter + 1 + && rows_overlap(p.rect, o.rect) + }) +} + +/// True if some other pane's top edge sits one row below `p`'s bottom gutter and +/// their columns overlap. +fn has_neighbor_below(p: &PaneView, all: &[PaneView]) -> bool { + let gutter = p.rect.row + p.rect.rows; + all.iter().any(|o| { + !std::ptr::eq(o.screen, p.screen) + && o.rect.row == gutter + 1 + && cols_overlap(p.rect, o.rect) + }) +} + +fn rows_overlap(a: Rect, b: Rect) -> bool { + a.row < b.row + b.rows && b.row < a.row + a.rows +} + +fn cols_overlap(a: Rect, b: Rect) -> bool { + a.col < b.col + b.cols && b.col < a.col + a.cols +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filled(rows: usize, cols: usize, ch: char) -> Screen { + let mut s = Screen::new(rows, cols); + for r in 0..rows { + for c in 0..cols { + s.set( + r, + c, + Cell { + ch, + style: Style::default(), + }, + ); + } + } + s + } + + #[test] + fn two_panes_composite_at_their_offsets_with_a_divider_between() { + // Left pane 'L' at cols 0..3, right pane 'R' at cols 4..7, divider col 3. + let left = filled(4, 3, 'L'); + let right = filled(4, 3, 'R'); + let panes = vec![ + PaneView { + screen: &left, + rect: Rect { + row: 0, + col: 0, + rows: 4, + cols: 3, + }, + focused: true, + }, + PaneView { + screen: &right, + rect: Rect { + row: 0, + col: 4, + rows: 4, + cols: 3, + }, + focused: false, + }, + ]; + let m = compose(4, 7, &panes); + // Left content. + assert_eq!(m.cell(0, 0).ch, 'L'); + assert_eq!(m.cell(3, 2).ch, 'L'); + // Divider column. + assert_eq!(m.cell(0, 3).ch, '│'); + assert_eq!(m.cell(3, 3).ch, '│'); + // Right content. + assert_eq!(m.cell(0, 4).ch, 'R'); + assert_eq!(m.cell(3, 6).ch, 'R'); + } + + #[test] + fn focused_pane_divider_is_reversed() { + let left = filled(2, 2, 'L'); + let right = filled(2, 2, 'R'); + let panes = vec![ + PaneView { + screen: &left, + rect: Rect { + row: 0, + col: 0, + rows: 2, + cols: 2, + }, + focused: true, + }, + PaneView { + screen: &right, + rect: Rect { + row: 0, + col: 3, + rows: 2, + cols: 2, + }, + focused: false, + }, + ]; + let m = compose(2, 5, &panes); + // The focused (left) pane owns the divider at col 2, drawn reversed. + assert!( + m.cell(0, 2).style.reverse, + "focused divider should be reversed" + ); + } + + #[test] + fn cursor_parks_at_the_focused_pane_in_master_coords() { + let mut left = filled(4, 4, 'L'); + left.cursor = (1, 2); + let right = filled(4, 4, 'R'); + let panes = vec![ + PaneView { + screen: &left, + rect: Rect { + row: 0, + col: 0, + rows: 4, + cols: 4, + }, + focused: false, + }, + PaneView { + screen: &right, + rect: Rect { + row: 0, + col: 5, + rows: 4, + cols: 4, + }, + focused: true, + }, + ]; + // Right pane cursor at its origin -> master (0, 5). + let m = compose(4, 9, &panes); + assert_eq!(m.cursor, (0, 5)); + } + + #[test] + fn horizontal_divider_row_drawn_between_stacked_panes() { + let top = filled(2, 4, 'T'); + let bottom = filled(2, 4, 'B'); + let panes = vec![ + PaneView { + screen: &top, + rect: Rect { + row: 0, + col: 0, + rows: 2, + cols: 4, + }, + focused: false, + }, + PaneView { + screen: &bottom, + rect: Rect { + row: 3, + col: 0, + rows: 2, + cols: 4, + }, + focused: false, + }, + ]; + let m = compose(5, 4, &panes); + assert_eq!(m.cell(0, 0).ch, 'T'); + assert_eq!(m.cell(2, 0).ch, '─'); // divider row + assert_eq!(m.cell(3, 0).ch, 'B'); + } +} diff --git a/tests/amux.rs b/tests/amux.rs index aced483..ca17769 100644 --- a/tests/amux.rs +++ b/tests/amux.rs @@ -3,7 +3,7 @@ //! its passthrough output read back. Deadline-bounded throughout. use amux::bar::{bar_text, PaneInfo}; -use amux::input::{Action, PrefixScanner}; +use amux::input::{Action, Dir, PrefixScanner}; use std::time::{Duration, Instant}; // --- prefix scanner --------------------------------------------------------- @@ -48,8 +48,51 @@ fn prefix_survives_chunk_boundaries() { #[test] fn unknown_command_swallows_prefix_and_byte() { + // `.` is not a bound command, so the prefix and it are both swallowed. let mut s = PrefixScanner::new(); - assert_eq!(s.feed(b"a\x01zb"), vec![Action::Forward(b"ab".to_vec())]); + assert_eq!(s.feed(b"a\x01.b"), vec![Action::Forward(b"ab".to_vec())]); +} + +#[test] +fn tiling_commands_are_recognized() { + let mut s = PrefixScanner::new(); + assert_eq!(s.feed(b"\x01\""), vec![Action::SplitH]); + assert_eq!(s.feed(b"\x01%"), vec![Action::SplitV]); + assert_eq!(s.feed(b"\x01z"), vec![Action::Zoom]); + assert_eq!(s.feed(b"\x01h"), vec![Action::MoveFocus(Dir::Left)]); + assert_eq!(s.feed(b"\x01j"), vec![Action::MoveFocus(Dir::Down)]); + assert_eq!(s.feed(b"\x01k"), vec![Action::MoveFocus(Dir::Up)]); + assert_eq!(s.feed(b"\x01l"), vec![Action::MoveFocus(Dir::Right)]); +} + +#[test] +fn prefixed_arrow_keys_move_focus() { + // Ctrl+A then ESC [ C -> move focus right; the whole three-byte arrow + // sequence is consumed as one command, and nothing leaks to the pane. + let mut s = PrefixScanner::new(); + assert_eq!(s.feed(b"\x01\x1b[C"), vec![Action::MoveFocus(Dir::Right)]); + assert_eq!(s.feed(b"\x01\x1b[A"), vec![Action::MoveFocus(Dir::Up)]); + assert_eq!(s.feed(b"\x01\x1b[B"), vec![Action::MoveFocus(Dir::Down)]); + assert_eq!(s.feed(b"\x01\x1b[D"), vec![Action::MoveFocus(Dir::Left)]); +} + +#[test] +fn prefixed_arrow_survives_chunk_boundaries() { + // The arrow's bytes arrive one per read; the state machine holds across. + let mut s = PrefixScanner::new(); + assert_eq!(s.feed(b"\x01"), vec![]); + assert!(s.armed()); + assert_eq!(s.feed(b"\x1b"), vec![]); + assert_eq!(s.feed(b"["), vec![]); + assert_eq!(s.feed(b"C"), vec![Action::MoveFocus(Dir::Right)]); + assert!(!s.armed()); +} + +#[test] +fn bare_arrow_keys_still_forward_to_the_pane() { + // Without a prefix, arrows are ordinary bytes for the child (0.1 behavior). + let mut s = PrefixScanner::new(); + assert_eq!(s.feed(b"\x1b[C"), vec![Action::Forward(b"\x1b[C".to_vec())]); } #[test] @@ -299,6 +342,162 @@ fn new_pane_opens_and_switches() { assert_eq!(wait_exit(&mut p, 15), 0); } +// --- tiling (0.2): splits, focus, zoom, kill-retile ------------------------- + +/// Spawn amux hosting an interactive shell in a pty, returning it once the bar +/// has appeared (the shell is up). Shared setup for the tiling e2e tests. +fn spawn_amux_shell(rows: u16, cols: u16) -> pty::Pty { + let (shell, args): (&str, Vec<&str>) = if cfg!(windows) { + ("cmd", vec!["/Q"]) + } else { + ("sh", vec!["-i"]) + }; + let mut argv = vec![shell]; + argv.extend(args); + let mut p = pty::Pty::spawn(env!("CARGO_BIN_EXE_amux"), &argv, rows, cols).unwrap(); + let bar: &[u8] = if cfg!(windows) { b"1:cmd" } else { b"1:sh" }; + read_until(&mut p, bar, Duration::from_secs(15)); + p +} + +/// Ctrl+A % splits the focused pane into a second live tile, and *both* shells +/// round-trip: a marker echoed in each appears in the composited output. +#[test] +fn split_creates_a_second_live_tile_and_both_shells_roundtrip() { + let mut p = spawn_amux_shell(24, 100); + // Echo a marker in the first pane, then split vertically and echo another + // marker in the new (focused) pane. + p.write(b"echo amux-tileA\r\n").unwrap(); + let out = read_until(&mut p, b"amux-tileA", Duration::from_secs(15)); + assert!( + contains(&out, b"amux-tileA"), + "first pane silent: {:?}", + String::from_utf8_lossy(&out) + ); + p.write(b"\x01%").unwrap(); // split vertical -> focus the new pane + p.write(b"echo amux-tileB\r\n").unwrap(); + let out = read_until(&mut p, b"amux-tileB", Duration::from_secs(15)); + assert!( + contains(&out, b"amux-tileB"), + "second tile silent after split: {:?}", + String::from_utf8_lossy(&out) + ); + p.write(b"\x01q").unwrap(); + assert_eq!(wait_exit(&mut p, 15), 0); +} + +/// A 2x2 grid: split vertical, split the right column horizontally, focus the +/// left column and split it horizontally — four live panes. Echo a unique +/// marker in each and assert all four reach the composited frame. +#[test] +fn two_by_two_grid_has_four_live_panes() { + let mut p = spawn_amux_shell(30, 120); + // Pane 1 (top-left after the splits below) — mark it before splitting so the + // first shell is proven live. + p.write(b"echo amux-q1\r\n").unwrap(); + read_until(&mut p, b"amux-q1", Duration::from_secs(15)); + // Build the square: % (vertical) then " (horizontal on the right), then move + // focus back to the left column with h and " to split it. + p.write(b"\x01%").unwrap(); // now two columns, focus right + p.write(b"echo amux-q2\r\n").unwrap(); + read_until(&mut p, b"amux-q2", Duration::from_secs(15)); + p.write(b"\x01\"").unwrap(); // split right column -> focus bottom-right + p.write(b"echo amux-q3\r\n").unwrap(); + read_until(&mut p, b"amux-q3", Duration::from_secs(15)); + p.write(b"\x01h").unwrap(); // focus back to the left column + p.write(b"\x01\"").unwrap(); // split it -> focus bottom-left + p.write(b"echo amux-q4\r\n").unwrap(); + // Collect everything for a few seconds and assert all four markers appeared. + let out = read_until(&mut p, b"amux-q4", Duration::from_secs(15)); + for m in [ + &b"amux-q1"[..], + &b"amux-q2"[..], + &b"amux-q3"[..], + &b"amux-q4"[..], + ] { + assert!( + contains(&out, m), + "missing {} in 2x2 frame: {:?}", + String::from_utf8_lossy(m), + String::from_utf8_lossy(&out) + ); + } + p.write(b"\x01q").unwrap(); + assert_eq!(wait_exit(&mut p, 15), 0); +} + +/// Focus movement changes which pane receives input: after a split, moving +/// focus back to the original pane makes *it* echo the next command. +#[test] +fn focus_movement_routes_input_to_the_focused_pane() { + let mut p = spawn_amux_shell(24, 100); + p.write(b"\x01%").unwrap(); // split -> focus the new (right) pane + p.write(b"echo amux-right-pane\r\n").unwrap(); + read_until(&mut p, b"amux-right-pane", Duration::from_secs(15)); + // Move focus left (h) back to the original pane and run a distinct command. + p.write(b"\x01h").unwrap(); + p.write(b"echo amux-left-again\r\n").unwrap(); + let out = read_until(&mut p, b"amux-left-again", Duration::from_secs(15)); + assert!( + contains(&out, b"amux-left-again"), + "focus did not route back to the left pane: {:?}", + String::from_utf8_lossy(&out) + ); + p.write(b"\x01q").unwrap(); + assert_eq!(wait_exit(&mut p, 15), 0); +} + +/// Zoom toggles a tiled pane to full-screen passthrough and back; the shell +/// keeps round-tripping across both toggles. +#[test] +fn zoom_toggles_and_pane_stays_live() { + let mut p = spawn_amux_shell(24, 100); + p.write(b"\x01%").unwrap(); // two tiles + p.write(b"echo amux-prezoom\r\n").unwrap(); + read_until(&mut p, b"amux-prezoom", Duration::from_secs(15)); + p.write(b"\x01z").unwrap(); // zoom the focused pane full-screen + p.write(b"echo amux-zoomed\r\n").unwrap(); + let out = read_until(&mut p, b"amux-zoomed", Duration::from_secs(15)); + assert!( + contains(&out, b"amux-zoomed"), + "zoomed pane silent: {:?}", + String::from_utf8_lossy(&out) + ); + p.write(b"\x01z").unwrap(); // un-zoom back to tiled + p.write(b"echo amux-unzoomed\r\n").unwrap(); + let out = read_until(&mut p, b"amux-unzoomed", Duration::from_secs(15)); + assert!( + contains(&out, b"amux-unzoomed"), + "pane dead after un-zoom: {:?}", + String::from_utf8_lossy(&out) + ); + p.write(b"\x01q").unwrap(); + assert_eq!(wait_exit(&mut p, 15), 0); +} + +/// Killing the focused pane in a split re-tiles down to the survivor, which is +/// still live (and, being the sole pane, back in passthrough). +#[test] +fn kill_focused_pane_retiles_to_survivor() { + let mut p = spawn_amux_shell(24, 100); + p.write(b"echo amux-keepme\r\n").unwrap(); + read_until(&mut p, b"amux-keepme", Duration::from_secs(15)); + p.write(b"\x01%").unwrap(); // split -> focus new pane + p.write(b"echo amux-killme\r\n").unwrap(); + read_until(&mut p, b"amux-killme", Duration::from_secs(15)); + p.write(b"\x01x").unwrap(); // kill the focused (new) pane + // The survivor takes over full-screen and still talks: + p.write(b"echo amux-survivor\r\n").unwrap(); + let out = read_until(&mut p, b"amux-survivor", Duration::from_secs(15)); + assert!( + contains(&out, b"amux-survivor"), + "survivor dead after kill/re-tile: {:?}", + String::from_utf8_lossy(&out) + ); + p.write(b"\x01q").unwrap(); + assert_eq!(wait_exit(&mut p, 15), 0); +} + /// A literal Ctrl+A goes through with the doubled prefix. #[test] fn double_prefix_reaches_the_child() {