use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use anyhow::{Context, Result, bail};
use bmux_attach_pipeline::PaneRenderBuffer;
use bmux_client::{AttachLayoutState, AttachSnapshotState, BmuxClient};
use regex::Regex;
use serde::Serialize;
use uuid::Uuid;
use super::types::PaneCapture;
use crate::pane_runtime_client::{BmuxPaneRuntimeClientExt, attach_pane_grid_snapshot_state};
const SNAPSHOT_MAX_BYTES_PER_PANE: usize = 256 * 1024;
const OUTPUT_BATCH_MAX_BYTES: usize = 256 * 1024;
const GRID_SNAPSHOT_MAX_ROWS_PER_PANE: usize = 100_000;
struct ParsedPane {
_pane_id: Uuid,
pane_index: u32,
focused: bool,
screen_text: String,
cursor_row: u16,
cursor_col: u16,
}
struct PaneStreamState {
pane_id: Uuid,
pane_index: u32,
focused: bool,
rows: u16,
cols: u16,
terminal_grid: bmux_terminal_grid::TerminalGridStream,
expected_stream_start: Option<u64>,
sync_update_active: bool,
}
#[derive(Debug, Clone)]
pub struct PaneOutputChunk {
pub pane_index: u32,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct OutputDrainResult {
pub had_activity: bool,
pub focused_output: Vec<u8>,
pub pane_outputs: Vec<PaneOutputChunk>,
pub output_still_pending: bool,
pub any_sync_update_active: bool,
}
pub struct ScreenInspector {
panes: Vec<ParsedPane>,
pane_states: BTreeMap<Uuid, PaneStreamState>,
viewport_rows: u16,
viewport_cols: u16,
session_id: Option<Uuid>,
needs_bootstrap: bool,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScreenDeltaFormat {
LineOps,
UnifiedDiff,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CursorPosition {
pub row: u16,
pub col: u16,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CursorDeltaEvent {
pub pane_index: u32,
pub from: CursorPosition,
pub to: CursorPosition,
pub distance: u16,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ScreenLineOp {
SetLine { row: u16, text: String },
ClearLine { row: u16 },
Cursor { row: u16, col: u16 },
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ScreenDeltaEvent {
pub pane_index: u32,
pub format: ScreenDeltaFormat,
pub base_hash: String,
pub new_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ops: Option<Vec<ScreenLineOp>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PaneDeltaResult {
pub pane: PaneCapture,
pub cursor_delta: Option<CursorDeltaEvent>,
pub screen_delta: Option<ScreenDeltaEvent>,
}
impl ScreenInspector {
#[must_use]
pub const fn new(viewport_cols: u16, viewport_rows: u16) -> Self {
Self {
panes: Vec::new(),
pane_states: BTreeMap::new(),
viewport_rows,
viewport_cols,
session_id: None,
needs_bootstrap: true,
}
}
pub fn update_viewport(&mut self, cols: u16, rows: u16) {
self.viewport_cols = cols;
self.viewport_rows = rows;
self.panes.clear();
self.pane_states.clear();
self.session_id = None;
self.needs_bootstrap = true;
}
#[must_use]
pub const fn viewport_size(&self) -> (u16, u16) {
(self.viewport_cols, self.viewport_rows)
}
pub async fn refresh(
&mut self,
client: &mut BmuxClient,
session_id: Uuid,
) -> Result<AttachSnapshotState> {
let (layout, _) = self
.sync_and_drain(client, session_id, OUTPUT_BATCH_MAX_BYTES)
.await?;
Ok(snapshot_from_layout(layout))
}
pub async fn drain_incremental_output(
&mut self,
client: &mut BmuxClient,
session_id: Uuid,
max_bytes_per_pane: usize,
) -> Result<OutputDrainResult> {
let (_, drain) = self
.sync_and_drain(client, session_id, max_bytes_per_pane.max(1))
.await?;
Ok(drain)
}
async fn sync_and_drain(
&mut self,
client: &mut BmuxClient,
session_id: Uuid,
max_bytes_per_pane: usize,
) -> Result<(AttachLayoutState, OutputDrainResult)> {
self.reset_for_session(session_id);
let mut layout = client
.attach_layout(session_id)
.await
.map_err(|e| anyhow::anyhow!("layout failed: {e}"))?;
let pane_set_changed = self.apply_layout_state(&layout);
if pane_set_changed {
self.needs_bootstrap = true;
}
if self.needs_bootstrap {
let snapshot = self.bootstrap_from_snapshot(client, session_id).await?;
layout = layout_from_snapshot(&snapshot);
let _ = self.apply_layout_state(&layout);
}
let drain = self
.drain_output_batch(client, session_id, &layout, max_bytes_per_pane)
.await?;
self.hydrate_structured_grid_snapshots(client, session_id, &layout)
.await;
self.rebuild_parsed_panes();
Ok((layout, drain))
}
fn reset_for_session(&mut self, session_id: Uuid) {
if self.session_id == Some(session_id) {
return;
}
self.session_id = Some(session_id);
self.panes.clear();
self.pane_states.clear();
self.needs_bootstrap = true;
}
fn apply_layout_state(&mut self, layout: &AttachLayoutState) -> bool {
let pane_ids = layout
.panes
.iter()
.map(|pane| pane.id)
.collect::<BTreeSet<_>>();
let existing_ids = self.pane_states.keys().copied().collect::<BTreeSet<_>>();
let pane_set_changed = pane_ids != existing_ids;
self.pane_states
.retain(|pane_id, _| pane_ids.contains(pane_id));
let pane_dims = build_pane_dimensions_from_scene(&layout.scene);
for pane in &layout.panes {
let (cols, rows) = pane_dims
.get(&pane.id)
.copied()
.unwrap_or_else(|| self.default_pane_dimensions());
let state = self
.pane_states
.entry(pane.id)
.or_insert_with(|| PaneStreamState {
pane_id: pane.id,
pane_index: pane.index,
focused: pane.focused,
rows,
cols,
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
cols.max(1),
rows.max(1),
bmux_terminal_grid::GridLimits::default(),
)
.expect("playbook pane dimensions should be valid"),
expected_stream_start: None,
sync_update_active: false,
});
state.pane_index = pane.index;
state.focused = pane.focused;
if state.rows != rows || state.cols != cols {
state.rows = rows;
state.cols = cols;
let _ = state.terminal_grid.resize(cols.max(1), rows.max(1));
}
}
pane_set_changed
}
async fn bootstrap_from_snapshot(
&mut self,
client: &mut BmuxClient,
session_id: Uuid,
) -> Result<AttachSnapshotState> {
let snapshot = client
.attach_snapshot(session_id, SNAPSHOT_MAX_BYTES_PER_PANE)
.await
.map_err(|e| anyhow::anyhow!("snapshot failed: {e}"))?;
let pane_dims = build_pane_dimensions_from_scene(&snapshot.scene);
let mut next_states = BTreeMap::new();
for pane in &snapshot.panes {
let (cols, rows) = pane_dims
.get(&pane.id)
.copied()
.unwrap_or_else(|| self.default_pane_dimensions());
let terminal_grid = bmux_terminal_grid::TerminalGridStream::new(
cols.max(1),
rows.max(1),
bmux_terminal_grid::GridLimits::default(),
)
.expect("playbook pane dimensions should be valid");
next_states.insert(
pane.id,
PaneStreamState {
pane_id: pane.id,
pane_index: pane.index,
focused: pane.focused,
rows,
cols,
terminal_grid,
expected_stream_start: None,
sync_update_active: snapshot
.chunks
.iter()
.find(|chunk| chunk.pane_id == pane.id)
.is_some_and(|chunk| chunk.sync_update_active),
},
);
}
self.pane_states = next_states;
self.needs_bootstrap = false;
self.rebuild_parsed_panes();
Ok(snapshot)
}
async fn drain_output_batch(
&mut self,
client: &mut BmuxClient,
session_id: Uuid,
layout: &AttachLayoutState,
max_bytes_per_pane: usize,
) -> Result<OutputDrainResult> {
let pane_ids = layout.panes.iter().map(|pane| pane.id).collect::<Vec<_>>();
if pane_ids.is_empty() {
return Ok(OutputDrainResult::default());
}
let batch = client
.attach_pane_output_batch(session_id, pane_ids, max_bytes_per_pane.max(1))
.await
.map_err(|e| anyhow::anyhow!("pane output batch failed: {e}"))?;
self.apply_batch(layout, batch, client, session_id).await
}
async fn apply_batch(
&mut self,
layout: &AttachLayoutState,
batch: bmux_client::PaneOutputBatchResult,
client: &mut BmuxClient,
session_id: Uuid,
) -> Result<OutputDrainResult> {
let mut result = OutputDrainResult {
had_activity: false,
focused_output: Vec::new(),
pane_outputs: Vec::new(),
output_still_pending: batch.output_still_pending,
any_sync_update_active: false,
};
let mut needs_resync = false;
for chunk in batch.chunks {
let bmux_attach_layout_protocol::AttachPaneChunk {
pane_id,
data,
stream_start,
stream_end,
stream_gap,
sync_update_active,
} = chunk;
result.any_sync_update_active |= sync_update_active;
let Some(state) = self.pane_states.get_mut(&pane_id) else {
needs_resync = true;
continue;
};
if stream_end < stream_start {
needs_resync = true;
continue;
}
if stream_gap {
needs_resync = true;
continue;
}
if let Some(expected) = state.expected_stream_start
&& stream_start != expected
{
needs_resync = true;
continue;
}
if pane_id == layout.focused_pane_id && !data.is_empty() {
result.focused_output.extend_from_slice(&data);
}
if !data.is_empty() {
state.terminal_grid.process(&data);
result.pane_outputs.push(PaneOutputChunk {
pane_index: state.pane_index,
data,
});
result.had_activity = true;
}
state.sync_update_active = sync_update_active;
state.expected_stream_start = Some(stream_end);
}
if needs_resync {
let _ = self.bootstrap_from_snapshot(client, session_id).await?;
result.had_activity = true;
}
Ok(result)
}
async fn hydrate_structured_grid_snapshots(
&mut self,
client: &mut BmuxClient,
session_id: Uuid,
layout: &AttachLayoutState,
) {
let pane_ids = layout.panes.iter().map(|pane| pane.id).collect::<Vec<_>>();
if pane_ids.is_empty() {
return;
}
let snapshots = match attach_pane_grid_snapshot_state(
client,
session_id,
pane_ids,
GRID_SNAPSHOT_MAX_ROWS_PER_PANE,
)
.await
{
Ok(snapshots) => snapshots,
Err(error) => {
tracing::debug!(%error, "structured pane grid snapshot unavailable for playbook inspector");
return;
}
};
for snapshot in snapshots {
let decoded = match serde_json::from_slice::<bmux_terminal_grid::GridSnapshot>(
&snapshot.encoded,
) {
Ok(decoded) => decoded,
Err(error) => {
tracing::warn!(pane_id = %snapshot.pane_id, %error, "failed decoding playbook structured pane grid snapshot");
continue;
}
};
let stream = match bmux_terminal_grid::TerminalGridStream::from_snapshot(
&decoded,
bmux_terminal_grid::GridLimits::default(),
) {
Ok(stream) => stream,
Err(error) => {
tracing::warn!(pane_id = %snapshot.pane_id, %error, "failed hydrating playbook structured pane grid snapshot");
continue;
}
};
if let Some(state) = self.pane_states.get_mut(&snapshot.pane_id) {
state.terminal_grid = stream;
state.expected_stream_start = Some(snapshot.stream_end);
}
}
}
fn rebuild_parsed_panes(&mut self) {
let mut panes = self
.pane_states
.values()
.map(|state| {
let cursor = state.terminal_grid.grid().cursor();
ParsedPane {
_pane_id: state.pane_id,
pane_index: state.pane_index,
focused: state.focused,
screen_text: terminal_grid_to_text(
state.terminal_grid.grid(),
usize::from(state.rows),
),
cursor_row: u16::try_from(cursor.row).unwrap_or(u16::MAX),
cursor_col: u16::try_from(cursor.col).unwrap_or(u16::MAX),
}
})
.collect::<Vec<_>>();
panes.sort_by_key(|pane| pane.pane_index);
self.panes = panes;
}
fn default_pane_dimensions(&self) -> (u16, u16) {
(
self.viewport_cols.saturating_sub(2).max(1),
self.viewport_rows.saturating_sub(2).max(1),
)
}
pub fn sync_attach_render_buffers(
&self,
layout: &AttachLayoutState,
buffers: &mut BTreeMap<Uuid, PaneRenderBuffer>,
) {
buffers.retain(|pane_id, _| self.pane_states.contains_key(pane_id));
let content_dims = build_pane_content_dimensions_from_scene(&layout.scene);
for (pane_id, state) in &self.pane_states {
let (cols, rows) = content_dims
.get(pane_id)
.copied()
.unwrap_or((state.cols, state.rows));
let buffer = buffers.entry(*pane_id).or_default();
let snapshot = state.terminal_grid.snapshot(0, usize::from(rows));
if let Ok(mut stream) = bmux_terminal_grid::TerminalGridStream::from_snapshot(
&snapshot,
bmux_terminal_grid::GridLimits::default(),
) {
let _ = stream.resize(cols.max(1), rows.max(1));
buffer.terminal_grid = stream;
buffer.visual_row_fingerprints.clear();
}
buffer.sync_update_in_progress = state.sync_update_active;
buffer.expected_stream_start = state.expected_stream_start;
}
}
#[must_use]
pub fn pane_text(&self, pane_index: u32) -> Option<String> {
self.panes
.iter()
.find(|p| p.pane_index == pane_index)
.map(|p| p.screen_text.clone())
}
#[must_use]
pub fn pane_scrollback_text(&self, pane_index: u32) -> Option<String> {
let pane_id = self
.pane_states
.values()
.find(|state| state.pane_index == pane_index)
.map(|state| state.pane_id)?;
let grid = &self.pane_states.get(&pane_id)?.terminal_grid;
Some(terminal_grid_rows_to_text(grid.grid().all_main_rows_slow()))
}
#[must_use]
pub fn pane_cursor(&self, pane_index: u32) -> Option<(u16, u16)> {
self.panes
.iter()
.find(|p| p.pane_index == pane_index)
.map(|p| (p.cursor_row, p.cursor_col))
}
#[must_use]
pub fn capture_all(&self) -> Vec<PaneCapture> {
self.panes
.iter()
.map(|p| PaneCapture {
index: p.pane_index,
focused: p.focused,
screen_text: p.screen_text.clone(),
cursor_row: p.cursor_row,
cursor_col: p.cursor_col,
})
.collect()
}
#[must_use]
pub fn capture_all_safe(&self) -> Option<Vec<PaneCapture>> {
if self.panes.is_empty() {
None
} else {
Some(self.capture_all())
}
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn build_deltas(
&self,
previous: &std::collections::HashMap<u32, PaneCapture>,
format: ScreenDeltaFormat,
) -> Vec<PaneDeltaResult> {
self.capture_all()
.into_iter()
.map(|pane| {
let prior = previous.get(&pane.index);
let cursor_delta = build_cursor_delta(prior, &pane);
let screen_delta = build_screen_delta(prior, &pane, format);
PaneDeltaResult {
pane,
cursor_delta,
screen_delta,
}
})
.collect()
}
#[must_use]
pub fn pane_contains(&self, pane_index: u32, needle: &str) -> bool {
self.pane_text(pane_index)
.is_some_and(|text| text.contains(needle))
}
pub fn pane_matches(&self, pane_index: u32, pattern: &str) -> Result<bool> {
let re = Regex::new(pattern).with_context(|| format!("invalid regex: {pattern}"))?;
Ok(self.pane_matches_compiled(pane_index, &re))
}
#[must_use]
pub fn pane_matches_compiled(&self, pane_index: u32, re: &Regex) -> bool {
self.pane_text(pane_index)
.is_some_and(|text| re.is_match(&text))
}
#[allow(clippy::unused_self)] pub fn resolve_pane_index(
&self,
pane: Option<u32>,
snapshot: &AttachSnapshotState,
) -> Result<u32> {
match pane {
Some(idx) => {
if snapshot.panes.iter().any(|p| p.index == idx) {
Ok(idx)
} else {
bail!("pane index {idx} not found")
}
}
None => snapshot
.panes
.iter()
.find(|p| p.focused)
.map(|p| p.index)
.context("no focused pane"),
}
}
}
fn snapshot_from_layout(layout: AttachLayoutState) -> AttachSnapshotState {
AttachSnapshotState {
context_id: layout.context_id,
session_id: layout.session_id,
focused_pane_id: layout.focused_pane_id,
panes: layout.panes,
layout_root: layout.layout_root,
scene: layout.scene,
chunks: Vec::new(),
pane_mouse_protocols: Vec::new(),
pane_input_modes: Vec::new(),
zoomed: layout.zoomed,
}
}
fn layout_from_snapshot(snapshot: &AttachSnapshotState) -> AttachLayoutState {
AttachLayoutState {
context_id: snapshot.context_id,
session_id: snapshot.session_id,
focused_pane_id: snapshot.focused_pane_id,
panes: snapshot.panes.clone(),
layout_root: snapshot.layout_root.clone(),
scene: snapshot.scene.clone(),
zoomed: snapshot.zoomed,
}
}
fn build_cursor_delta(
previous: Option<&PaneCapture>,
current: &PaneCapture,
) -> Option<CursorDeltaEvent> {
let prior = previous?;
if prior.cursor_row == current.cursor_row && prior.cursor_col == current.cursor_col {
return None;
}
let row_delta = current.cursor_row.abs_diff(prior.cursor_row);
let col_delta = current.cursor_col.abs_diff(prior.cursor_col);
Some(CursorDeltaEvent {
pane_index: current.index,
from: CursorPosition {
row: prior.cursor_row,
col: prior.cursor_col,
},
to: CursorPosition {
row: current.cursor_row,
col: current.cursor_col,
},
distance: row_delta.saturating_add(col_delta),
})
}
fn build_screen_delta(
previous: Option<&PaneCapture>,
current: &PaneCapture,
format: ScreenDeltaFormat,
) -> Option<ScreenDeltaEvent> {
let previous_text = previous.map_or("", |p| p.screen_text.as_str());
if previous.is_some_and(|p| p.screen_text == current.screen_text) {
return None;
}
let base_hash = text_hash(previous_text);
let new_hash = text_hash(¤t.screen_text);
match format {
ScreenDeltaFormat::LineOps => {
let mut ops = line_ops_delta(previous_text, ¤t.screen_text);
ops.push(ScreenLineOp::Cursor {
row: current.cursor_row,
col: current.cursor_col,
});
Some(ScreenDeltaEvent {
pane_index: current.index,
format,
base_hash,
new_hash,
ops: Some(ops),
diff: None,
})
}
ScreenDeltaFormat::UnifiedDiff => {
let diff = unified_diff(previous_text, ¤t.screen_text)?;
Some(ScreenDeltaEvent {
pane_index: current.index,
format,
base_hash,
new_hash,
ops: None,
diff: Some(diff),
})
}
}
}
#[allow(clippy::cast_possible_truncation)]
fn line_ops_delta(previous_text: &str, current_text: &str) -> Vec<ScreenLineOp> {
let previous_lines = previous_text.lines().collect::<Vec<_>>();
let current_lines = current_text.lines().collect::<Vec<_>>();
let max_len = previous_lines.len().max(current_lines.len());
let mut ops = Vec::new();
for row in 0..max_len {
match (previous_lines.get(row), current_lines.get(row)) {
(Some(prev), Some(curr)) if prev != curr => ops.push(ScreenLineOp::SetLine {
row: row as u16,
text: (*curr).to_string(),
}),
(None, Some(curr)) => ops.push(ScreenLineOp::SetLine {
row: row as u16,
text: (*curr).to_string(),
}),
(Some(_), None) => ops.push(ScreenLineOp::ClearLine { row: row as u16 }),
_ => {}
}
}
ops
}
fn unified_diff(previous_text: &str, current_text: &str) -> Option<String> {
let previous_lines = previous_text.lines().collect::<Vec<_>>();
let current_lines = current_text.lines().collect::<Vec<_>>();
let max_len = previous_lines.len().max(current_lines.len());
let mut output = String::new();
for row in 0..max_len {
let prev = previous_lines.get(row).copied().unwrap_or("");
let curr = current_lines.get(row).copied().unwrap_or("");
if prev == curr {
continue;
}
let _ = writeln!(output, "@@ -{},1 +{},1 @@", row + 1, row + 1);
let _ = writeln!(output, "-{prev}");
let _ = writeln!(output, "+{curr}");
}
if output.is_empty() {
None
} else {
Some(output)
}
}
fn text_hash(text: &str) -> String {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
format!("{hash:016x}")
}
fn build_pane_dimensions_from_scene(
scene: &bmux_attach_layout_protocol::AttachScene,
) -> BTreeMap<Uuid, (u16, u16)> {
scene
.surfaces
.iter()
.filter_map(|surface| {
let pane_id = surface.pane_id?;
if !surface.visible {
return None;
}
let cols = surface.rect.w.max(1);
let rows = surface.rect.h.max(1);
Some((pane_id, (cols, rows)))
})
.collect()
}
fn build_pane_content_dimensions_from_scene(
scene: &bmux_attach_layout_protocol::AttachScene,
) -> BTreeMap<Uuid, (u16, u16)> {
scene
.surfaces
.iter()
.filter_map(|surface| {
let pane_id = surface.pane_id?;
if !surface.visible {
return None;
}
let cols = surface.content_rect.w.max(1);
let rows = surface.content_rect.h.max(1);
Some((pane_id, (cols, rows)))
})
.collect()
}
fn terminal_grid_to_text(grid: &bmux_terminal_grid::TerminalGrid, rows: usize) -> String {
terminal_grid_rows_to_text(grid.display_rows(0, rows))
}
fn terminal_grid_rows_to_text(rows: Vec<bmux_terminal_grid::PhysicalRow>) -> String {
let mut lines = rows
.into_iter()
.map(|row| {
let mut line = String::new();
for cell in row.cells() {
if !cell.is_wide_continuation() {
line.push_str(cell.text());
}
}
line.trim_end().to_string()
})
.collect::<Vec<_>>();
while lines.last().is_some_and(String::is_empty) {
lines.pop();
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_grid_to_text_basic() {
let mut grid = bmux_terminal_grid::TerminalGridStream::new(
80,
24,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid");
grid.process(b"hello world\r\nsecond line");
let text = terminal_grid_to_text(grid.grid(), 24);
assert!(text.contains("hello world"));
assert!(text.contains("second line"));
}
#[test]
fn terminal_grid_to_text_trims_trailing_empty_lines() {
let mut grid = bmux_terminal_grid::TerminalGridStream::new(
80,
24,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid");
grid.process(b"line one\r\nline two");
let text = terminal_grid_to_text(grid.grid(), 24);
assert!(!text.ends_with('\n'));
let line_count = text.lines().count();
assert_eq!(line_count, 2);
}
#[test]
#[allow(clippy::too_many_lines)] fn sync_attach_render_buffers_preserves_render_caches_and_uses_scene_dimensions() {
use bmux_attach_layout_protocol::{
AttachFocusTarget, AttachLayer, AttachRect, AttachScene, AttachSurface,
AttachSurfaceKind, PaneLayoutNode, PaneState, PaneSummary,
};
use bmux_attach_pipeline::types::ExtensionRenderCacheEntry;
use bmux_plugin::{ExtensionRect, RenderDamage};
let session_id = Uuid::from_u128(91);
let pane_id = Uuid::from_u128(92);
let surface_id = Uuid::from_u128(93);
let mut pane_grid = bmux_terminal_grid::TerminalGridStream::new(
12,
5,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid");
pane_grid.process(b"first\r\nsecond");
let mut inspector = ScreenInspector {
panes: Vec::new(),
pane_states: BTreeMap::new(),
viewport_rows: 8,
viewport_cols: 20,
session_id: Some(session_id),
needs_bootstrap: false,
};
inspector.pane_states.insert(
pane_id,
PaneStreamState {
pane_id,
pane_index: 1,
focused: true,
rows: 5,
cols: 12,
terminal_grid: pane_grid,
expected_stream_start: Some(42),
sync_update_active: true,
},
);
let mut buffers = BTreeMap::new();
let mut buffer = PaneRenderBuffer {
prev_rows: vec!["cached row".to_string()],
..PaneRenderBuffer::default()
};
buffer.extension_render_cache.insert(
("test.extension".to_string(), surface_id),
ExtensionRenderCacheEntry {
surface_id,
surface_rect: ExtensionRect {
x: 0,
y: 0,
w: 6,
h: 3,
},
damage: RenderDamage::FullSurface,
revision: 7,
bytes: b"cached".to_vec(),
},
);
buffers.insert(pane_id, buffer);
let layout = AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: surface_id,
kind: AttachSurfaceKind::Pane,
layer: AttachLayer::Pane,
z: 0,
rect: AttachRect {
x: 0,
y: 0,
w: 8,
h: 5,
},
content_rect: AttachRect {
x: 1,
y: 1,
w: 6,
h: 3,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
pane_id: Some(pane_id),
}],
},
zoomed: false,
};
inspector.sync_attach_render_buffers(&layout, &mut buffers);
let synced = buffers.get(&pane_id).expect("buffer should remain");
assert_eq!(
(
synced.terminal_grid.grid().height(),
synced.terminal_grid.grid().width()
),
(3, 6)
);
assert_eq!(
terminal_grid_to_text(synced.terminal_grid.grid(), 3),
"first\nsecond"
);
assert_eq!(synced.prev_rows, vec!["cached row".to_string()]);
assert_eq!(synced.extension_render_cache.len(), 1);
assert_eq!(synced.expected_stream_start, Some(42));
assert!(synced.sync_update_in_progress);
}
#[test]
fn structured_grid_handles_split_alt_exit_sequence() {
let mut grid = bmux_terminal_grid::TerminalGridStream::new(
120,
30,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid");
grid.process(b"\x1b[12;34H");
grid.process(b"\x1b[?1049h\x1b[2J\x1b[HSEQ_TUI");
grid.process(b"\x1b[?10");
grid.process(b"49l");
let cursor = grid.grid().cursor();
assert_eq!((cursor.row, cursor.col), (11, 33));
}
#[test]
fn line_ops_delta_reports_changed_rows() {
let ops = line_ops_delta("a\nb", "a\nc");
assert!(matches!(
ops.as_slice(),
[ScreenLineOp::SetLine { row: 1, text }] if text == "c"
));
}
#[test]
fn unified_diff_reports_changed_rows() {
let diff = unified_diff("hello", "hullo").expect("diff should exist");
assert!(diff.contains("@@ -1,1 +1,1 @@"));
assert!(diff.contains("-hello"));
assert!(diff.contains("+hullo"));
}
}