The TUI repainted on every 33ms tick whether or not anything had changed — measured at ~725 bytes/s of escape sequences with nothing attached and nothing happening. Over a remote terminal that is constant traffic for no visible change. Output was also unbuffered: `CrosstermBackend::new(std::io::stdout())` issues one `write!` per cell run, 3551 of them for a 200x50 repaint, each taking the global stdout lock. A BufWriter collapses a frame into one flush (measured: 3551 writes -> 2). Frames are now keyed on the catalog version, the active pane's screen revision, an input epoch and the terminal size, and skipped when the key is unchanged. Session cards carry an advancing duration, so a frame still never stands for longer than a second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
6.4 KiB
Rust
190 lines
6.4 KiB
Rust
//! `SessionEmulators` — `SessionId → SessionHandle` registry, the single
|
|
//! source of truth for "which sessions are currently attached".
|
|
//!
|
|
//! Plus [`ResizeTracker`] — every pane reports its allocated `(cols, rows)`
|
|
//! per render, and the tracker computes the per-dimension minimum across all
|
|
//! panes bound to the same session. That min is what the dispatcher sends
|
|
//! to the daemon as a `ClientControl::Resize` so the PTY matches what every
|
|
//! viewer can actually display.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use uuid::Uuid;
|
|
|
|
use crate::session_task::SessionHandle;
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct SessionEmulators {
|
|
handles: HashMap<Uuid, SessionHandle>,
|
|
resize: ResizeTracker,
|
|
}
|
|
|
|
impl SessionEmulators {
|
|
pub fn get(&self, id: Uuid) -> Option<&SessionHandle> {
|
|
self.handles.get(&id)
|
|
}
|
|
|
|
pub fn insert(&mut self, id: Uuid, handle: SessionHandle) {
|
|
self.handles.insert(id, handle);
|
|
}
|
|
|
|
/// Remove the handle for `id` (typically because the session ended or
|
|
/// the user closed the last pane bound to it). Drops the resize entries
|
|
/// for the session at the same time so a re-attach starts fresh.
|
|
pub fn remove(&mut self, id: Uuid) {
|
|
if let Some(h) = self.handles.remove(&id) {
|
|
h.end();
|
|
}
|
|
self.resize.drop_session(id);
|
|
}
|
|
|
|
pub fn ids(&self) -> impl Iterator<Item = Uuid> + '_ {
|
|
self.handles.keys().copied()
|
|
}
|
|
|
|
pub fn resize_tracker(&self) -> &ResizeTracker {
|
|
&self.resize
|
|
}
|
|
|
|
pub fn resize_tracker_mut(&mut self) -> &mut ResizeTracker {
|
|
&mut self.resize
|
|
}
|
|
}
|
|
|
|
/// Per-pane size table for a multi-pane view of the same session. The
|
|
/// renderer calls [`set_pane_size`](ResizeTracker::set_pane_size) once per
|
|
/// frame; the dispatcher calls [`session_size`](ResizeTracker::session_size)
|
|
/// to discover the per-dimension minimum to wire to the daemon.
|
|
#[derive(Debug, Default)]
|
|
pub struct ResizeTracker {
|
|
per_pane: HashMap<(Uuid, String), (u16, u16)>,
|
|
}
|
|
|
|
impl ResizeTracker {
|
|
/// Record a pane's currently-allocated `(cols, rows)`. `pane_id` is any
|
|
/// stable identifier for the pane — caller's choice. v1 has one pane per
|
|
/// tab, so a `Tab N` identifier is fine.
|
|
pub fn set_pane_size(&mut self, sid: Uuid, pane_id: &str, size: (u16, u16)) {
|
|
self.per_pane.insert((sid, pane_id.to_string()), size);
|
|
}
|
|
|
|
/// Forget a pane's recorded size. Use when a pane is destroyed (tab
|
|
/// closed) but other panes may still be displaying the same session.
|
|
pub fn drop_pane(&mut self, sid: Uuid, pane_id: &str) {
|
|
self.per_pane.remove(&(sid, pane_id.to_string()));
|
|
}
|
|
|
|
/// Per-dimension minimum across every pane bound to `sid`. Returns
|
|
/// `None` if no pane is currently tracking this session.
|
|
pub fn session_size(&self, sid: Uuid) -> Option<(u16, u16)> {
|
|
let mut cols: Option<u16> = None;
|
|
let mut rows: Option<u16> = None;
|
|
for ((s, _), (c, r)) in &self.per_pane {
|
|
if *s == sid {
|
|
cols = Some(cols.map_or(*c, |x| x.min(*c)));
|
|
rows = Some(rows.map_or(*r, |x| x.min(*r)));
|
|
}
|
|
}
|
|
match (cols, rows) {
|
|
(Some(c), Some(r)) => Some((c, r)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Forget every pane for a given session. Called by
|
|
/// [`SessionEmulators::remove`] so a future re-attach gets a fresh size.
|
|
pub fn drop_session(&mut self, sid: Uuid) {
|
|
self.per_pane.retain(|(s, _), _| *s != sid);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn uid(n: u128) -> Uuid {
|
|
Uuid::from_u128(n)
|
|
}
|
|
|
|
#[test]
|
|
fn single_pane_yields_its_own_size() {
|
|
let mut tracker = ResizeTracker::default();
|
|
tracker.set_pane_size(uid(1), "pane-a", (120, 40));
|
|
assert_eq!(tracker.session_size(uid(1)), Some((120, 40)));
|
|
}
|
|
|
|
#[test]
|
|
fn multi_pane_takes_per_dimension_min() {
|
|
let mut tracker = ResizeTracker::default();
|
|
tracker.set_pane_size(uid(1), "pane-a", (120, 30));
|
|
tracker.set_pane_size(uid(1), "pane-b", (80, 50));
|
|
// Independent dims: cols come from b, rows from a.
|
|
assert_eq!(tracker.session_size(uid(1)), Some((80, 30)));
|
|
}
|
|
|
|
#[test]
|
|
fn sessions_dont_cross_contaminate() {
|
|
let mut tracker = ResizeTracker::default();
|
|
tracker.set_pane_size(uid(1), "pane-a", (100, 30));
|
|
tracker.set_pane_size(uid(2), "pane-b", (50, 20));
|
|
assert_eq!(tracker.session_size(uid(1)), Some((100, 30)));
|
|
assert_eq!(tracker.session_size(uid(2)), Some((50, 20)));
|
|
}
|
|
|
|
#[test]
|
|
fn drop_pane_recomputes_min() {
|
|
let mut tracker = ResizeTracker::default();
|
|
tracker.set_pane_size(uid(1), "a", (120, 30));
|
|
tracker.set_pane_size(uid(1), "b", (80, 50));
|
|
tracker.drop_pane(uid(1), "b");
|
|
assert_eq!(tracker.session_size(uid(1)), Some((120, 30)));
|
|
}
|
|
|
|
#[test]
|
|
fn drop_session_clears_all_panes() {
|
|
let mut tracker = ResizeTracker::default();
|
|
tracker.set_pane_size(uid(1), "a", (120, 30));
|
|
tracker.set_pane_size(uid(1), "b", (80, 50));
|
|
tracker.drop_session(uid(1));
|
|
assert_eq!(tracker.session_size(uid(1)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn unset_session_has_no_size() {
|
|
let tracker = ResizeTracker::default();
|
|
assert_eq!(tracker.session_size(uid(99)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn emulators_insert_and_get() {
|
|
use crate::session_task::{SessionHandle, SessionState};
|
|
use std::sync::atomic::AtomicU8;
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::mpsc;
|
|
|
|
let mut emulators = SessionEmulators::default();
|
|
let id = uid(42);
|
|
let handle = SessionHandle {
|
|
tx: mpsc::channel(1).0,
|
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
|
revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
};
|
|
emulators.insert(id, handle);
|
|
assert!(emulators.get(id).is_some());
|
|
emulators.remove(id);
|
|
assert!(emulators.get(id).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn emulators_remove_also_drops_resize_entries() {
|
|
let mut emulators = SessionEmulators::default();
|
|
let id = uid(7);
|
|
emulators
|
|
.resize_tracker_mut()
|
|
.set_pane_size(id, "p", (80, 24));
|
|
emulators.remove(id);
|
|
assert_eq!(emulators.resize_tracker().session_size(id), None);
|
|
}
|
|
}
|