diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 7807524d..7306af18 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -34,7 +34,21 @@ pub trait Backend { fn hello(&mut self) -> Result; - fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option) -> Result; + /// Start a shell for `workspace`. `size` is the grid the GUI is going to + /// draw it at, when the caller can work that out: a shell spawned at the + /// wrong size runs whatever it is sent first at that size, and the resize + /// the GUI sends a moment later starts a new scrollback segment, so + /// `capture` would no longer show it. + fn spawn_shell( + &mut self, + workspace: WorkspaceId, + cwd: Option, + size: Option, + ) -> Result; + + /// The pane's grid as the server last sized it, or `None` when the pane + /// cannot be read. Best effort: only ever used to size a new pane. + fn pane_size(&mut self, pane: u64) -> Option; fn send_input(&mut self, pane: u64, bytes: Vec) -> Result<()>; @@ -86,7 +100,7 @@ pub mod mock { use tty7_core::daemon::control::{ CONTROL_VERSION, ControlEvent, ControlHelloOk, ControlRequest, ReplyOk, feature, }; - use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs}; + use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs, WinSize}; use super::{Backend, CaptureSegment, RunSpec}; use crate::exec::{ExecEnd, ExecRun}; @@ -97,6 +111,8 @@ pub mod mock { pub control_calls: Vec, pub spawned: Vec<(WorkspaceId, Option)>, pub next_spawn_id: u64, + pub spawned_sizes: Vec>, + pub pane_sizes: std::collections::HashMap, pub sent: Vec<(u64, Vec)>, pub captured: Vec<(u64, bool)>, pub capture_segments: Vec, @@ -127,6 +143,8 @@ pub mod mock { control_calls: Vec::new(), spawned: Vec::new(), next_spawn_id: 6, + spawned_sizes: Vec::new(), + pane_sizes: std::collections::HashMap::new(), sent: Vec::new(), captured: Vec::new(), capture_segments: Vec::new(), @@ -194,13 +212,23 @@ pub mod mock { }) } - fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option) -> Result { + fn spawn_shell( + &mut self, + workspace: WorkspaceId, + cwd: Option, + size: Option, + ) -> Result { self.spawned.push((workspace, cwd)); + self.spawned_sizes.push(size); let id = self.next_spawn_id; self.next_spawn_id += 1; Ok(id) } + fn pane_size(&mut self, pane: u64) -> Option { + self.pane_sizes.get(&pane).copied() + } + fn send_input(&mut self, pane: u64, bytes: Vec) -> Result<()> { self.sent.push((pane, bytes)); Ok(()) diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index 7e6e96c4..fb39b120 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -21,6 +21,10 @@ const SESSION_SIZE: WinSize = WinSize { cell_h: 16, }; +/// Reading a pane's size for a split must not hold the split up: a server +/// that is slow to answer gets the stand-in size, as before this was asked. +const PANE_SIZE_FIRST_WAIT: Duration = Duration::from_secs(2); +const PANE_SIZE_SETTLE: Duration = Duration::from_millis(100); const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10); const REPLAY_SETTLE: Duration = Duration::from_millis(300); /// A backstop on reading the replay: it ends on the first live output or a @@ -133,13 +137,18 @@ impl Backend for RealBackend { Ok(self.control_client()?.hello().clone()) } - fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option) -> Result { + fn spawn_shell( + &mut self, + workspace: WorkspaceId, + cwd: Option, + size: Option, + ) -> Result { let workspace = workspace.to_string(); let session = self .pane_client()? .spawn( cwd.map(PathBuf::from), - SESSION_SIZE, + size.unwrap_or(SESSION_SIZE), None, Some(workspace.clone()), Some(workspace), @@ -150,6 +159,30 @@ impl Backend for RealBackend { Ok(pane) } + fn pane_size(&mut self, pane: u64) -> Option { + let mut session = self.pane_client().ok()?.observe(pane, SESSION_SIZE).ok()?; + // The ring replays as `Size`, `Snapshot` pairs, oldest first, and a + // segment only starts on a resize — so the last `Size` before anything + // else arrives is the size the pane has now. The replay is queued in + // one go, which is why a short quiet spell is enough to call it over. + let _ = session.set_recv_timeout(Some(PANE_SIZE_FIRST_WAIT)); + let mut size = None; + loop { + match session.recv() { + Ok(DaemonMsg::Size(seen)) => { + size = Some(seen); + let _ = session.set_recv_timeout(Some(PANE_SIZE_SETTLE)); + } + Ok(DaemonMsg::Snapshot(_)) => { + let _ = session.set_recv_timeout(Some(PANE_SIZE_SETTLE)); + } + _ => break, + } + } + let _ = session.detach(); + size.filter(|s| s.cols > 0 && s.rows > 0) + } + fn send_input(&mut self, pane: u64, bytes: Vec) -> Result<()> { self.pane_client()? .send_input(pane, &bytes) diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index b67168a0..16601162 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -2,11 +2,11 @@ use anyhow::{Context as _, Result, bail}; use serde_json::{Value, json}; use std::time::Duration; use tty7_core::core::agent_hooks::{HookAgent, HooksState}; -use tty7_core::core::machine::{Axis, Machine, PaneSeed, Workspace}; +use tty7_core::core::machine::{Axis, Machine, PaneNode, PaneSeed, Workspace}; use tty7_core::core::session::WorkspaceId; use tty7_core::core::tab_view::tab_views_of; use tty7_core::daemon::control::{CONTROL_VERSION, ControlEvent, ControlRequest, ReplyOk}; -use tty7_core::daemon::protocol::PROTOCOL_VERSION; +use tty7_core::daemon::protocol::{PROTOCOL_VERSION, WinSize}; use crate::address::{self, Context, WorkspaceAddress}; use crate::backend::{Backend, RunSpec}; @@ -376,6 +376,9 @@ fn ws_detach(ws: &str, backend: &mut dyn Backend) -> Result { } fn new_workspace(path: Option, open: bool, backend: &mut dyn Backend) -> Result { + let size = fetch_machine(backend) + .ok() + .and_then(|machine| last_tab_grid(&machine, backend)); let ws = match backend.control(ControlRequest::WorkspaceCreate { name: None, workspace: None, @@ -383,7 +386,7 @@ fn new_workspace(path: Option, open: bool, backend: &mut dyn Backend) -> ReplyOk::WorkspaceTree(ws) => *ws, other => bail!("the server answered WorkspaceCreate with {other:?}"), }; - let pane = backend.spawn_shell(ws.id, path.clone())?; + let pane = backend.spawn_shell(ws.id, path.clone(), size)?; backend.control(ControlRequest::TabCreate { workspace: ws.id, at: None, @@ -509,7 +512,10 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu } else { Axis::Vertical }; - let new = backend.spawn_shell(workspace, cwd.clone())?; + let size = backend + .pane_size(pane) + .map(|parent| split_share(parent, axis, args.ratio)); + let new = backend.spawn_shell(workspace, cwd.clone(), size)?; backend.control(ControlRequest::PaneSplit { workspace, pane, @@ -527,6 +533,71 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu report(format!("%{new}"), json!({ "pane": new })) } +/// The grid a pane split off `parent` will be drawn at: the new pane takes +/// the share the existing one does not keep, less a cell for the divider +/// between them. Close to what the GUI lays out, which is what matters — the +/// GUI's own resize then moves it by a cell at most, instead of reflowing +/// every line a shell spawned at a stand-in size has already printed. +fn split_share(parent: WinSize, axis: Axis, ratio: f32) -> WinSize { + let share = |n: u16| { + let kept = ratio.clamp(0.05, 0.95); + ((f32::from(n.saturating_sub(1)) * (1. - kept)).floor() as u16).max(1) + }; + match axis { + Axis::Horizontal => WinSize { + cols: share(parent.cols), + ..parent + }, + Axis::Vertical => WinSize { + rows: share(parent.rows), + ..parent + }, + } +} + +/// The grid a whole tab of `ws` is drawn at, so a new tab's shell can start at +/// it: the active tab's layout added back up from its panes. `None` when no +/// pane in it can be read — the caller falls back to the server's default. +fn tab_grid(ws: &Workspace, backend: &mut dyn Backend) -> Option { + fn whole(node: &PaneNode, backend: &mut dyn Backend) -> Option { + match node { + PaneNode::Leaf { pane } => backend.pane_size(*pane), + PaneNode::Split { axis, a, b, .. } => { + let (a, b) = (whole(a, backend)?, whole(b, backend)?); + Some(match axis { + Axis::Horizontal => WinSize { + cols: a.cols.saturating_add(b.cols).saturating_add(1), + rows: a.rows.max(b.rows), + ..a + }, + Axis::Vertical => WinSize { + rows: a.rows.saturating_add(b.rows).saturating_add(1), + cols: a.cols.max(b.cols), + ..a + }, + }) + } + } + } + let tab = ws + .active_tab + .and_then(|id| ws.tabs.iter().find(|t| t.id == id)) + .or_else(|| ws.tabs.first())?; + whole(&tab.root, backend) +} + +/// [`tab_grid`] for whichever workspace was used last — a brand-new +/// workspace has no tab of its own to measure, and it opens in the same +/// window size the last one was shown in. +fn last_tab_grid(machine: &Machine, backend: &mut dyn Backend) -> Option { + let ws = machine + .workspaces + .iter() + .filter(|ws| !ws.tabs.is_empty()) + .max_by_key(|ws| ws.last_active)?; + tab_grid(ws, backend) +} + fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { // `--enter` is the same thing as `--key enter`, and predates it. Keeping it // as sugar rather than deprecating it: it reads better for the overwhelming @@ -1019,7 +1090,12 @@ fn tab_new( Some(spec) => adopt_pane(spec, explicit, cwd, ctx, &machine, backend)?, None => { let id = resolve_ws(explicit, ctx, &machine)?; - let pane = backend.spawn_shell(id, cwd.clone())?; + let size = machine + .workspaces + .iter() + .find(|ws| ws.id == id) + .and_then(|ws| tab_grid(ws, backend)); + let pane = backend.spawn_shell(id, cwd.clone(), size)?; (id, pane, cwd) } }; @@ -1218,7 +1294,12 @@ fn worktree_new(args: WorktreeNew, ctx: &Context, backend: &mut dyn Backend) -> ); let cwd = wt.path.to_string_lossy().into_owned(); - let pane = backend.spawn_shell(ws, Some(cwd.clone()))?; + let size = machine + .workspaces + .iter() + .find(|w| w.id == ws) + .and_then(|w| tab_grid(w, backend)); + let pane = backend.spawn_shell(ws, Some(cwd.clone()), size)?; let tab = match backend.control(ControlRequest::TabCreate { workspace: ws, at: None, @@ -2635,6 +2716,7 @@ mod tests { assert_eq!( backend.control_calls, vec![ + ControlRequest::MachineGet, ControlRequest::WorkspaceCreate { name: None, workspace: None, @@ -3118,6 +3200,70 @@ mod tests { ); } + fn grid(cols: u16, rows: u16) -> WinSize { + WinSize { + cols, + rows, + cell_w: 9, + cell_h: 21, + } + } + + /// A shell spawned at a stand-in 120x30 runs whatever `send` gives it at + /// that width, then the GUI's resize starts a new scrollback segment and + /// `capture` shows none of it. So the new pane starts at its share of the + /// pane it was split off. + #[test] + fn pane_split_spawns_the_new_shell_at_its_share_of_the_parent() { + let mut backend = mock(); + backend.pane_sizes.insert(2, grid(128, 40)); + run_cli( + &["tty7", "pane", "split", "%2", "--h"], + &Context::default(), + &mut backend, + ); + run_cli( + &["tty7", "pane", "split", "%2", "--v", "--ratio", "0.3"], + &Context::default(), + &mut backend, + ); + assert_eq!( + backend.spawned_sizes, + vec![Some(grid(63, 40)), Some(grid(128, 27))], + "side by side halves the columns, stacked gives the new pane the 70% \ + the existing one does not keep — each less a cell for the divider" + ); + } + + #[test] + fn pane_split_of_an_unreadable_pane_leaves_the_size_to_the_server() { + let mut backend = mock(); + run_cli( + &["tty7", "pane", "split", "%2", "--h"], + &Context::default(), + &mut backend, + ); + assert_eq!(backend.spawned_sizes, vec![None]); + } + + #[test] + fn tab_new_spawns_at_the_size_of_the_workspaces_tab_added_back_up() { + let mut backend = mock(); + let api = backend.machine.workspaces[0].clone(); + backend.machine.workspaces[0].active_tab = Some(api.tabs[1].id); + backend.pane_sizes.insert(2, grid(63, 40)); + backend.pane_sizes.insert(3, grid(64, 40)); + backend + .replies + .push_back(ReplyOk::TabTree(Box::new(Tab::leaf(6)))); + run_cli( + &["tty7", "tab", "new", &api.id.to_string()], + &Context::default(), + &mut backend, + ); + assert_eq!(backend.spawned_sizes, vec![Some(grid(128, 40))]); + } + #[test] fn split_without_an_address_uses_the_pane_from_the_environment() { let mut backend = mock();