Files
ghostty-rs/crates/ghostty-terminal/src/tmux/layout.rs
T
ddidderr db1d4fe41d feat(terminal): port terminal search and the tmux control-mode viewer
search/: sliding-window matcher over pages, active-area, page list,
viewport and screen searches, and the background search thread (std
thread + crossbeam-channel mailbox, 24 ms refresh, terminal behind
Arc<Mutex<_>>). Searches take &mut Screen explicitly; ScreenSearch::deinit
must be called to untrack pins. The sliding window uses a VecDeque
instead of CircBuf (same results; two layout-only assertions dropped).

tmux/: layout parsing, output formatting and the control-mode viewer
state machine, reusing ghostty_vt::tmux::control for parsing. The viewer
is not wired into termio yet; steps are in the notes.

The renderer must set terminal.flags.search_viewport_dirty for the
search thread to re-search the viewport.

Test Plan:
- cargo test -p ghostty-terminal --lib search (54; 51/53 Zig tests,
  2 allocator-failure tests skipped)
- cargo test -p ghostty-terminal --lib tmux (96; all Zig tests)
2026-10-11 22:51:36 +02:00

468 lines
13 KiB
Rust

//! tmux layout string parsing (port of `tmux/layout.zig`).
/// A tmux layout.
///
/// A tree structure; children are owned by their parent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
/// Width of the node.
pub width: usize,
/// Height of the node.
pub height: usize,
/// X offset from the top-left corner of the window.
pub x: usize,
/// Y offset from the top-left corner of the window.
pub y: usize,
/// The content of this node, either a pane (leaf) or more nodes
/// (split) horizontally or vertically.
pub content: Content,
}
/// Content of a layout node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Content {
/// Leaf pane with its ID.
Pane(usize),
/// Left-right split.
Horizontal(Vec<Layout>),
/// Top-bottom split.
Vertical(Vec<Layout>),
}
/// Errors from layout parsing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
/// The layout string is malformed.
#[error("syntax error")]
SyntaxError,
/// The checksum prefix did not match.
#[error("checksum mismatch")]
ChecksumMismatch,
}
fn parse_usize(s: &[u8]) -> Result<usize, ParseError> {
// std.fmt.parseInt(usize, s, 10): optional '+', digits only.
let s = s.strip_prefix(b"+").unwrap_or(s);
if s.is_empty() {
return Err(ParseError::SyntaxError);
}
let mut v: usize = 0;
for &c in s {
if !c.is_ascii_digit() {
return Err(ParseError::SyntaxError);
}
v = v
.checked_mul(10)
.and_then(|v| v.checked_add(usize::from(c - b'0')))
.ok_or(ParseError::SyntaxError)?;
}
Ok(v)
}
fn index_of_any(s: &[u8], set: &[u8]) -> Option<usize> {
s.iter().position(|c| set.contains(c))
}
impl Layout {
/// Parse a layout string that includes a 4-character checksum prefix
/// (`XXXX,layout_string`).
pub fn parse_with_checksum(str: &[u8]) -> Result<Layout, ParseError> {
if str.len() < 5 || str[4] != b',' {
return Err(ParseError::SyntaxError);
}
let checksum = Checksum::calculate(&str[5..]);
if !str.starts_with(&checksum.as_string()) {
return Err(ParseError::ChecksumMismatch);
}
Self::parse(&str[5..])
}
/// Parse a layout string into a Layout structure.
///
/// - `WxH,X,Y,ID` leaf pane
/// - `WxH,X,Y{...}` horizontal split
/// - `WxH,X,Y[...]` vertical split
pub fn parse(str: &[u8]) -> Result<Layout, ParseError> {
let mut offset = 0;
let root = Self::parse_next(str, &mut offset)?;
if offset != str.len() {
return Err(ParseError::SyntaxError);
}
Ok(root)
}
fn parse_next(str: &[u8], offset: &mut usize) -> Result<Layout, ParseError> {
let err = ParseError::SyntaxError;
let idx = str[*offset..].iter().position(|&c| c == b'x').ok_or(err)?;
let width = parse_usize(&str[*offset..*offset + idx]);
*offset += idx + 1;
let width = width?;
let idx = str[*offset..].iter().position(|&c| c == b',').ok_or(err)?;
let height = parse_usize(&str[*offset..*offset + idx]);
*offset += idx + 1;
let height = height?;
let idx = str[*offset..].iter().position(|&c| c == b',').ok_or(err)?;
let x = parse_usize(&str[*offset..*offset + idx]);
*offset += idx + 1;
let x = x?;
let idx = index_of_any(&str[*offset..], b",{[").ok_or(err)?;
let y = parse_usize(&str[*offset..*offset + idx]);
*offset += idx;
let y = y?;
let content = match str[*offset] {
b',' => {
*offset += 1;
let idx = index_of_any(&str[*offset..], b",}]").unwrap_or(str.len() - *offset);
let pane_id = parse_usize(&str[*offset..*offset + idx]);
*offset += idx;
Content::Pane(pane_id?)
}
opening @ (b'{' | b'[') => {
let mut nodes = Vec::new();
*offset += 1;
loop {
nodes.push(Self::parse_next(str, offset)?);
if *offset >= str.len() {
return Err(err);
}
if str[*offset] == b',' {
*offset += 1;
continue;
}
let close = if opening == b'{' { b'}' } else { b']' };
if str[*offset] != close {
return Err(err);
}
*offset += 1;
break if opening == b'{' {
Content::Horizontal(nodes)
} else {
Content::Vertical(nodes)
};
}
}
_ => unreachable!("index_of_any guarantees one of the delimiters"),
};
Ok(Layout {
width,
height,
x,
y,
content,
})
}
}
/// Checksum of a tmux layout string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Checksum(pub u16);
impl Checksum {
/// Calculate the checksum of a tmux layout string: rotate right by 1
/// bit (with wraparound) and add each byte.
pub fn calculate(str: &[u8]) -> Checksum {
let mut result: u16 = 0;
for &c in str {
result = result.rotate_right(1);
result = result.wrapping_add(u16::from(c));
}
Checksum(result)
}
/// 4-character zero-padded lowercase hex string.
pub fn as_string(self) -> [u8; 4] {
let charset = b"0123456789abcdef";
let v = self.0;
[
charset[usize::from((v >> 12) & 0xf)],
charset[usize::from((v >> 8) & 0xf)],
charset[usize::from((v >> 4) & 0xf)],
charset[usize::from(v & 0xf)],
]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(s: &str) -> Result<Layout, ParseError> {
Layout::parse(s.as_bytes())
}
fn pane(l: &Layout) -> usize {
match l.content {
Content::Pane(p) => p,
_ => panic!("not a pane"),
}
}
fn horiz(l: &Layout) -> &[Layout] {
match &l.content {
Content::Horizontal(c) => c,
_ => panic!("not horizontal"),
}
}
fn vert(l: &Layout) -> &[Layout] {
match &l.content {
Content::Vertical(c) => c,
_ => panic!("not vertical"),
}
}
fn dims(l: &Layout) -> (usize, usize, usize, usize) {
(l.width, l.height, l.x, l.y)
}
fn cs(s: &str) -> String {
String::from_utf8(Checksum::calculate(s.as_bytes()).as_string().to_vec()).unwrap()
}
#[test]
fn simple_single_pane() {
let l = p("80x24,0,0,42").unwrap();
assert_eq!(dims(&l), (80, 24, 0, 0));
assert_eq!(pane(&l), 42);
}
#[test]
fn single_pane_with_offset() {
let l = p("40x12,10,5,7").unwrap();
assert_eq!(dims(&l), (40, 12, 10, 5));
assert_eq!(pane(&l), 7);
}
#[test]
fn single_pane_large_values() {
let l = p("1920x1080,100,200,999").unwrap();
assert_eq!(dims(&l), (1920, 1080, 100, 200));
assert_eq!(pane(&l), 999);
}
#[test]
fn horizontal_split_two_panes() {
let l = p("80x24,0,0{40x24,0,0,1,40x24,40,0,2}").unwrap();
assert_eq!(dims(&l), (80, 24, 0, 0));
let c = horiz(&l);
assert_eq!(c.len(), 2);
assert_eq!(dims(&c[0]), (40, 24, 0, 0));
assert_eq!(pane(&c[0]), 1);
assert_eq!(dims(&c[1]), (40, 24, 40, 0));
assert_eq!(pane(&c[1]), 2);
}
#[test]
fn vertical_split_two_panes() {
let l = p("80x24,0,0[80x12,0,0,1,80x12,0,12,2]").unwrap();
assert_eq!(dims(&l), (80, 24, 0, 0));
let c = vert(&l);
assert_eq!(c.len(), 2);
assert_eq!(dims(&c[0]), (80, 12, 0, 0));
assert_eq!(pane(&c[0]), 1);
assert_eq!(dims(&c[1]), (80, 12, 0, 12));
assert_eq!(pane(&c[1]), 2);
}
#[test]
fn horizontal_split_three_panes() {
let l = p("120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}").unwrap();
assert_eq!((l.width, l.height), (120, 24));
let c = horiz(&l);
assert_eq!(c.len(), 3);
assert_eq!(pane(&c[0]), 1);
assert_eq!(pane(&c[1]), 2);
assert_eq!(pane(&c[2]), 3);
}
#[test]
fn nested_horizontal_in_vertical() {
let l = p("80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]").unwrap();
let v = vert(&l);
assert_eq!(v.len(), 2);
assert_eq!(pane(&v[0]), 1);
let h = horiz(&v[1]);
assert_eq!(h.len(), 2);
assert_eq!(pane(&h[0]), 2);
assert_eq!(pane(&h[1]), 3);
}
#[test]
fn nested_vertical_in_horizontal() {
let l = p("80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}").unwrap();
let h = horiz(&l);
assert_eq!(h.len(), 2);
assert_eq!(pane(&h[0]), 1);
let v = vert(&h[1]);
assert_eq!(v.len(), 2);
assert_eq!(pane(&v[0]), 2);
assert_eq!(pane(&v[1]), 3);
}
#[test]
fn deeply_nested_layout() {
let l = p("80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}").unwrap();
let h = horiz(&l);
assert_eq!(h.len(), 2);
let v = vert(&h[0]);
assert_eq!(v.len(), 2);
assert_eq!(pane(&v[0]), 1);
assert_eq!(pane(&v[1]), 2);
assert_eq!(pane(&h[1]), 3);
}
fn syntax(s: &str) {
assert_eq!(p(s), Err(ParseError::SyntaxError), "{s}");
}
#[test]
fn syntax_error_empty_string() {
syntax("");
}
#[test]
fn syntax_error_missing_width() {
syntax("x24,0,0,1");
}
#[test]
fn syntax_error_missing_height() {
syntax("80x,0,0,1");
}
#[test]
fn syntax_error_missing_x() {
syntax("80x24,,0,1");
}
#[test]
fn syntax_error_missing_y() {
syntax("80x24,0,,1");
}
#[test]
fn syntax_error_missing_pane_id() {
syntax("80x24,0,0,");
}
#[test]
fn syntax_error_non_numeric_width() {
syntax("abcx24,0,0,1");
}
#[test]
fn syntax_error_non_numeric_pane_id() {
syntax("80x24,0,0,abc");
}
#[test]
fn syntax_error_unclosed_horizontal_bracket() {
syntax("80x24,0,0{40x24,0,0,1");
}
#[test]
fn syntax_error_unclosed_vertical_bracket() {
syntax("80x24,0,0[40x24,0,0,1");
}
#[test]
fn syntax_error_mismatched_brackets() {
syntax("80x24,0,0{40x24,0,0,1]");
syntax("80x24,0,0[40x24,0,0,1}");
}
#[test]
fn syntax_error_trailing_data() {
syntax("80x24,0,0,1extra");
}
#[test]
fn syntax_error_no_x_separator() {
syntax("8024,0,0,1");
}
#[test]
fn syntax_error_no_content_delimiter() {
syntax("80x24,0,0");
}
#[test]
fn parse_with_checksum_valid() {
let l = Layout::parse_with_checksum(b"f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}").unwrap();
assert_eq!((l.width, l.height), (80, 24));
}
#[test]
fn parse_with_checksum_mismatch() {
assert_eq!(
Layout::parse_with_checksum(b"0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"),
Err(ParseError::ChecksumMismatch)
);
}
#[test]
fn parse_with_checksum_too_short() {
assert_eq!(
Layout::parse_with_checksum(b"bb62"),
Err(ParseError::SyntaxError)
);
assert_eq!(
Layout::parse_with_checksum(b""),
Err(ParseError::SyntaxError)
);
}
#[test]
fn parse_with_checksum_missing_comma() {
assert_eq!(
Layout::parse_with_checksum(b"bb62x159x48,0,0"),
Err(ParseError::SyntaxError)
);
}
#[test]
fn checksum_empty_string() {
assert_eq!(Checksum::calculate(b"").0, 0);
assert_eq!(cs(""), "0000");
}
#[test]
fn checksum_single_character() {
assert_eq!(Checksum::calculate(b"A").0, 65);
assert_eq!(cs("A"), "0041");
}
#[test]
fn checksum_two_characters() {
assert_eq!(Checksum::calculate(b"AB").0, 32866);
assert_eq!(cs("AB"), "8062");
}
#[test]
fn checksum_simple_layout() {
assert_eq!(cs("80x24,0,0,42"), "d962");
}
#[test]
fn checksum_horizontal_split_layout() {
assert_eq!(cs("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"), "f8f9");
}
#[test]
fn checksum_as_string_zero_padding() {
assert_eq!(&Checksum(0x000f).as_string(), b"000f");
}
#[test]
fn checksum_as_string_all_digits() {
assert_eq!(&Checksum(0x1234).as_string(), b"1234");
}
#[test]
fn checksum_as_string_with_letters() {
assert_eq!(&Checksum(0xabcd).as_string(), b"abcd");
}
#[test]
fn checksum_as_string_max_value() {
assert_eq!(&Checksum(0xffff).as_string(), b"ffff");
}
#[test]
fn checksum_wraparound() {
let c = Checksum::calculate(&[0xff; 8]);
assert_eq!(&c.as_string(), b"03fc");
}
#[test]
fn checksum_deterministic() {
let s = b"159x48,0,0{79x48,0,0,79x48,80,0}";
assert_eq!(Checksum::calculate(s), Checksum::calculate(s));
}
#[test]
fn checksum_different_inputs_different_outputs() {
assert_ne!(
Checksum::calculate(b"80x24,0,0,1"),
Checksum::calculate(b"80x24,0,0,2")
);
}
#[test]
fn checksum_known_tmux_layout_bb62() {
assert_eq!(cs("159x48,0,0{79x48,0,0,79x48,80,0}"), "bb62");
}
}