pub mod keys;
use std::collections::HashMap;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use tui_term::widget::PseudoTerminal;
use crate::tmux;
pub use keys::{encode as encode_key, send_keys_command};
pub const DEFAULT_SCROLLBACK: usize = 10_000;
#[derive(Debug, Default, Clone)]
pub struct PaneState {
title: Option<String>,
bell: bool,
}
impl vt100::Callbacks for PaneState {
fn set_window_title(&mut self, _: &mut vt100::Screen, title: &[u8]) {
self.title = Some(String::from_utf8_lossy(title).into_owned());
}
fn audible_bell(&mut self, _: &mut vt100::Screen) {
self.bell = true;
}
}
pub struct PaneTerminal {
parser: vt100::Parser<PaneState>,
}
impl PaneTerminal {
pub fn new(size: (u16, u16), scrollback: usize) -> Self {
let (cols, rows) = size;
Self {
parser: vt100::Parser::new_with_callbacks(rows, cols, scrollback, PaneState::default()),
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.parser.process(bytes);
}
pub fn resize(&mut self, size: (u16, u16)) {
let (cols, rows) = size;
self.parser.screen_mut().set_size(rows, cols);
}
pub fn size(&self) -> (u16, u16) {
let (rows, cols) = self.parser.screen().size();
(cols, rows)
}
pub fn screen(&self) -> &vt100::Screen {
self.parser.screen()
}
pub fn scrollback(&self) -> usize {
self.parser.screen().scrollback()
}
pub fn scroll_by(&mut self, delta: isize) -> bool {
let was = self.scrollback();
let want = was.saturating_add_signed(delta);
self.parser.screen_mut().set_scrollback(want);
self.scrollback() != was
}
pub fn scroll_to_bottom(&mut self) {
self.parser.screen_mut().set_scrollback(0);
}
pub fn contents(&self) -> String {
self.parser.screen().contents()
}
pub fn cursor(&self) -> (u16, u16) {
let (row, col) = self.parser.screen().cursor_position();
(col, row)
}
pub fn title(&self) -> Option<&str> {
self.parser.callbacks().title.as_deref()
}
pub fn bell(&self) -> bool {
self.parser.callbacks().bell
}
pub fn take_bell(&mut self) -> bool {
std::mem::replace(&mut self.parser.callbacks_mut().bell, false)
}
pub fn widget(&self) -> PseudoTerminal<'_, vt100::Screen> {
PseudoTerminal::new(self.parser.screen())
}
}
impl Widget for &PaneTerminal {
fn render(self, area: Rect, buf: &mut Buffer) {
self.widget().render(area, buf);
}
}
pub struct Panes {
panes: HashMap<String, PaneTerminal>,
size: (u16, u16),
scrollback: usize,
}
impl Panes {
pub fn new(size: (u16, u16), scrollback: usize) -> Self {
Self {
panes: HashMap::new(),
size,
scrollback,
}
}
pub fn apply(&mut self, event: &tmux::Event) -> bool {
match event {
tmux::Event::Output { pane, data } => {
let size = self.size;
let scrollback = self.scrollback;
self.panes
.entry(pane.clone())
.or_insert_with(|| PaneTerminal::new(size, scrollback))
.feed(data);
true
}
_ => false,
}
}
pub fn get(&self, pane: &str) -> Option<&PaneTerminal> {
self.panes.get(pane)
}
pub fn get_mut(&mut self, pane: &str) -> Option<&mut PaneTerminal> {
self.panes.get_mut(pane)
}
pub fn ensure(&mut self, pane: &str) -> &mut PaneTerminal {
let size = self.size;
let scrollback = self.scrollback;
self.panes
.entry(pane.to_string())
.or_insert_with(|| PaneTerminal::new(size, scrollback))
}
pub fn remove(&mut self, pane: &str) -> Option<PaneTerminal> {
self.panes.remove(pane)
}
pub fn ids(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self.panes.keys().map(String::as_str).collect();
ids.sort();
ids
}
pub fn len(&self) -> usize {
self.panes.len()
}
pub fn is_empty(&self) -> bool {
self.panes.is_empty()
}
pub fn resize(&mut self, size: (u16, u16)) {
self.size = size;
for pane in self.panes.values_mut() {
pane.resize(size);
}
}
pub fn size(&self) -> (u16, u16) {
self.size
}
}
#[cfg(test)]
mod tests {
use super::*;
fn output(pane: &str, data: &[u8]) -> tmux::Event {
tmux::Event::Output {
pane: pane.to_string(),
data: data.to_vec(),
}
}
fn term() -> PaneTerminal {
PaneTerminal::new((20, 5), 100)
}
fn scrolled(count: usize) -> PaneTerminal {
let mut pane = term();
for n in 0..count {
pane.feed(format!("line {n}\r\n").as_bytes());
}
pane
}
#[test]
fn a_pane_can_be_scrolled_back_through_what_it_printed() {
let mut pane = scrolled(30);
assert_eq!(pane.scrollback(), 0, "starts on the live screen");
assert!(pane.contents().contains("line 29"));
assert!(pane.scroll_by(10));
assert_eq!(pane.scrollback(), 10);
assert!(pane.contents().contains("line 19"), "{}", pane.contents());
assert!(!pane.contents().contains("line 29"));
}
#[test]
fn scrolling_past_the_ends_stops_rather_than_wrapping() {
let mut pane = scrolled(30);
pane.scroll_by(10_000);
let all_the_way = pane.scrollback();
assert!(all_the_way > 0);
assert!(
!pane.scroll_by(10),
"already at the top, so nothing moved and no redraw is owed"
);
pane.scroll_by(-10_000);
assert_eq!(pane.scrollback(), 0);
assert!(!pane.scroll_by(-10));
}
#[test]
fn a_pane_can_be_brought_back_to_what_is_happening_now() {
let mut pane = scrolled(30);
pane.scroll_by(15);
pane.scroll_to_bottom();
assert_eq!(pane.scrollback(), 0);
assert!(pane.contents().contains("line 29"));
}
fn render(term: &PaneTerminal, cols: u16, rows: u16) -> Vec<String> {
let area = Rect::new(0, 0, cols, rows);
let mut buf = Buffer::empty(area);
term.render(area, &mut buf);
(0..rows)
.map(|y| {
(0..cols)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn plain_text_lands_on_the_screen() {
let mut term = term();
term.feed(b"hello");
assert!(term.contents().contains("hello"));
}
#[test]
fn dimensions_are_cols_by_rows_not_transposed() {
let term = PaneTerminal::new((80, 24), 0);
assert_eq!(term.size(), (80, 24));
let (rows, cols) = term.screen().size();
assert_eq!((rows, cols), (24, 80), "vt100 takes rows first");
}
#[test]
fn resizing_keeps_the_cols_rows_order() {
let mut term = term();
term.resize((100, 30));
assert_eq!(term.size(), (100, 30));
}
#[test]
fn escape_sequences_are_interpreted_not_printed() {
let mut term = term();
term.feed(b"\x1b[31mred\x1b[0m");
let contents = term.contents();
assert!(contents.contains("red"));
assert!(
!contents.contains('\x1b'),
"the escape must be consumed, got {contents:?}"
);
}
#[test]
fn cursor_movement_is_tracked_as_col_row() {
let mut term = term();
term.feed(b"\x1b[3;7H");
assert_eq!(term.cursor(), (6, 2), "reported as (col, row), zero-based");
}
#[test]
fn carriage_return_overwrites_rather_than_appends() {
let mut term = term();
term.feed(b"first\rsecond");
let first_line = term.contents().lines().next().unwrap().to_string();
assert_eq!(first_line, "second");
}
#[test]
fn clearing_the_screen_works() {
let mut term = term();
term.feed(b"junk");
term.feed(b"\x1b[2J\x1b[H");
assert_eq!(term.contents().trim(), "");
}
#[test]
fn bytes_split_across_feeds_still_parse() {
let mut term = term();
term.feed(b"\x1b[");
term.feed(b"31m");
term.feed(b"split");
assert!(term.contents().contains("split"));
assert!(!term.contents().contains('['));
}
#[test]
fn a_title_is_captured_when_set() {
let mut term = term();
assert_eq!(term.title(), None);
term.feed(b"\x1b]2;my title\x07");
assert_eq!(term.title(), Some("my title"));
}
#[test]
fn renders_into_a_ratatui_buffer() {
let mut term = PaneTerminal::new((10, 3), 0);
term.feed(b"ab\r\ncd");
assert_eq!(render(&term, 10, 3), ["ab", "cdâ–ˆ", ""]);
}
#[test]
fn rendering_preserves_colour() {
let mut term = PaneTerminal::new((10, 1), 0);
term.feed(b"\x1b[31mR\x1b[0m");
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
(&term).render(area, &mut buf);
assert_eq!(
buf[(0, 0)].fg,
ratatui::style::Color::Indexed(1),
"styling must survive the widget"
);
}
#[test]
fn a_bell_is_recorded_and_consumable() {
let mut term = term();
assert!(!term.bell());
term.feed(b"before\x07after");
assert!(term.bell(), "BEL should be noticed");
assert!(term.take_bell());
assert!(!term.bell(), "taking the bell clears it");
assert!(
term.contents().contains("beforeafter"),
"BEL must not print"
);
}
#[test]
fn output_events_reach_the_right_pane() {
let mut panes = Panes::new((20, 5), 100);
panes.apply(&output("%0", b"zero"));
panes.apply(&output("%1", b"one"));
assert!(panes.get("%0").unwrap().contents().contains("zero"));
assert!(panes.get("%1").unwrap().contents().contains("one"));
assert!(
!panes.get("%0").unwrap().contents().contains("one"),
"panes must not bleed into each other"
);
}
#[test]
fn a_pane_appears_on_its_first_output() {
let mut panes = Panes::new((20, 5), 100);
assert!(panes.is_empty());
assert!(panes.apply(&output("%7", b"hi")));
assert_eq!(panes.len(), 1);
assert!(panes.get("%7").unwrap().contents().contains("hi"));
}
#[test]
fn non_output_events_change_nothing() {
let mut panes = Panes::new((20, 5), 100);
assert!(!panes.apply(&tmux::Event::SessionsChanged));
assert!(!panes.apply(&tmux::Event::Exit { reason: None }));
assert!(panes.is_empty());
}
#[test]
fn successive_output_accumulates_in_one_pane() {
let mut panes = Panes::new((20, 5), 100);
for byte in b"abc" {
panes.apply(&output("%0", &[*byte]));
}
assert_eq!(panes.len(), 1);
assert!(panes.get("%0").unwrap().contents().contains("abc"));
}
#[test]
fn resize_applies_to_existing_and_future_panes() {
let mut panes = Panes::new((20, 5), 100);
panes.apply(&output("%0", b"x"));
panes.resize((80, 24));
assert_eq!(panes.get("%0").unwrap().size(), (80, 24));
panes.apply(&output("%1", b"y"));
assert_eq!(
panes.get("%1").unwrap().size(),
(80, 24),
"a pane created after the resize must use the new size"
);
}
#[test]
fn panes_can_be_created_early_and_removed() {
let mut panes = Panes::new((20, 5), 100);
panes.ensure("%2").feed(b"early");
assert!(panes.get("%2").unwrap().contents().contains("early"));
assert!(panes.remove("%2").is_some());
assert!(panes.get("%2").is_none());
assert!(panes.remove("%2").is_none());
}
#[test]
fn output_reaches_the_rendered_screen_end_to_end() {
use crate::tmux::testing::TestServer;
use crate::tmux::{ControlClient, DEFAULT_SIZE};
use std::time::Duration;
let server = TestServer::new();
let dir = tempfile::TempDir::new().unwrap();
server
.tmux
.new_session("e2e", dir.path(), DEFAULT_SIZE)
.unwrap();
let pane = server.tmux.list_panes("e2e").unwrap().remove(0);
let mut client = ControlClient::attach(&server.tmux, "e2e", DEFAULT_SIZE).unwrap();
let _ = client.wait_for(Duration::from_secs(10), |e| {
matches!(e, tmux::Event::SessionChanged { .. })
});
client
.send_command(&format!("send-keys -t {pane} -l 'printf MARVERPIPE'"))
.unwrap();
client
.send_command(&format!("send-keys -t {pane} Enter"))
.unwrap();
let mut panes = Panes::new(DEFAULT_SIZE, 100);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut rendered = String::new();
while std::time::Instant::now() < deadline {
let Ok(event) = client.next_event(Duration::from_millis(500)) else {
continue;
};
panes.apply(&event);
if let Some(term) = panes.get(&pane) {
rendered = term.contents();
if rendered.contains("MARVERPIPE") {
break;
}
}
}
assert!(
rendered.contains("MARVERPIPE"),
"screen never showed the output; got {rendered:?}"
);
assert!(
!rendered.contains("\\033") && !rendered.contains("\\015"),
"octal escapes were never decoded; got {rendered:?}"
);
assert!(
!rendered.contains('\x1b'),
"decoded escapes must be interpreted by the emulator, not displayed"
);
}
#[test]
fn pane_ids_come_back_in_a_stable_order() {
let mut panes = Panes::new((20, 5), 100);
for id in ["%2", "%0", "%10", "%1"] {
panes.apply(&output(id, b"x"));
}
assert_eq!(panes.ids(), ["%0", "%1", "%10", "%2"]);
}
}