#![forbid(unsafe_code)]
use std::io::{self, BufWriter, Write};
use crate::ansi::{self, EraseLineMode};
use crate::buffer::Buffer;
use crate::cell::{Cell, CellAttrs, GraphemeId, PackedRgba, StyleFlags};
use crate::char_width;
use crate::counting_writer::{CountingWriter, PresentStats, StatsCollector};
use crate::diff::{BufferDiff, ChangeRun};
use crate::display_width;
use crate::grapheme_pool::GraphemePool;
use crate::link_registry::LinkRegistry;
use crate::sanitize::sanitize;
pub use ftui_core::terminal_capabilities::TerminalCapabilities;
const BUFFER_CAPACITY: usize = 64 * 1024;
const MAX_SAFE_HYPERLINK_URL_BYTES: usize = 4096;
#[inline]
fn is_safe_hyperlink_url(url: &str) -> bool {
url.len() <= MAX_SAFE_HYPERLINK_URL_BYTES && !url.chars().any(char::is_control)
}
mod cost_model {
use smallvec::SmallVec;
use super::ChangeRun;
#[inline]
fn digit_count(n: u16) -> usize {
if n < 10 {
1
} else if n < 100 {
2
} else if n < 1000 {
3
} else if n < 10000 {
4
} else {
5
}
}
#[inline]
pub fn cup_cost(row: u16, col: u16) -> usize {
4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
}
#[inline]
pub fn cha_cost(col: u16) -> usize {
3 + digit_count(col.saturating_add(1))
}
#[inline]
pub fn cuf_cost(n: u16) -> usize {
match n {
0 => 0,
1 => 3, _ => 3 + digit_count(n),
}
}
#[inline]
pub fn cub_cost(n: u16) -> usize {
match n {
0 => 0,
1 => 3, _ => 3 + digit_count(n),
}
}
pub fn cheapest_move_cost(
from_x: Option<u16>,
from_y: Option<u16>,
to_x: u16,
to_y: u16,
) -> usize {
if from_x == Some(to_x) && from_y == Some(to_y) {
return 0;
}
match (from_x, from_y) {
(Some(fx), Some(fy)) if fy == to_y => {
let cha = cha_cost(to_x);
if to_x > fx {
let cuf = cuf_cost(to_x - fx);
cha.min(cuf)
} else if to_x < fx {
let cub = cub_cost(fx - to_x);
cha.min(cub)
} else {
0
}
}
_ => cup_cost(to_y, to_x),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RowSpan {
pub y: u16,
pub x0: u16,
pub x1: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowPlan {
spans: SmallVec<[RowSpan; 8]>,
total_cost: usize,
}
impl RowPlan {
#[inline]
#[must_use]
pub fn spans(&self) -> &[RowSpan] {
&self.spans
}
#[inline]
#[allow(dead_code)] pub fn total_cost(&self) -> usize {
self.total_cost
}
}
#[derive(Debug, Default)]
pub struct RowPlanScratch {
prefix_cells: Vec<usize>,
dp: Vec<usize>,
prev: Vec<usize>,
}
#[allow(dead_code)]
pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
let mut scratch = RowPlanScratch::default();
plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
}
pub fn plan_row_reuse(
row_runs: &[ChangeRun],
prev_x: Option<u16>,
prev_y: Option<u16>,
scratch: &mut RowPlanScratch,
) -> RowPlan {
if row_runs.is_empty() {
return RowPlan {
spans: SmallVec::new(),
total_cost: 0,
};
}
let row_y = row_runs[0].y;
let run_count = row_runs.len();
if run_count == 1 {
let run = row_runs[0];
let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
spans.push(RowSpan {
y: row_y,
x0: run.x0,
x1: run.x1,
});
return RowPlan {
spans,
total_cost: cheapest_move_cost(prev_x, prev_y, run.x0, row_y)
.saturating_add(run.len()),
};
}
scratch.prefix_cells.clear();
scratch.prefix_cells.resize(run_count + 1, 0);
scratch.dp.clear();
scratch.dp.resize(run_count, usize::MAX);
scratch.prev.clear();
scratch.prev.resize(run_count, 0);
for (i, run) in row_runs.iter().enumerate() {
scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len();
}
for j in 0..run_count {
let mut best_cost = usize::MAX;
let mut best_i = j;
for i in (0..=j).rev() {
let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
let total_cells =
(row_runs[j].x1 as usize).saturating_sub(row_runs[i].x0 as usize) + 1;
let gap_cells = total_cells.saturating_sub(changed_cells);
if gap_cells > 32 {
break;
}
let from_x = if i == 0 {
prev_x
} else {
Some(row_runs[i - 1].x1.saturating_add(1))
};
let from_y = if i == 0 { prev_y } else { Some(row_y) };
let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
let gap_overhead = gap_cells * 2; let emit_cost = changed_cells + gap_overhead;
let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
let cost = prev_cost
.saturating_add(move_cost)
.saturating_add(emit_cost);
if cost < best_cost {
best_cost = cost;
best_i = i;
}
}
scratch.dp[j] = best_cost;
scratch.prev[j] = best_i;
}
let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
let mut j = run_count - 1;
loop {
let i = scratch.prev[j];
spans.push(RowSpan {
y: row_y,
x0: row_runs[i].x0,
x1: row_runs[j].x1,
});
if i == 0 {
break;
}
j = i - 1;
}
spans.reverse();
RowPlan {
spans,
total_cost: scratch.dp[run_count - 1],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CellStyle {
fg: PackedRgba,
bg: PackedRgba,
attrs: StyleFlags,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreparedContent {
Empty,
Char(char),
Grapheme(GraphemeId),
}
impl PreparedContent {
#[inline]
fn from_cell(cell: &Cell) -> (Self, usize) {
let content = cell.content;
if let Some(grapheme_id) = content.grapheme_id() {
(Self::Grapheme(grapheme_id), content.width())
} else if let Some(ch) = content.as_char() {
let width = if ch.is_ascii() {
match ch {
'\t' | '\n' | '\r' => 1,
' '..='~' => 1,
_ => 0,
}
} else {
char_width(ch)
};
(Self::Char(ch), width)
} else {
(Self::Empty, 0)
}
}
}
impl Default for CellStyle {
fn default() -> Self {
Self {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
}
}
}
impl CellStyle {
fn from_cell(cell: &Cell) -> Self {
Self {
fg: cell.fg,
bg: cell.bg,
attrs: cell.attrs.flags(),
}
}
}
pub struct Presenter<W: Write> {
writer: CountingWriter<BufWriter<W>>,
current_style: Option<CellStyle>,
current_link: Option<u32>,
cursor_x: Option<u16>,
cursor_y: Option<u16>,
viewport_offset_y: u16,
capabilities: TerminalCapabilities,
hyperlinks_enabled: bool,
plan_scratch: cost_model::RowPlanScratch,
runs_buf: Vec<ChangeRun>,
}
impl<W: Write> Presenter<W> {
pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
Self {
writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
current_style: None,
current_link: None,
cursor_x: None,
cursor_y: None,
viewport_offset_y: 0,
hyperlinks_enabled: capabilities.use_hyperlinks(),
capabilities,
plan_scratch: cost_model::RowPlanScratch::default(),
runs_buf: Vec::new(),
}
}
pub fn writer_mut(&mut self) -> &mut W {
self.writer.inner_mut().get_mut()
}
pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
&mut self.writer
}
pub fn set_viewport_offset_y(&mut self, offset: u16) {
self.viewport_offset_y = offset;
}
#[inline]
pub fn capabilities(&self) -> &TerminalCapabilities {
&self.capabilities
}
pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
self.present_with_pool(buffer, diff, None, None)
}
pub fn present_with_pool(
&mut self,
buffer: &Buffer,
diff: &BufferDiff,
pool: Option<&GraphemePool>,
links: Option<&LinkRegistry>,
) -> io::Result<PresentStats> {
let bracket_supported = self.capabilities.use_sync_output();
#[cfg(feature = "tracing")]
let _span = tracing::info_span!(
"present",
width = buffer.width(),
height = buffer.height(),
changes = diff.len()
);
#[cfg(feature = "tracing")]
let _guard = _span.enter();
#[cfg(feature = "tracing")]
let fallback_used = !bracket_supported;
#[cfg(feature = "tracing")]
let _sync_span = tracing::info_span!(
"render.sync_bracket",
bracket_supported,
fallback_used,
frame_bytes = tracing::field::Empty,
);
#[cfg(feature = "tracing")]
let _sync_guard = _sync_span.enter();
diff.runs_into(&mut self.runs_buf);
let run_count = self.runs_buf.len();
let cells_changed = diff.len();
self.writer.reset_counter();
let collector = StatsCollector::start(cells_changed, run_count);
if bracket_supported {
if let Err(err) = ansi::sync_begin(&mut self.writer) {
let _ = ansi::sync_end(&mut self.writer);
let _ = self.writer.flush();
return Err(err);
}
} else {
#[cfg(feature = "tracing")]
tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
ansi::cursor_hide(&mut self.writer)?;
}
let emit_result = self.emit_diff_runs(buffer, pool, links);
let frame_end_result = self.finish_frame();
let bracket_end_result = if bracket_supported {
ansi::sync_end(&mut self.writer)
} else {
ansi::cursor_show(&mut self.writer)
};
let flush_result = self.writer.flush();
let cleanup_error = frame_end_result
.err()
.or_else(|| bracket_end_result.err())
.or_else(|| flush_result.err());
if let Some(err) = cleanup_error {
return Err(err);
}
emit_result?;
let stats = collector.finish(self.writer.bytes_written());
#[cfg(feature = "tracing")]
{
_sync_span.record("frame_bytes", stats.bytes_emitted);
stats.log();
tracing::trace!("frame presented");
}
Ok(stats)
}
pub fn emit_diff_runs(
&mut self,
buffer: &Buffer,
pool: Option<&GraphemePool>,
links: Option<&LinkRegistry>,
) -> io::Result<()> {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!("emit_diff");
#[cfg(feature = "tracing")]
let _guard = _span.enter();
#[cfg(feature = "tracing")]
tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
let mut i = 0;
while i < self.runs_buf.len() {
let row_y = self.runs_buf[i].y;
let row_start = i;
while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
i += 1;
}
let row_runs = &self.runs_buf[row_start..i];
let plan = cost_model::plan_row_reuse(
row_runs,
self.cursor_x,
self.cursor_y,
&mut self.plan_scratch,
);
#[cfg(feature = "tracing")]
tracing::trace!(
row = row_y,
spans = plan.spans().len(),
cost = plan.total_cost(),
"row plan"
);
let row = buffer.row_cells(row_y);
for span in plan.spans() {
self.move_cursor_optimal(span.x0, span.y)?;
let start = span.x0 as usize;
let end = span.x1 as usize;
debug_assert!(start <= end);
debug_assert!(end < row.len());
let mut idx = start;
while idx <= end {
let cell = &row[idx];
self.emit_cell(idx as u16, cell, pool, links)?;
let mut advance = 1usize;
let width = cell.content.width();
let should_repair_invalid_tail = cell.content.as_char().is_some()
|| (cell.content.is_grapheme() && width == 2);
if width > 1 && should_repair_invalid_tail {
for off in 1..width {
let tx = idx + off;
if tx >= row.len() {
break;
}
if row[tx].is_continuation() {
if tx <= end {
advance = advance.max(off + 1);
}
continue;
}
self.move_cursor_optimal(tx as u16, span.y)?;
self.emit_orphan_continuation_space(tx as u16, links)?;
if tx <= end {
advance = advance.max(off + 1);
}
}
}
idx = idx.saturating_add(advance);
}
}
}
Ok(())
}
pub fn prepare_runs(&mut self, diff: &BufferDiff) {
diff.runs_into(&mut self.runs_buf);
}
pub fn finish_frame(&mut self) -> io::Result<()> {
let reset_result = ansi::sgr_reset(&mut self.writer);
self.current_style = None;
let hyperlink_close_result = if self.current_link.is_some() {
let res = ansi::hyperlink_end(&mut self.writer);
if res.is_ok() {
self.current_link = None;
}
Some(res)
} else {
None
};
if let Some(err) = reset_result
.err()
.or_else(|| hyperlink_close_result.and_then(Result::err))
{
return Err(err);
}
Ok(())
}
pub fn finish_frame_best_effort(&mut self) {
let _ = ansi::sgr_reset(&mut self.writer);
self.current_style = None;
if self.current_link.is_some() {
let _ = ansi::hyperlink_end(&mut self.writer);
self.current_link = None;
}
}
fn emit_cell(
&mut self,
x: u16,
cell: &Cell,
pool: Option<&GraphemePool>,
links: Option<&LinkRegistry>,
) -> io::Result<()> {
if let Some(cx) = self.cursor_x {
if cx != x && !cell.is_continuation() {
if let Some(y) = self.cursor_y {
self.move_cursor_optimal(x, y)?;
}
}
} else {
if let Some(y) = self.cursor_y {
self.move_cursor_optimal(x, y)?;
}
}
if cell.is_continuation() {
match self.cursor_x {
Some(cx) if cx > x => return Ok(()),
Some(cx) => {
if cx < x
&& let Some(y) = self.cursor_y
{
self.move_cursor_optimal(x, y)?;
}
return self.emit_orphan_continuation_space(x, links);
}
None => {
if let Some(y) = self.cursor_y {
self.move_cursor_optimal(x, y)?;
}
return self.emit_orphan_continuation_space(x, links);
}
}
}
self.emit_style_changes(cell)?;
self.emit_link_changes(cell, links)?;
let (prepared_content, raw_width) = PreparedContent::from_cell(cell);
let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
if is_zero_width_content {
self.writer.write_all(b"\xEF\xBF\xBD")?;
} else {
self.emit_content(prepared_content, raw_width, pool)?;
}
if let Some(cx) = self.cursor_x {
let width = if cell.is_empty() || is_zero_width_content {
1
} else {
raw_width
};
self.cursor_x = Some(cx.saturating_add(width as u16));
}
Ok(())
}
fn emit_orphan_continuation_space(
&mut self,
x: u16,
links: Option<&LinkRegistry>,
) -> io::Result<()> {
let blank = Cell::default();
self.emit_style_changes(&blank)?;
self.emit_link_changes(&blank, links)?;
self.writer.write_all(b" ")?;
self.cursor_x = Some(x.saturating_add(1));
Ok(())
}
fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
let new_style = CellStyle::from_cell(cell);
if self.current_style == Some(new_style) {
return Ok(());
}
match self.current_style {
None => {
self.emit_style_full(new_style)?;
}
Some(old_style) => {
self.emit_style_delta(old_style, new_style)?;
}
}
self.current_style = Some(new_style);
Ok(())
}
fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
ansi::sgr_reset(&mut self.writer)?;
if style.fg.a() > 0 {
ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
}
if style.bg.a() > 0 {
ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
}
if !style.attrs.is_empty() {
ansi::sgr_flags(&mut self.writer, style.attrs)?;
}
Ok(())
}
#[inline]
fn dec_len_u8(value: u8) -> u32 {
if value >= 100 {
3
} else if value >= 10 {
2
} else {
1
}
}
#[inline]
fn sgr_code_len(code: u8) -> u32 {
2 + Self::dec_len_u8(code) + 1
}
#[inline]
fn sgr_flags_len(flags: StyleFlags) -> u32 {
if flags.is_empty() {
return 0;
}
let mut count = 0u32;
let mut digits = 0u32;
for (flag, codes) in ansi::FLAG_TABLE {
if flags.contains(flag) {
count += 1;
digits += Self::dec_len_u8(codes.on);
}
}
if count == 0 {
return 0;
}
3 + digits + (count - 1)
}
#[inline]
fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
if flags.is_empty() {
return 0;
}
let mut len = 0u32;
for (flag, codes) in ansi::FLAG_TABLE {
if flags.contains(flag) {
len += Self::sgr_code_len(codes.off);
}
}
len
}
#[inline]
fn sgr_rgb_len(color: PackedRgba) -> u32 {
10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
}
fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
let attrs_removed = old.attrs & !new.attrs;
let attrs_added = new.attrs & !old.attrs;
let fg_changed = old.fg != new.fg;
let bg_changed = old.bg != new.bg;
if old.attrs == new.attrs {
if fg_changed {
ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
}
if bg_changed {
ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
}
return Ok(());
}
let mut collateral = StyleFlags::empty();
if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
collateral |= StyleFlags::DIM;
}
if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
collateral |= StyleFlags::BOLD;
}
let mut delta_len = 0u32;
delta_len += Self::sgr_flags_off_len(attrs_removed);
delta_len += Self::sgr_flags_len(collateral);
delta_len += Self::sgr_flags_len(attrs_added);
if fg_changed {
delta_len += if new.fg.a() == 0 {
5
} else {
Self::sgr_rgb_len(new.fg)
};
}
if bg_changed {
delta_len += if new.bg.a() == 0 {
5
} else {
Self::sgr_rgb_len(new.bg)
};
}
let mut baseline_len = 4u32;
if new.fg.a() > 0 {
baseline_len += Self::sgr_rgb_len(new.fg);
}
if new.bg.a() > 0 {
baseline_len += Self::sgr_rgb_len(new.bg);
}
baseline_len += Self::sgr_flags_len(new.attrs);
if delta_len > baseline_len {
return self.emit_style_full(new);
}
if !attrs_removed.is_empty() {
let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
if !collateral.is_empty() {
ansi::sgr_flags(&mut self.writer, collateral)?;
}
}
if !attrs_added.is_empty() {
ansi::sgr_flags(&mut self.writer, attrs_added)?;
}
if fg_changed {
ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
}
if bg_changed {
ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
}
Ok(())
}
fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
if !self.hyperlinks_enabled {
if self.current_link.is_none() {
return Ok(());
}
if self.current_link.is_some() {
ansi::hyperlink_end(&mut self.writer)?;
}
self.current_link = None;
return Ok(());
}
let raw_link_id = cell.attrs.link_id();
let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
None
} else {
Some(raw_link_id)
};
if self.current_link == new_link {
return Ok(());
}
if self.current_link.is_some() {
ansi::hyperlink_end(&mut self.writer)?;
}
let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
&& let Some(url) = registry.get(link_id)
&& is_safe_hyperlink_url(url)
{
ansi::hyperlink_start(&mut self.writer, url)?;
true
} else {
false
};
self.current_link = if actually_opened { new_link } else { None };
Ok(())
}
fn emit_content(
&mut self,
content: PreparedContent,
raw_width: usize,
pool: Option<&GraphemePool>,
) -> io::Result<()> {
match content {
PreparedContent::Grapheme(grapheme_id) => {
if let Some(pool) = pool
&& let Some(text) = pool.get(grapheme_id)
{
let safe = sanitize(text);
if !safe.is_empty() && display_width(safe.as_ref()) == raw_width {
return self.writer.write_all(safe.as_bytes());
}
}
if raw_width > 0 {
for _ in 0..raw_width {
self.writer.write_all(b"?")?;
}
}
Ok(())
}
PreparedContent::Char(ch) => {
if ch.is_ascii() {
let byte = if ch.is_ascii_control() {
b' '
} else {
ch as u8
};
return self.writer.write_all(&[byte]);
}
let safe_ch = if ch.is_control() { ' ' } else { ch };
let mut buf = [0u8; 4];
let encoded = safe_ch.encode_utf8(&mut buf);
self.writer.write_all(encoded.as_bytes())
}
PreparedContent::Empty => {
self.writer.write_all(b" ")
}
}
}
fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
return Ok(());
}
ansi::cup(
&mut self.writer,
y.saturating_add(self.viewport_offset_y),
x,
)?;
self.cursor_x = Some(x);
self.cursor_y = Some(y);
Ok(())
}
fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
return Ok(());
}
let same_row = self.cursor_y == Some(y);
let actual_y = y.saturating_add(self.viewport_offset_y);
if same_row {
if let Some(cx) = self.cursor_x {
if x > cx {
let dx = x - cx;
let cuf = cost_model::cuf_cost(dx);
let cha = cost_model::cha_cost(x);
let cup = cost_model::cup_cost(actual_y, x);
if cuf <= cha && cuf <= cup {
ansi::cuf(&mut self.writer, dx)?;
} else if cha <= cup {
ansi::cha(&mut self.writer, x)?;
} else {
ansi::cup(&mut self.writer, actual_y, x)?;
}
} else if x < cx {
let dx = cx - x;
let cub = cost_model::cub_cost(dx);
let cha = cost_model::cha_cost(x);
let cup = cost_model::cup_cost(actual_y, x);
if cha <= cub && cha <= cup {
ansi::cha(&mut self.writer, x)?;
} else if cub <= cup {
ansi::cub(&mut self.writer, dx)?;
} else {
ansi::cup(&mut self.writer, actual_y, x)?;
}
} else {
}
} else {
ansi::cup(&mut self.writer, actual_y, x)?;
}
} else {
ansi::cup(&mut self.writer, actual_y, x)?;
}
self.cursor_x = Some(x);
self.cursor_y = Some(y);
Ok(())
}
pub fn clear_screen(&mut self) -> io::Result<()> {
ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
ansi::cup(&mut self.writer, 0, 0)?;
self.cursor_x = Some(0);
self.cursor_y = Some(0);
self.writer.flush()
}
pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
self.move_cursor_to(0, y)?;
ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
self.writer.flush()
}
pub fn hide_cursor(&mut self) -> io::Result<()> {
ansi::cursor_hide(&mut self.writer)?;
self.writer.flush()
}
pub fn show_cursor(&mut self) -> io::Result<()> {
ansi::cursor_show(&mut self.writer)?;
self.writer.flush()
}
pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
self.move_cursor_to(x, y)?;
self.writer.flush()
}
pub fn reset(&mut self) {
self.current_style = None;
self.current_link = None;
self.cursor_x = None;
self.cursor_y = None;
}
pub fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
pub fn into_inner(self) -> Result<W, io::Error> {
self.writer
.into_inner() .into_inner() .map_err(|e| e.into_error())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::{CellAttrs, CellContent};
use crate::link_registry::LinkRegistry;
fn test_presenter() -> Presenter<Vec<u8>> {
let caps = TerminalCapabilities::basic();
Presenter::new(Vec::new(), caps)
}
fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
let mut caps = TerminalCapabilities::basic();
caps.sync_output = true;
Presenter::new(Vec::new(), caps)
}
fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
let mut caps = TerminalCapabilities::basic();
caps.osc8_hyperlinks = true;
Presenter::new(Vec::new(), caps)
}
fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
presenter.into_inner().unwrap()
}
fn legacy_plan_row(
row_runs: &[ChangeRun],
prev_x: Option<u16>,
prev_y: Option<u16>,
) -> Vec<cost_model::RowSpan> {
if row_runs.is_empty() {
return Vec::new();
}
if row_runs.len() == 1 {
let run = row_runs[0];
return vec![cost_model::RowSpan {
y: run.y,
x0: run.x0,
x1: run.x1,
}];
}
let row_y = row_runs[0].y;
let first_x = row_runs[0].x0;
let last_x = row_runs[row_runs.len() - 1].x1;
let mut sparse_cost: usize = 0;
let mut cursor_x = prev_x;
let mut cursor_y = prev_y;
for run in row_runs {
let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
let cells = (run.x1 as usize).saturating_sub(run.x0 as usize) + 1;
sparse_cost += move_cost + cells;
cursor_x = Some(run.x1.saturating_add(1));
cursor_y = Some(row_y);
}
let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
let total_cells = (last_x as usize).saturating_sub(first_x as usize) + 1;
let changed_cells: usize = row_runs
.iter()
.map(|r| (r.x1 as usize).saturating_sub(r.x0 as usize) + 1)
.sum();
let gap_cells = total_cells.saturating_sub(changed_cells);
let gap_overhead = gap_cells * 2;
let merged_cost = merge_move + changed_cells + gap_overhead;
if merged_cost < sparse_cost {
vec![cost_model::RowSpan {
y: row_y,
x0: first_x,
x1: last_x,
}]
} else {
row_runs
.iter()
.map(|run| cost_model::RowSpan {
y: run.y,
x0: run.x0,
x1: run.x1,
})
.collect()
}
}
fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
let mut presenter = test_presenter();
for span in spans {
presenter
.move_cursor_optimal(span.x0, span.y)
.expect("cursor move should succeed");
for x in span.x0..=span.x1 {
let cell = buffer.get_unchecked(x, span.y);
presenter
.emit_cell(x, cell, None, None)
.expect("emit_cell should succeed");
}
}
presenter
.writer
.write_all(b"\x1b[0m")
.expect("reset should succeed");
presenter.into_inner().expect("presenter output")
}
#[test]
fn empty_diff_produces_minimal_output() {
let mut presenter = test_presenter();
let buffer = Buffer::new(10, 10);
let diff = BufferDiff::new();
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
assert!(output.starts_with(ansi::CURSOR_HIDE));
assert!(output.ends_with(ansi::CURSOR_SHOW));
assert!(
output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
"SGR reset should be present"
);
}
#[test]
fn sync_output_wraps_frame() {
let mut presenter = test_presenter_with_sync();
let mut buffer = Buffer::new(3, 1);
buffer.set_raw(0, 0, Cell::from_char('X'));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
assert!(
output.starts_with(ansi::SYNC_BEGIN),
"sync output should begin with DEC 2026 begin"
);
assert!(
output.ends_with(ansi::SYNC_END),
"sync output should end with DEC 2026 end"
);
}
#[test]
fn sync_output_obeys_mux_policy() {
let caps = TerminalCapabilities::builder()
.sync_output(true)
.in_tmux(true)
.build();
let mut presenter = Presenter::new(Vec::new(), caps);
let mut buffer = Buffer::new(2, 1);
buffer.set_raw(0, 0, Cell::from_char('X'));
let old = Buffer::new(2, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
assert!(
!output
.windows(ansi::SYNC_BEGIN.len())
.any(|w| w == ansi::SYNC_BEGIN),
"tmux policy should suppress sync begin"
);
assert!(
!output
.windows(ansi::SYNC_END.len())
.any(|w| w == ansi::SYNC_END),
"tmux policy should suppress sync end"
);
}
#[test]
fn hyperlink_sequences_emitted_and_closed() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(3, 1);
let mut registry = LinkRegistry::new();
let link_id = registry.register("https://example.com");
let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
buffer.set_raw(0, 0, linked);
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(®istry))
.unwrap();
let output = get_output(presenter);
let start = b"\x1b]8;;https://example.com\x07";
let end = b"\x1b]8;;\x07";
let start_pos = output
.windows(start.len())
.position(|w| w == start)
.expect("hyperlink start not found");
let end_pos = output
.windows(end.len())
.position(|w| w == end)
.expect("hyperlink end not found");
let char_pos = output
.iter()
.position(|&b| b == b'L')
.expect("linked character not found");
assert!(start_pos < char_pos, "link start should precede text");
assert!(char_pos < end_pos, "link end should follow text");
}
#[test]
fn single_cell_change() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 10);
buffer.set_raw(5, 5, Cell::from_char('X'));
let old = Buffer::new(10, 10);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("X"));
assert!(output_str.contains("\x1b[")); }
#[test]
fn style_tracking_avoids_redundant_sgr() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 1);
let fg = PackedRgba::rgb(255, 0, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let sgr_count = output_str.matches("\x1b[38;2").count();
assert_eq!(
sgr_count, 1,
"Expected 1 SGR fg sequence, got {}",
sgr_count
);
}
#[test]
fn reset_reapplies_style_after_clear() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(1, 1);
let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
buffer.set_raw(0, 0, styled);
let old = Buffer::new(1, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
presenter.reset();
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let sgr_count = output_str.matches("\x1b[38;2").count();
assert_eq!(
sgr_count, 2,
"Expected style to be re-applied after reset, got {sgr_count} sequences"
);
}
#[test]
fn cursor_position_optimized() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 5);
buffer.set_raw(3, 2, Cell::from_char('A'));
buffer.set_raw(4, 2, Cell::from_char('B'));
buffer.set_raw(5, 2, Cell::from_char('C'));
let old = Buffer::new(10, 5);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
assert!(
output_str.contains("ABC")
|| (output_str.contains('A')
&& output_str.contains('B')
&& output_str.contains('C'))
);
}
#[test]
fn sync_output_wrapped_when_supported() {
let mut presenter = test_presenter_with_sync();
let buffer = Buffer::new(10, 10);
let diff = BufferDiff::new();
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
assert!(output.starts_with(ansi::SYNC_BEGIN));
assert!(
output
.windows(ansi::SYNC_END.len())
.any(|w| w == ansi::SYNC_END)
);
}
#[test]
fn clear_screen_works() {
let mut presenter = test_presenter();
presenter.clear_screen().unwrap();
let output = get_output(presenter);
assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
}
#[test]
fn cursor_visibility() {
let mut presenter = test_presenter();
presenter.hide_cursor().unwrap();
presenter.show_cursor().unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
#[test]
fn reset_clears_state() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(50);
presenter.cursor_y = Some(20);
presenter.current_style = Some(CellStyle::default());
presenter.reset();
assert!(presenter.cursor_x.is_none());
assert!(presenter.cursor_y.is_none());
assert!(presenter.current_style.is_none());
}
#[test]
fn position_cursor() {
let mut presenter = test_presenter();
presenter.position_cursor(10, 5).unwrap();
let output = get_output(presenter);
assert!(
output
.windows(b"\x1b[6;11H".len())
.any(|w| w == b"\x1b[6;11H")
);
}
#[test]
fn skip_cursor_move_when_already_at_position() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(5);
presenter.cursor_y = Some(3);
presenter.move_cursor_to(5, 3).unwrap();
let output = get_output(presenter);
assert!(output.is_empty());
}
#[test]
fn continuation_cells_skipped() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 1);
buffer.set_raw(0, 0, Cell::from_char('ä¸'));
buffer.set_raw(1, 0, Cell::CONTINUATION);
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('ä¸'));
}
#[test]
fn continuation_at_run_start_clears_orphan_tail() {
let mut presenter = test_presenter();
let mut old = Buffer::new(3, 1);
let mut new = Buffer::new(3, 1);
old.set_raw(0, 0, Cell::from_char('ä¸'));
new.set_raw(0, 0, Cell::from_char('ä¸'));
old.set_raw(1, 0, Cell::from_char('X'));
new.set_raw(1, 0, Cell::CONTINUATION);
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.changes(), &[(1u16, 0u16)]);
presenter.present(&new, &diff).unwrap();
let output = get_output(presenter);
assert!(
output.contains(&b' '),
"orphan continuation should be cleared with a space"
);
}
#[test]
fn continuation_cleanup_resets_style_and_closes_link_before_space() {
let mut presenter = test_presenter_with_hyperlinks();
let mut links = LinkRegistry::new();
let link_id = links.register("https://example.com");
let styled = Cell::from_char('X')
.with_fg(PackedRgba::rgb(255, 0, 0))
.with_bg(PackedRgba::rgb(0, 0, 255))
.with_attrs(CellAttrs::new(StyleFlags::UNDERLINE, link_id));
presenter.current_style = Some(CellStyle::from_cell(&styled));
presenter.current_link = Some(link_id);
presenter.cursor_x = Some(0);
presenter.cursor_y = Some(0);
presenter
.emit_cell(0, &Cell::CONTINUATION, None, Some(&links))
.unwrap();
let output = presenter.into_inner().unwrap();
let reset = b"\x1b[0m";
let close = b"\x1b]8;;\x07";
let reset_pos = output
.windows(reset.len())
.position(|window| window == reset)
.expect("continuation cleanup should reset SGR state");
let close_pos = output
.windows(close.len())
.position(|window| window == close)
.expect("continuation cleanup should close OSC 8");
let space_pos = output
.iter()
.position(|&byte| byte == b' ')
.expect("continuation cleanup should emit a space");
assert!(
reset_pos < space_pos,
"cleanup reset must precede the blank"
);
assert!(
close_pos < space_pos,
"cleanup link close must precede the blank"
);
}
#[test]
fn wide_char_missing_continuation_causes_drift() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 1);
buffer.set_raw(0, 0, Cell::from_char('ä¸'));
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('ä¸'));
let has_correction = output_str.contains("\x1b[D")
|| output_str.contains("\x1b[2G")
|| output_str.contains("\x1b[1;2H");
assert!(
has_correction,
"Presenter should correct cursor drift when wide char tail is missing. Output: {:?}",
output_str
);
}
#[test]
fn hyperlink_emitted_with_registry() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(10, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://example.com");
let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
buffer.set_raw(0, 0, cell);
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\x1b]8;;https://example.com\x07"),
"Expected OSC 8 open, got: {:?}",
output_str
);
assert!(
output_str.contains("\x1b]8;;\x07"),
"Expected OSC 8 close, got: {:?}",
output_str
);
}
#[test]
fn hyperlink_not_emitted_without_registry() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(10, 1);
let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
buffer.set_raw(0, 0, cell);
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
!output_str.contains("\x1b]8;"),
"OSC 8 should not appear without registry, got: {:?}",
output_str
);
}
#[test]
fn hyperlink_not_emitted_for_unknown_id() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(10, 1);
let links = LinkRegistry::new();
let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
buffer.set_raw(0, 0, cell);
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
!output_str.contains("\x1b]8;"),
"OSC 8 should not appear for unknown link IDs, got: {:?}",
output_str
);
assert!(output_str.contains('L'));
}
#[test]
fn hyperlink_closed_at_frame_end() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(10, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://example.com");
for x in 0..5 {
buffer.set_raw(
x,
0,
Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
}
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let close_seq = b"\x1b]8;;\x07";
assert!(
output.windows(close_seq.len()).any(|w| w == close_seq),
"Link must be closed at frame end"
);
}
#[test]
fn hyperlink_transitions_between_links() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(10, 1);
let mut links = LinkRegistry::new();
let link_a = links.register("https://a.com");
let link_b = links.register("https://b.com");
buffer.set_raw(
0,
0,
Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
);
buffer.set_raw(
1,
0,
Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
);
buffer.set_raw(2, 0, Cell::from_char('C'));
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("https://a.com"));
assert!(output_str.contains("https://b.com"));
let close_count = output_str.matches("\x1b]8;;\x07").count();
assert!(
close_count >= 2,
"Expected at least 2 link close sequences (transition + frame end), got {}",
close_count
);
}
#[test]
fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
let caps = TerminalCapabilities::builder()
.osc8_hyperlinks(true)
.in_tmux(true)
.build();
let mut presenter = Presenter::new(Vec::new(), caps);
let mut buffer = Buffer::new(3, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://example.com");
buffer.set_raw(
0,
0,
Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
!output_str.contains("\x1b]8;"),
"tmux policy should suppress OSC 8 sequences"
);
assert!(output_str.contains('L'));
}
#[test]
fn hyperlink_disabled_policy_noops_when_no_link_is_open() {
let mut presenter = test_presenter();
presenter
.emit_link_changes(&Cell::from_char('X'), None)
.unwrap();
assert!(presenter.into_inner().unwrap().is_empty());
}
#[test]
fn hyperlink_disabled_policy_still_closes_stale_open_link() {
let mut presenter = test_presenter();
presenter.current_link = Some(7);
presenter
.emit_link_changes(&Cell::from_char('X'), None)
.unwrap();
assert_eq!(presenter.into_inner().unwrap(), b"\x1b]8;;\x07");
}
#[test]
fn hyperlink_unsafe_url_not_emitted() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(3, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://example.com/\x1b[?2026h");
buffer.set_raw(
0,
0,
Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
!output_str.contains("\x1b]8;;https://example.com/"),
"unsafe hyperlink URL should be suppressed"
);
assert!(
!output_str.contains("\x1b[?2026h"),
"control payload must never be emitted via OSC 8"
);
assert!(output_str.contains('X'));
}
#[test]
fn hyperlink_overlong_url_not_emitted() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(3, 1);
let mut links = LinkRegistry::new();
let long_url = format!(
"https://example.com/{}",
"a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
);
let link_id = links.register(&long_url);
buffer.set_raw(
0,
0,
Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
!output_str.contains("\x1b]8;;https://example.com/"),
"overlong hyperlink URL should be suppressed"
);
assert!(output_str.contains('Y'));
}
#[test]
fn sync_output_not_wrapped_when_unsupported() {
let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
let diff = BufferDiff::new();
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
assert!(
!output
.windows(ansi::SYNC_BEGIN.len())
.any(|w| w == ansi::SYNC_BEGIN),
"Sync begin should not appear when sync_output is disabled"
);
assert!(
!output
.windows(ansi::SYNC_END.len())
.any(|w| w == ansi::SYNC_END),
"Sync end should not appear when sync_output is disabled"
);
assert!(
output.starts_with(ansi::CURSOR_HIDE),
"Fallback should start with cursor hide"
);
assert!(
output.ends_with(ansi::CURSOR_SHOW),
"Fallback should end with cursor show"
);
}
#[test]
fn present_flushes_buffered_output() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(5, 1);
buffer.set_raw(0, 0, Cell::from_char('T'));
buffer.set_raw(1, 0, Cell::from_char('E'));
buffer.set_raw(2, 0, Cell::from_char('S'));
buffer.set_raw(3, 0, Cell::from_char('T'));
let old = Buffer::new(5, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("TEST"),
"Expected 'TEST' in flushed output"
);
}
#[test]
fn present_stats_reports_cells_and_bytes() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 1);
for i in 0..5 {
buffer.set_raw(i, 0, Cell::from_char('X'));
}
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
let stats = presenter.present(&buffer, &diff).unwrap();
assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
assert!(stats.bytes_emitted > 0, "Expected some bytes written");
assert!(stats.run_count >= 1, "Expected at least 1 run");
}
#[test]
fn cursor_tracking_after_wide_char() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(0);
presenter.cursor_y = Some(0);
let mut buffer = Buffer::new(10, 1);
buffer.set_raw(0, 0, Cell::from_char('ä¸'));
buffer.set_raw(1, 0, Cell::CONTINUATION);
buffer.set_raw(2, 0, Cell::from_char('A'));
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('ä¸'));
assert!(output_str.contains('A'));
}
#[test]
fn cursor_position_after_multiple_runs() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(20, 3);
buffer.set_raw(0, 0, Cell::from_char('A'));
buffer.set_raw(1, 0, Cell::from_char('B'));
buffer.set_raw(5, 2, Cell::from_char('X'));
buffer.set_raw(6, 2, Cell::from_char('Y'));
let old = Buffer::new(20, 3);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('A'));
assert!(output_str.contains('B'));
assert!(output_str.contains('X'));
assert!(output_str.contains('Y'));
let cup_count = output_str.matches("\x1b[").count();
assert!(
cup_count >= 2,
"Expected at least 2 escape sequences for multiple runs"
);
}
#[test]
fn style_with_all_flags() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(5, 1);
let all_flags = StyleFlags::BOLD
| StyleFlags::DIM
| StyleFlags::ITALIC
| StyleFlags::UNDERLINE
| StyleFlags::BLINK
| StyleFlags::REVERSE
| StyleFlags::STRIKETHROUGH;
let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
buffer.set_raw(0, 0, cell);
let old = Buffer::new(5, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('X'));
assert!(output_str.contains("\x1b["), "Expected SGR sequences");
}
#[test]
fn style_transitions_between_different_colors() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
buffer.set_raw(
0,
0,
Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
);
buffer.set_raw(
1,
0,
Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
);
buffer.set_raw(
2,
0,
Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
);
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
}
#[test]
fn link_at_buffer_boundaries() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(5, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://boundary.test");
buffer.set_raw(
0,
0,
Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
buffer.set_raw(
4,
0,
Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
let old = Buffer::new(5, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("https://boundary.test"));
assert!(output_str.contains('F'));
assert!(output_str.contains('L'));
}
#[test]
fn link_state_cleared_after_reset() {
let mut presenter = test_presenter();
let mut links = LinkRegistry::new();
let link_id = links.register("https://example.com");
presenter.current_link = Some(link_id);
presenter.current_style = Some(CellStyle::default());
presenter.cursor_x = Some(5);
presenter.cursor_y = Some(3);
presenter.reset();
assert!(
presenter.current_link.is_none(),
"current_link should be None after reset"
);
assert!(
presenter.current_style.is_none(),
"current_style should be None after reset"
);
assert!(
presenter.cursor_x.is_none(),
"cursor_x should be None after reset"
);
assert!(
presenter.cursor_y.is_none(),
"cursor_y should be None after reset"
);
}
#[test]
fn link_transitions_linked_unlinked_linked() {
let mut presenter = test_presenter_with_hyperlinks();
let mut buffer = Buffer::new(5, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://toggle.test");
buffer.set_raw(
0,
0,
Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
2,
0,
Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
let old = Buffer::new(5, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let url_count = output_str.matches("https://toggle.test").count();
assert!(
url_count >= 2,
"Expected link to open at least twice, got {} occurrences",
url_count
);
let close_count = output_str.matches("\x1b]8;;\x07").count();
assert!(
close_count >= 2,
"Expected at least 2 link closes, got {}",
close_count
);
}
#[test]
fn multiple_presents_maintain_correct_state() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 1);
buffer.set_raw(0, 0, Cell::from_char('1'));
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let prev = buffer.clone();
buffer.set_raw(1, 0, Cell::from_char('2'));
let diff = BufferDiff::compute(&prev, &buffer);
presenter.present(&buffer, &diff).unwrap();
let prev = buffer.clone();
buffer.set_raw(2, 0, Cell::from_char('3'));
let diff = BufferDiff::compute(&prev, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('1'));
assert!(output_str.contains('2'));
assert!(output_str.contains('3'));
}
#[test]
fn sgr_delta_fg_only_change_no_reset() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let fg1 = PackedRgba::rgb(255, 0, 0);
let fg2 = PackedRgba::rgb(0, 255, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let reset_count = output_str.matches("\x1b[0m").count();
assert_eq!(
reset_count, 2,
"Expected 2 resets (initial + frame end), got {} in: {:?}",
reset_count, output_str
);
}
#[test]
fn sgr_delta_bg_only_change_no_reset() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let bg1 = PackedRgba::rgb(0, 0, 255);
let bg2 = PackedRgba::rgb(255, 255, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let reset_count = output_str.matches("\x1b[0m").count();
assert_eq!(
reset_count, 2,
"Expected 2 resets, got {} in: {:?}",
reset_count, output_str
);
}
#[test]
fn sgr_delta_attr_addition_no_reset() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let reset_count = output_str.matches("\x1b[0m").count();
assert_eq!(
reset_count, 2,
"Expected 2 resets, got {} in: {:?}",
reset_count, output_str
);
assert!(
output_str.contains("\x1b[3m"),
"Expected italic-on sequence in: {:?}",
output_str
);
}
#[test]
fn sgr_delta_attr_removal_uses_off_code() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\x1b[23m"),
"Expected italic-off sequence in: {:?}",
output_str
);
let reset_count = output_str.matches("\x1b[0m").count();
assert_eq!(
reset_count, 2,
"Expected 2 resets, got {} in: {:?}",
reset_count, output_str
);
}
#[test]
fn sgr_delta_bold_dim_collateral_re_enables() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\x1b[22m"),
"Expected bold-off (22) in: {:?}",
output_str
);
assert!(
output_str.contains("\x1b[2m"),
"Expected dim re-enable (2) in: {:?}",
output_str
);
}
#[test]
fn sgr_delta_same_style_no_output() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let fg = PackedRgba::rgb(255, 0, 0);
let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
let fg_count = output_str.matches("38;2;255;0;0").count();
assert_eq!(
fg_count, 1,
"Expected 1 fg sequence, got {} in: {:?}",
fg_count, output_str
);
}
#[test]
fn sgr_delta_cost_dominance_never_exceeds_baseline() {
let transitions: Vec<(CellStyle, CellStyle)> = vec![
(
CellStyle {
fg: PackedRgba::rgb(255, 0, 0),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
},
CellStyle {
fg: PackedRgba::rgb(0, 255, 0),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
},
),
(
CellStyle {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::rgb(255, 0, 0),
attrs: StyleFlags::empty(),
},
CellStyle {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::rgb(0, 0, 255),
attrs: StyleFlags::empty(),
},
),
(
CellStyle {
fg: PackedRgba::rgb(100, 100, 100),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::BOLD,
},
CellStyle {
fg: PackedRgba::rgb(100, 100, 100),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
},
),
(
CellStyle {
fg: PackedRgba::rgb(100, 100, 100),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
},
CellStyle {
fg: PackedRgba::rgb(100, 100, 100),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::BOLD,
},
),
];
for (old_style, new_style) in &transitions {
let delta_buf = {
let mut delta_presenter = {
let caps = TerminalCapabilities::basic();
Presenter::new(Vec::new(), caps)
};
delta_presenter.current_style = Some(*old_style);
delta_presenter
.emit_style_delta(*old_style, *new_style)
.unwrap();
delta_presenter.into_inner().unwrap()
};
let reset_buf = {
let mut reset_presenter = {
let caps = TerminalCapabilities::basic();
Presenter::new(Vec::new(), caps)
};
reset_presenter.emit_style_full(*new_style).unwrap();
reset_presenter.into_inner().unwrap()
};
assert!(
delta_buf.len() <= reset_buf.len(),
"Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
Delta: {:?}\nReset: {:?}",
delta_buf.len(),
reset_buf.len(),
old_style,
new_style,
String::from_utf8_lossy(&delta_buf),
String::from_utf8_lossy(&reset_buf),
);
}
}
#[test]
fn sgr_delta_evidence_ledger() {
use std::io::Write as _;
const SEED: u64 = 0xDEAD_BEEF_CAFE;
let mut rng_state = SEED;
let mut next_u64 = || -> u64 {
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
rng_state
};
let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
let v = rng();
let fg = if v & 1 == 0 {
PackedRgba::TRANSPARENT
} else {
let r = ((v >> 8) & 0xFF) as u8;
let g = ((v >> 16) & 0xFF) as u8;
let b = ((v >> 24) & 0xFF) as u8;
PackedRgba::rgb(r, g, b)
};
let v2 = rng();
let bg = if v2 & 1 == 0 {
PackedRgba::TRANSPARENT
} else {
let r = ((v2 >> 8) & 0xFF) as u8;
let g = ((v2 >> 16) & 0xFF) as u8;
let b = ((v2 >> 24) & 0xFF) as u8;
PackedRgba::rgb(r, g, b)
};
let attrs = StyleFlags::from_bits_truncate(rng() as u8);
CellStyle { fg, bg, attrs }
};
let mut ledger = Vec::new();
let num_transitions = 200;
for i in 0..num_transitions {
let old_style = random_style(&mut next_u64);
let new_style = random_style(&mut next_u64);
let mut delta_p = {
let caps = TerminalCapabilities::basic();
Presenter::new(Vec::new(), caps)
};
delta_p.current_style = Some(old_style);
delta_p.emit_style_delta(old_style, new_style).unwrap();
let delta_out = delta_p.into_inner().unwrap();
let mut reset_p = {
let caps = TerminalCapabilities::basic();
Presenter::new(Vec::new(), caps)
};
reset_p.emit_style_full(new_style).unwrap();
let reset_out = reset_p.into_inner().unwrap();
let delta_bytes = delta_out.len();
let baseline_bytes = reset_out.len();
let attrs_removed = old_style.attrs & !new_style.attrs;
let removed_count = attrs_removed.bits().count_ones();
let fg_changed = old_style.fg != new_style.fg;
let bg_changed = old_style.bg != new_style.bg;
let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
assert!(
delta_bytes <= baseline_bytes,
"Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
);
writeln!(
&mut ledger,
"{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
\"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
\"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
\"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
old_style.fg,
old_style.bg,
old_style.attrs.bits(),
new_style.fg,
new_style.bg,
new_style.attrs.bits(),
baseline_bytes as isize - delta_bytes as isize,
)
.unwrap();
}
let text = String::from_utf8(ledger).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), num_transitions);
let mut total_saved: isize = 0;
for line in &lines {
let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
let cd: isize = line[cd_start..cd_end].parse().unwrap();
total_saved += cd;
}
assert!(
total_saved >= 0,
"Total byte savings should be non-negative, got {total_saved}"
);
}
#[test]
fn e2e_style_stress_with_byte_metrics() {
let width = 40u16;
let height = 10u16;
let mut buffer = Buffer::new(width, height);
for y in 0..height {
for x in 0..width {
let i = (y as usize * width as usize + x as usize) as u8;
let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
let bg = if i.is_multiple_of(4) {
PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
} else {
PackedRgba::TRANSPARENT
};
let flags = StyleFlags::from_bits_truncate(i % 128);
let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
let cell = Cell::from_char(ch)
.with_fg(fg)
.with_bg(bg)
.with_attrs(CellAttrs::new(flags, 0));
buffer.set_raw(x, y, cell);
}
}
let blank = Buffer::new(width, height);
let diff = BufferDiff::compute(&blank, &buffer);
let mut presenter = test_presenter();
presenter.present(&buffer, &diff).unwrap();
let frame1_bytes = presenter.into_inner().unwrap().len();
let mut buffer2 = Buffer::new(width, height);
for y in 0..height {
for x in 0..width {
let i = (y as usize * width as usize + x as usize + 1) as u8;
let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
let bg = if i.is_multiple_of(4) {
PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
} else {
PackedRgba::TRANSPARENT
};
let flags = StyleFlags::from_bits_truncate(i % 128);
let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
let cell = Cell::from_char(ch)
.with_fg(fg)
.with_bg(bg)
.with_attrs(CellAttrs::new(flags, 0));
buffer2.set_raw(x, y, cell);
}
}
let diff2 = BufferDiff::compute(&buffer, &buffer2);
let mut presenter2 = test_presenter();
presenter2.present(&buffer2, &diff2).unwrap();
let frame2_bytes = presenter2.into_inner().unwrap().len();
assert!(
frame2_bytes > 0,
"Second frame should produce output for style churn"
);
assert!(!diff2.is_empty(), "Style shift should produce changes");
assert!(
frame2_bytes <= frame1_bytes * 2,
"Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
);
}
#[test]
fn cost_model_empty_row_single_run() {
let runs = [ChangeRun::new(5, 10, 20)];
let plan = cost_model::plan_row(&runs, None, None);
assert_eq!(plan.spans().len(), 1);
assert_eq!(plan.spans()[0].x0, 10);
assert_eq!(plan.spans()[0].x1, 20);
assert!(plan.total_cost() > 0);
}
#[test]
fn cost_model_full_row_merges() {
let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
let plan = cost_model::plan_row(&runs, None, None);
assert_eq!(plan.spans().len(), 2);
assert_eq!(plan.spans()[0].x0, 0);
assert_eq!(plan.spans()[0].x1, 2);
assert_eq!(plan.spans()[1].x0, 77);
assert_eq!(plan.spans()[1].x1, 79);
}
#[test]
fn cost_model_adjacent_runs_merge() {
let runs = [
ChangeRun::new(3, 10, 10),
ChangeRun::new(3, 12, 12),
ChangeRun::new(3, 14, 14),
ChangeRun::new(3, 16, 16),
ChangeRun::new(3, 18, 18),
ChangeRun::new(3, 20, 20),
ChangeRun::new(3, 22, 22),
ChangeRun::new(3, 24, 24),
];
let plan = cost_model::plan_row(&runs, None, None);
assert_eq!(plan.spans().len(), 1);
assert_eq!(plan.spans()[0].x0, 10);
assert_eq!(plan.spans()[0].x1, 24);
}
#[test]
fn cost_model_single_cell_stays_sparse() {
let runs = [ChangeRun::new(0, 40, 40)];
let plan = cost_model::plan_row(&runs, Some(0), Some(0));
assert_eq!(plan.spans().len(), 1);
assert_eq!(plan.spans()[0].x0, 40);
assert_eq!(plan.spans()[0].x1, 40);
}
#[test]
fn cost_model_cup_vs_cha_vs_cuf() {
assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
let cha = cost_model::cha_cost(5);
let cup = cost_model::cup_cost(0, 5);
assert!(cha <= cup);
let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
assert_eq!(cost, 3); }
#[test]
fn cost_model_digit_estimation_accuracy() {
let mut buf = Vec::new();
ansi::cup(&mut buf, 0, 0).unwrap();
assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
buf.clear();
ansi::cup(&mut buf, 9, 9).unwrap();
assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
buf.clear();
ansi::cup(&mut buf, 99, 99).unwrap();
assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
buf.clear();
ansi::cha(&mut buf, 0).unwrap();
assert_eq!(buf.len(), cost_model::cha_cost(0));
buf.clear();
ansi::cuf(&mut buf, 1).unwrap();
assert_eq!(buf.len(), cost_model::cuf_cost(1));
buf.clear();
ansi::cuf(&mut buf, 10).unwrap();
assert_eq!(buf.len(), cost_model::cuf_cost(10));
}
#[test]
fn cost_model_merged_row_produces_correct_output() {
let width = 30u16;
let mut buffer = Buffer::new(width, 1);
for col in [5u16, 10, 15, 20] {
let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
buffer.set_raw(col, 0, Cell::from_char(ch));
}
let old = Buffer::new(width, 1);
let diff = BufferDiff::compute(&old, &buffer);
let mut presenter = test_presenter();
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
for col in [5u16, 10, 15, 20] {
let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
assert!(
output_str.contains(ch),
"Missing character '{ch}' at col {col} in output"
);
}
}
#[test]
fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(5);
presenter.cursor_y = Some(0);
presenter.move_cursor_optimal(6, 0).unwrap();
let output = presenter.into_inner().unwrap();
assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
}
#[test]
fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(10);
presenter.cursor_y = Some(3);
let target_x = 2;
let target_y = 3;
let cha_cost = cost_model::cha_cost(target_x);
let cup_cost = cost_model::cup_cost(target_y, target_x);
assert!(
cha_cost <= cup_cost,
"Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
);
presenter.move_cursor_optimal(target_x, target_y).unwrap();
let output = presenter.into_inner().unwrap();
let mut expected = Vec::new();
ansi::cha(&mut expected, target_x).unwrap();
assert_eq!(output, expected, "Should use CHA for backward move");
}
#[test]
fn cost_model_optimal_cursor_uses_cup_on_row_change() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(4);
presenter.cursor_y = Some(1);
presenter.move_cursor_optimal(7, 4).unwrap();
let output = presenter.into_inner().unwrap();
let mut expected = Vec::new();
ansi::cup(&mut expected, 4, 7).unwrap();
assert_eq!(output, expected, "Should use CUP when row changes");
}
#[test]
fn cost_model_chooses_full_row_when_cheaper() {
let width = 40u16;
let mut buffer = Buffer::new(width, 1);
for col in (0..20).step_by(2) {
buffer.set_raw(col, 0, Cell::from_char('X'));
}
let old = Buffer::new(width, 1);
let diff = BufferDiff::compute(&old, &buffer);
let runs = diff.runs();
let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
if row_runs.len() > 1 {
let plan = cost_model::plan_row(&row_runs, None, None);
assert!(
plan.spans().len() == 1,
"Expected single merged span for many small runs, got {} spans",
plan.spans().len()
);
assert_eq!(plan.spans()[0].x0, 0);
assert_eq!(plan.spans()[0].x1, 18);
}
}
#[test]
fn perf_cost_model_overhead() {
use std::time::Instant;
let runs: Vec<ChangeRun> = (0..100)
.map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
.collect();
let (iterations, max_ms) = if cfg!(debug_assertions) {
(1_000, 1_000u128)
} else {
(10_000, 500u128)
};
let start = Instant::now();
for _ in 0..iterations {
let _ = cost_model::plan_row(&runs, None, None);
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < max_ms,
"Cost model planning too slow: {elapsed:?} for {iterations} iterations"
);
}
#[test]
fn perf_legacy_vs_dp_worst_case_sparse() {
use std::time::Instant;
let width = 200u16;
let height = 1u16;
let mut buffer = Buffer::new(width, height);
for col in (0..40).step_by(2) {
buffer.set_raw(col, 0, Cell::from_char('X'));
}
for col in (160..200).step_by(2) {
buffer.set_raw(col, 0, Cell::from_char('Y'));
}
let blank = Buffer::new(width, height);
let diff = BufferDiff::compute(&blank, &buffer);
let runs = diff.runs();
let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
let dp_plan = cost_model::plan_row(&row_runs, None, None);
let legacy_spans = legacy_plan_row(&row_runs, None, None);
let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
assert!(
dp_output.len() <= legacy_output.len(),
"DP output should be <= legacy output (dp={}, legacy={})",
dp_output.len(),
legacy_output.len()
);
let (iterations, max_ms) = if cfg!(debug_assertions) {
(1_000, 1_000u128)
} else {
(10_000, 500u128)
};
let start = Instant::now();
for _ in 0..iterations {
let _ = cost_model::plan_row(&row_runs, None, None);
}
let dp_elapsed = start.elapsed();
let start = Instant::now();
for _ in 0..iterations {
let _ = legacy_plan_row(&row_runs, None, None);
}
let legacy_elapsed = start.elapsed();
assert!(
dp_elapsed.as_millis() < max_ms,
"DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
);
let _ = legacy_elapsed;
}
fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
let mut buffer = Buffer::new(width, height);
let mut rng = seed;
let mut next = || -> u64 {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
rng
};
for y in 0..height {
for x in 0..width {
let v = next();
let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
let bg = if v & 3 == 0 {
PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
} else {
PackedRgba::TRANSPARENT
};
let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
let cell = Cell::from_char(ch)
.with_fg(fg)
.with_bg(bg)
.with_attrs(CellAttrs::new(flags, 0));
buffer.set_raw(x, y, cell);
}
}
buffer
}
fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
let mut buffer = base.clone();
let width = base.width();
let height = base.height();
let mut rng = seed;
let mut next = || -> u64 {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
rng
};
let change_count = (width as usize * height as usize) / 10;
for _ in 0..change_count {
let v = next();
let x = (v % width as u64) as u16;
let y = ((v >> 16) % height as u64) as u16;
let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
buffer.set_raw(x, y, Cell::from_char(ch));
}
buffer
}
#[test]
fn snapshot_presenter_equivalence() {
let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
let blank = Buffer::new(40, 10);
let diff = BufferDiff::compute(&blank, &buffer);
let mut presenter = test_presenter();
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
let checksum = {
let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3); }
hash
};
let mut presenter2 = test_presenter();
presenter2.present(&buffer, &diff).unwrap();
let output2 = presenter2.into_inner().unwrap();
assert_eq!(output, output2, "Presenter output must be deterministic");
let _ = checksum; }
#[test]
fn perf_presenter_microbench() {
use std::env;
use std::io::Write as _;
use std::time::Instant;
let width = 120u16;
let height = 40u16;
let seed = 0x00BE_EFCA_FE42;
let scene = build_style_heavy_scene(width, height, seed);
let blank = Buffer::new(width, height);
let diff_full = BufferDiff::compute(&blank, &scene);
let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
let diff_sparse = BufferDiff::compute(&scene, &scene2);
let mut jsonl = Vec::new();
let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(50);
let runs_full = diff_full.runs();
let runs_sparse = diff_sparse.runs();
let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
let mut idx = 0;
let mut total_cost = 0usize;
let mut span_count = 0usize;
let mut prev_x = None;
let mut prev_y = None;
while idx < runs.len() {
let y = runs[idx].y;
let start = idx;
while idx < runs.len() && runs[idx].y == y {
idx += 1;
}
let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
span_count += plan.spans().len();
total_cost = total_cost.saturating_add(plan.total_cost());
if let Some(last) = plan.spans().last() {
prev_x = Some(last.x1);
prev_y = Some(y);
}
}
(total_cost, span_count)
};
for i in 0..iterations {
let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
(&diff_full, &scene, &runs_full, "full")
} else {
(&diff_sparse, &scene2, &runs_sparse, "sparse")
};
let plan_start = Instant::now();
let (plan_cost, plan_spans) = plan_rows(runs_ref);
let plan_time_us = plan_start.elapsed().as_micros() as u64;
let mut presenter = test_presenter();
let start = Instant::now();
let stats = presenter.present(buf_ref, diff_ref).unwrap();
let elapsed_us = start.elapsed().as_micros() as u64;
let output = presenter.into_inner().unwrap();
let checksum = {
let mut hash: u64 = 0xcbf29ce484222325;
for &b in &output {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
};
writeln!(
&mut jsonl,
"{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
\"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
\"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
\"plan_time_us\":{plan_time_us},\"bytes\":{},\
\"emit_time_us\":{elapsed_us},\
\"checksum\":\"{checksum:016x}\"}}",
stats.cells_changed, stats.run_count, stats.bytes_emitted,
)
.unwrap();
}
let text = String::from_utf8(jsonl).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), iterations as usize);
let full_checksums: Vec<&str> = lines
.iter()
.filter(|l| l.contains("\"full\""))
.map(|l| {
let start = l.find("\"checksum\":\"").unwrap() + 12;
let end = l[start..].find('"').unwrap() + start;
&l[start..end]
})
.collect();
assert!(full_checksums.len() > 1);
assert!(
full_checksums.windows(2).all(|w| w[0] == w[1]),
"Full frame checksums should be identical across runs"
);
let full_bytes: Vec<u64> = lines
.iter()
.filter(|l| l.contains("\"full\""))
.map(|l| {
let start = l.find("\"bytes\":").unwrap() + 8;
let end = l[start..].find(',').unwrap() + start;
l[start..end].parse::<u64>().unwrap()
})
.collect();
let sparse_bytes: Vec<u64> = lines
.iter()
.filter(|l| l.contains("\"sparse\""))
.map(|l| {
let start = l.find("\"bytes\":").unwrap() + 8;
let end = l[start..].find(',').unwrap() + start;
l[start..end].parse::<u64>().unwrap()
})
.collect();
let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
assert!(
avg_sparse < avg_full,
"Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
);
}
#[test]
fn perf_emit_style_delta_microbench() {
use std::env;
use std::io::Write as _;
use std::time::Instant;
let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(200);
let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
let emit_json = mode != "raw";
let mut styles = Vec::with_capacity(128);
let mut rng = 0x00A5_A51E_AF42_u64;
let mut next = || -> u64 {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
rng
};
for _ in 0..128 {
let v = next();
let fg = PackedRgba::rgb(
(v & 0xFF) as u8,
((v >> 8) & 0xFF) as u8,
((v >> 16) & 0xFF) as u8,
);
let bg = PackedRgba::rgb(
((v >> 24) & 0xFF) as u8,
((v >> 32) & 0xFF) as u8,
((v >> 40) & 0xFF) as u8,
);
let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
let cell = Cell::from_char('A')
.with_fg(fg)
.with_bg(bg)
.with_attrs(CellAttrs::new(flags, 0));
styles.push(CellStyle::from_cell(&cell));
}
let mut presenter = test_presenter();
let mut jsonl = Vec::new();
let mut sink = 0u64;
for i in 0..iterations {
let old = styles[i as usize % styles.len()];
let new = styles[(i as usize + 1) % styles.len()];
presenter.writer.reset_counter();
presenter.writer.inner_mut().get_mut().clear();
let start = Instant::now();
presenter.emit_style_delta(old, new).unwrap();
let elapsed_us = start.elapsed().as_micros() as u64;
let bytes = presenter.writer.bytes_written();
if emit_json {
writeln!(
&mut jsonl,
"{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
)
.unwrap();
} else {
sink = sink.wrapping_add(elapsed_us ^ bytes);
}
}
if emit_json {
let text = String::from_utf8(jsonl).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len() as u32, iterations);
} else {
std::hint::black_box(sink);
}
}
#[test]
fn e2e_presenter_stress_deterministic() {
use crate::terminal_model::TerminalModel;
let width = 60u16;
let height = 20u16;
let num_frames = 10;
let mut prev_buffer = Buffer::new(width, height);
let mut presenter = test_presenter();
let mut model = TerminalModel::new(width as usize, height as usize);
let mut rng = 0x5D2E_55DE_5D42_u64;
let mut next = || -> u64 {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
rng
};
for _frame in 0..num_frames {
let mut buffer = prev_buffer.clone();
let changes = (width as usize * height as usize) / 5;
for _ in 0..changes {
let v = next();
let x = (v % width as u64) as u16;
let y = ((v >> 16) % height as u64) as u16;
let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
let cell = Cell::from_char(ch).with_fg(fg);
buffer.set_raw(x, y, cell);
}
let diff = BufferDiff::compute(&prev_buffer, &buffer);
presenter.present(&buffer, &diff).unwrap();
prev_buffer = buffer;
}
let output = presenter.into_inner().unwrap();
model.process(&output);
let mut checked = 0;
for y in 0..height {
for x in 0..width {
let buf_cell = prev_buffer.get_unchecked(x, y);
if !buf_cell.is_empty()
&& let Some(model_cell) = model.cell(x as usize, y as usize)
{
let expected = buf_cell.content.as_char().unwrap_or(' ');
let mut buf = [0u8; 4];
let expected_str = expected.encode_utf8(&mut buf);
if model_cell.text.as_str() == expected_str {
checked += 1;
}
}
}
}
let total_nonempty = (0..height)
.flat_map(|y| (0..width).map(move |x| (x, y)))
.filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
.count();
assert!(
checked > total_nonempty * 80 / 100,
"Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
);
}
#[test]
fn style_state_persists_across_frames() {
let mut presenter = test_presenter();
let fg = PackedRgba::rgb(100, 150, 200);
let mut buffer = Buffer::new(5, 1);
buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
let old = Buffer::new(5, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
assert!(
presenter.current_style.is_none(),
"Style should be reset after frame end"
);
}
#[test]
fn cost_cup_zero_zero() {
assert_eq!(cost_model::cup_cost(0, 0), 6);
}
#[test]
fn cost_cup_max_max() {
assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
}
#[test]
fn cost_cha_zero() {
assert_eq!(cost_model::cha_cost(0), 4);
}
#[test]
fn cost_cha_max() {
assert_eq!(cost_model::cha_cost(u16::MAX), 8);
}
#[test]
fn cost_cuf_zero_is_free() {
assert_eq!(cost_model::cuf_cost(0), 0);
}
#[test]
fn cost_cuf_one_is_three() {
assert_eq!(cost_model::cuf_cost(1), 3);
}
#[test]
fn cost_cuf_two_has_digit() {
assert_eq!(cost_model::cuf_cost(2), 4);
}
#[test]
fn cost_cuf_max() {
assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
}
#[test]
fn cost_cheapest_move_already_at_target() {
assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
}
#[test]
fn cost_cheapest_move_unknown_position() {
let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
assert_eq!(cost, cost_model::cup_cost(3, 5));
}
#[test]
fn cost_cheapest_move_known_y_unknown_x() {
let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
assert_eq!(cost, cost_model::cup_cost(3, 5));
}
#[test]
fn cost_cheapest_move_backward_same_row() {
let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
let cha = cost_model::cha_cost(5);
let cub = cost_model::cub_cost(45);
assert_eq!(cost, cha.min(cub));
assert!(cost_model::cup_cost(0, 5) > cha);
}
#[test]
fn cost_cheapest_move_forward_same_row() {
let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 50, 0);
let cha = cost_model::cha_cost(50);
let cuf = cost_model::cuf_cost(45);
assert_eq!(cost, cha.min(cuf));
assert!(cost_model::cup_cost(0, 50) > cha);
}
#[test]
fn cost_cheapest_move_same_row_same_col() {
assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
}
#[test]
fn cost_cup_digit_boundaries() {
let mut buf = Vec::new();
for (row, col) in [
(0u16, 0u16),
(8, 8),
(9, 9),
(98, 98),
(99, 99),
(998, 998),
(999, 999),
(9998, 9998),
(9999, 9999),
(u16::MAX, u16::MAX),
] {
buf.clear();
ansi::cup(&mut buf, row, col).unwrap();
assert_eq!(
buf.len(),
cost_model::cup_cost(row, col),
"CUP cost mismatch at ({row}, {col})"
);
}
}
#[test]
fn cost_cha_digit_boundaries() {
let mut buf = Vec::new();
for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
buf.clear();
ansi::cha(&mut buf, col).unwrap();
assert_eq!(
buf.len(),
cost_model::cha_cost(col),
"CHA cost mismatch at col {col}"
);
}
}
#[test]
fn cost_cuf_digit_boundaries() {
let mut buf = Vec::new();
for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
buf.clear();
ansi::cuf(&mut buf, n).unwrap();
assert_eq!(
buf.len(),
cost_model::cuf_cost(n),
"CUF cost mismatch for n={n}"
);
}
}
#[test]
fn plan_row_reuse_matches_plan_row() {
let runs = [
ChangeRun::new(5, 2, 4),
ChangeRun::new(5, 8, 10),
ChangeRun::new(5, 20, 25),
];
let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
let mut scratch = cost_model::RowPlanScratch::default();
let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
assert_eq!(plan1, plan2);
}
#[test]
fn plan_row_reuse_single_run_matches_plan_row() {
let runs = [ChangeRun::new(7, 18, 24)];
let plan1 = cost_model::plan_row(&runs, Some(2), Some(7));
let mut scratch = cost_model::RowPlanScratch::default();
let plan2 = cost_model::plan_row_reuse(&runs, Some(2), Some(7), &mut scratch);
assert_eq!(plan1, plan2);
assert_eq!(
plan2.total_cost(),
cost_model::cheapest_move_cost(Some(2), Some(7), 18, 7) + runs[0].len()
);
}
#[test]
fn plan_row_reuse_across_different_sizes() {
let mut scratch = cost_model::RowPlanScratch::default();
let large_runs: Vec<ChangeRun> = (0..20)
.map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
.collect();
let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
assert!(!plan_large.spans().is_empty());
let small_runs = [ChangeRun::new(1, 5, 8)];
let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
assert_eq!(plan_small.spans().len(), 1);
assert_eq!(plan_small.spans()[0].x0, 5);
assert_eq!(plan_small.spans()[0].x1, 8);
}
#[test]
fn plan_row_gap_exactly_32_cells() {
let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
let plan = cost_model::plan_row(&runs, None, None);
assert!(
plan.spans().len() <= 2,
"32-cell gap should still consider merge"
);
}
#[test]
fn plan_row_gap_33_cells_stays_sparse() {
let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
let plan = cost_model::plan_row(&runs, None, None);
assert_eq!(
plan.spans().len(),
2,
"33-cell gap should stay sparse (gap > 32 breaks)"
);
}
#[test]
fn plan_row_many_sparse_spans() {
let runs = [
ChangeRun::new(0, 0, 0),
ChangeRun::new(0, 40, 40),
ChangeRun::new(0, 80, 80),
ChangeRun::new(0, 120, 120),
ChangeRun::new(0, 160, 160),
ChangeRun::new(0, 200, 200),
];
let plan = cost_model::plan_row(&runs, None, None);
assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
}
#[test]
fn cell_style_default_is_transparent_no_attrs() {
let style = CellStyle::default();
assert_eq!(style.fg, PackedRgba::TRANSPARENT);
assert_eq!(style.bg, PackedRgba::TRANSPARENT);
assert!(style.attrs.is_empty());
}
#[test]
fn cell_style_from_cell_captures_all() {
let fg = PackedRgba::rgb(10, 20, 30);
let bg = PackedRgba::rgb(40, 50, 60);
let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
let cell = Cell::from_char('X')
.with_fg(fg)
.with_bg(bg)
.with_attrs(CellAttrs::new(flags, 5));
let style = CellStyle::from_cell(&cell);
assert_eq!(style.fg, fg);
assert_eq!(style.bg, bg);
assert_eq!(style.attrs, flags);
}
#[test]
fn cell_style_eq_and_clone() {
let a = CellStyle {
fg: PackedRgba::rgb(1, 2, 3),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::DIM,
};
let b = a;
assert_eq!(a, b);
}
#[test]
fn sgr_flags_len_empty() {
assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
}
#[test]
fn sgr_flags_len_single() {
let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
assert!(len > 0);
let mut buf = Vec::new();
ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
assert_eq!(len as usize, buf.len());
}
#[test]
fn sgr_flags_len_multiple() {
let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
let mut buf = Vec::new();
ansi::sgr_flags(&mut buf, flags).unwrap();
assert_eq!(len as usize, buf.len());
}
#[test]
fn sgr_flags_off_len_empty() {
assert_eq!(
Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
0
);
}
#[test]
fn sgr_rgb_len_matches_actual() {
let color = PackedRgba::rgb(0, 0, 0);
let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
assert!(estimated > 0);
}
#[test]
fn sgr_rgb_len_large_values() {
let color = PackedRgba::rgb(255, 255, 255);
let small_color = PackedRgba::rgb(0, 0, 0);
let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
assert!(large_len > small_len);
}
#[test]
fn dec_len_u8_boundaries() {
assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
}
#[test]
fn sgr_delta_all_attrs_removed_at_once() {
let mut presenter = test_presenter();
let all_flags = StyleFlags::BOLD
| StyleFlags::DIM
| StyleFlags::ITALIC
| StyleFlags::UNDERLINE
| StyleFlags::BLINK
| StyleFlags::REVERSE
| StyleFlags::STRIKETHROUGH;
let old = CellStyle {
fg: PackedRgba::rgb(100, 100, 100),
bg: PackedRgba::TRANSPARENT,
attrs: all_flags,
};
let new = CellStyle {
fg: PackedRgba::rgb(100, 100, 100),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
};
presenter.current_style = Some(old);
presenter.emit_style_delta(old, new).unwrap();
let output = presenter.into_inner().unwrap();
assert!(!output.is_empty());
}
#[test]
fn sgr_delta_fg_to_transparent() {
let mut presenter = test_presenter();
let old = CellStyle {
fg: PackedRgba::rgb(200, 100, 50),
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
};
let new = CellStyle {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
};
presenter.current_style = Some(old);
presenter.emit_style_delta(old, new).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
}
#[test]
fn sgr_delta_bg_to_transparent() {
let mut presenter = test_presenter();
let old = CellStyle {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::rgb(30, 60, 90),
attrs: StyleFlags::empty(),
};
let new = CellStyle {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
};
presenter.current_style = Some(old);
presenter.emit_style_delta(old, new).unwrap();
let output = presenter.into_inner().unwrap();
assert!(!output.is_empty(), "Should emit bg removal");
}
#[test]
fn sgr_delta_dim_removed_bold_stays() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\x1b[22m"),
"Expected dim-off (22) in: {output_str:?}"
);
assert!(
output_str.contains("\x1b[1m"),
"Expected bold re-enable (1) in: {output_str:?}"
);
}
#[test]
fn sgr_delta_fallback_to_full_reset_when_cheaper() {
let mut presenter = test_presenter();
let old = CellStyle {
fg: PackedRgba::rgb(10, 20, 30),
bg: PackedRgba::rgb(40, 50, 60),
attrs: StyleFlags::BOLD
| StyleFlags::DIM
| StyleFlags::ITALIC
| StyleFlags::UNDERLINE
| StyleFlags::STRIKETHROUGH,
};
let new = CellStyle {
fg: PackedRgba::TRANSPARENT,
bg: PackedRgba::TRANSPARENT,
attrs: StyleFlags::empty(),
};
presenter.current_style = Some(old);
presenter.emit_style_delta(old, new).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\x1b[0m"),
"Expected full reset fallback: {output_str:?}"
);
}
#[test]
fn emit_cell_control_char_replaced_with_fffd() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(0);
presenter.cursor_y = Some(0);
let cell = Cell::from_char('\x01');
presenter.emit_cell(0, &cell, None, None).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains('\u{FFFD}'),
"Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
);
assert!(
!output.contains(&0x01),
"Raw control char should not appear"
);
}
#[test]
fn emit_content_empty_cell_emits_space() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(0);
presenter.cursor_y = Some(0);
let cell = Cell::default();
assert!(cell.is_empty());
presenter.emit_cell(0, &cell, None, None).unwrap();
let output = presenter.into_inner().unwrap();
assert!(output.contains(&b' '), "Empty cell should emit space");
}
#[test]
fn emit_content_ascii_char_emits_single_byte() {
let mut presenter = test_presenter();
presenter
.emit_content(PreparedContent::Char('A'), 1, None)
.unwrap();
let output = presenter.into_inner().unwrap();
assert_eq!(output, b"A");
}
#[test]
fn emit_content_ascii_control_sanitizes_to_space() {
let mut presenter = test_presenter();
presenter
.emit_content(PreparedContent::Char('\n'), 1, None)
.unwrap();
let output = presenter.into_inner().unwrap();
assert_eq!(output, b" ");
}
#[test]
fn prepared_content_ascii_widths_match_char_width_contract() {
for ch in ['A', ' ', '\n', '\r', '\x1f', '\x7f'] {
let cell = Cell::from_char(ch);
let (prepared, width) = PreparedContent::from_cell(&cell);
assert_eq!(prepared, PreparedContent::Char(ch));
assert_eq!(width, char_width(ch), "width mismatch for {ch:?}");
}
}
#[test]
fn prepared_content_tab_uses_canonicalized_space() {
let cell = Cell::from_char('\t');
let (prepared, width) = PreparedContent::from_cell(&cell);
assert_eq!(prepared, PreparedContent::Char(' '));
assert_eq!(width, 1);
}
#[test]
fn prepared_content_nul_uses_empty_cell_representation() {
let cell = Cell::from_char('\0');
let (prepared, width) = PreparedContent::from_cell(&cell);
assert_eq!(prepared, PreparedContent::Empty);
assert_eq!(width, 0);
}
#[test]
fn emit_content_grapheme_sanitizes_escape_sequences() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(0);
presenter.cursor_y = Some(0);
let mut pool = GraphemePool::new();
let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
let cell = Cell::new(CellContent::from_grapheme(gid));
presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("AB"),
"sanitized grapheme should preserve visible payload"
);
assert!(
!output_str.contains("\x1b[31m"),
"raw escape sequence must not be emitted"
);
}
#[test]
fn emit_content_grapheme_width_mismatch_uses_placeholders() {
let mut presenter = test_presenter();
let mut pool = GraphemePool::new();
let gid = pool.intern("A\x07", 2);
presenter
.emit_content(PreparedContent::Grapheme(gid), 2, Some(&pool))
.unwrap();
let output = presenter.into_inner().unwrap();
assert_eq!(output, b"??");
}
#[test]
fn wide_grapheme_tail_repair_does_not_blank_unrelated_following_cells() {
let mut presenter = test_presenter();
let mut pool = GraphemePool::new();
let gid = pool.intern("XYZ", 3);
let mut buffer = Buffer::new(8, 1);
buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
buffer.set_raw(1, 0, Cell::from_char('a'));
buffer.set_raw(2, 0, Cell::from_char('b'));
buffer.set_raw(3, 0, Cell::from_char('c'));
let old = Buffer::new(8, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, Some(&pool), None)
.unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
let visible = sanitize(output_str.as_ref());
assert!(
visible.contains("XYZabc"),
"width-3 grapheme repair must not erase following cells: {:?}",
visible
);
}
#[test]
fn continuation_cell_cursor_x_none() {
let mut presenter = test_presenter();
presenter.cursor_x = None;
presenter.cursor_y = Some(0);
let cell = Cell::CONTINUATION;
presenter.emit_cell(5, &cell, None, None).unwrap();
let output = presenter.into_inner().unwrap();
assert!(
output.contains(&b' '),
"Should emit a space for continuation with unknown cursor_x"
);
}
#[test]
fn continuation_cell_cursor_already_past() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(10);
presenter.cursor_y = Some(0);
let cell = Cell::CONTINUATION;
presenter.emit_cell(5, &cell, None, None).unwrap();
let output = presenter.into_inner().unwrap();
assert!(
output.is_empty(),
"Should skip continuation when cursor is past it"
);
}
#[test]
fn clear_line_positions_cursor_and_erases() {
let mut presenter = test_presenter();
presenter.clear_line(5).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\x1b[2K"),
"Should contain erase line sequence"
);
}
#[test]
fn into_inner_returns_accumulated_output() {
let mut presenter = test_presenter();
presenter.position_cursor(0, 0).unwrap();
let inner = presenter.into_inner().unwrap();
assert!(!inner.is_empty(), "into_inner should return buffered data");
}
#[test]
fn move_cursor_optimal_same_row_forward_large() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(0);
presenter.cursor_y = Some(0);
presenter.move_cursor_optimal(100, 0).unwrap();
let output = presenter.into_inner().unwrap();
let cuf = cost_model::cuf_cost(100);
let cha = cost_model::cha_cost(100);
let cup = cost_model::cup_cost(0, 100);
let cheapest = cuf.min(cha).min(cup);
assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
}
#[test]
fn move_cursor_optimal_same_row_backward_to_zero() {
let mut presenter = test_presenter();
presenter.cursor_x = Some(50);
presenter.cursor_y = Some(0);
presenter.move_cursor_optimal(0, 0).unwrap();
let output = presenter.into_inner().unwrap();
let mut expected = Vec::new();
ansi::cha(&mut expected, 0).unwrap();
assert_eq!(output, expected, "Should use CHA for backward to col 0");
}
#[test]
fn move_cursor_optimal_unknown_cursor_uses_cup() {
let mut presenter = test_presenter();
presenter.move_cursor_optimal(10, 5).unwrap();
let output = presenter.into_inner().unwrap();
let mut expected = Vec::new();
ansi::cup(&mut expected, 5, 10).unwrap();
assert_eq!(output, expected, "Should use CUP when cursor is unknown");
}
#[test]
fn sync_wrap_order_begin_content_reset_end() {
let mut presenter = test_presenter_with_sync();
let mut buffer = Buffer::new(3, 1);
buffer.set_raw(0, 0, Cell::from_char('Z'));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let sync_begin_pos = output
.windows(ansi::SYNC_BEGIN.len())
.position(|w| w == ansi::SYNC_BEGIN)
.expect("sync begin missing");
let z_pos = output
.iter()
.position(|&b| b == b'Z')
.expect("character Z missing");
let reset_pos = output
.windows(b"\x1b[0m".len())
.rposition(|w| w == b"\x1b[0m")
.expect("SGR reset missing");
let sync_end_pos = output
.windows(ansi::SYNC_END.len())
.rposition(|w| w == ansi::SYNC_END)
.expect("sync end missing");
assert!(sync_begin_pos < z_pos, "sync begin before content");
assert!(z_pos < reset_pos, "content before reset");
assert!(reset_pos < sync_end_pos, "reset before sync end");
}
#[test]
fn style_none_after_each_frame() {
let mut presenter = test_presenter();
let fg = PackedRgba::rgb(255, 128, 64);
for _ in 0..5 {
let mut buffer = Buffer::new(3, 1);
buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
assert!(
presenter.current_style.is_none(),
"Style should be None after frame end"
);
assert!(
presenter.current_link.is_none(),
"Link should be None after frame end"
);
}
}
#[test]
fn link_closed_at_frame_end_even_if_all_cells_linked() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(3, 1);
let mut links = LinkRegistry::new();
let link_id = links.register("https://all-linked.test");
for x in 0..3 {
buffer.set_raw(
x,
0,
Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
);
}
let old = Buffer::new(3, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter
.present_with_pool(&buffer, &diff, None, Some(&links))
.unwrap();
assert!(
presenter.current_link.is_none(),
"Link must be closed at frame end"
);
}
#[test]
fn present_stats_empty_diff() {
let mut presenter = test_presenter();
let buffer = Buffer::new(10, 10);
let diff = BufferDiff::new();
let stats = presenter.present(&buffer, &diff).unwrap();
assert_eq!(stats.cells_changed, 0);
assert_eq!(stats.run_count, 0);
assert!(stats.bytes_emitted > 0);
}
#[test]
fn present_stats_full_row() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(10, 1);
for x in 0..10 {
buffer.set_raw(x, 0, Cell::from_char('A'));
}
let old = Buffer::new(10, 1);
let diff = BufferDiff::compute(&old, &buffer);
let stats = presenter.present(&buffer, &diff).unwrap();
assert_eq!(stats.cells_changed, 10);
assert!(stats.run_count >= 1);
assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
}
#[test]
fn capabilities_accessor() {
let mut caps = TerminalCapabilities::basic();
caps.sync_output = true;
let presenter = Presenter::new(Vec::<u8>::new(), caps);
assert!(presenter.capabilities().sync_output);
}
#[test]
fn flush_succeeds_on_empty_presenter() {
let mut presenter = test_presenter();
presenter.flush().unwrap();
let output = get_output(presenter);
assert!(output.is_empty());
}
#[test]
fn row_plan_total_cost_matches_dp() {
let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
let plan = cost_model::plan_row(&runs, None, None);
assert!(plan.total_cost() > 0);
}
#[test]
fn sgr_delta_hot_path_only_fg_change() {
let mut presenter = test_presenter();
let old = CellStyle {
fg: PackedRgba::rgb(255, 0, 0),
bg: PackedRgba::rgb(0, 0, 0),
attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
};
let new = CellStyle {
fg: PackedRgba::rgb(0, 255, 0),
bg: PackedRgba::rgb(0, 0, 0),
attrs: StyleFlags::BOLD | StyleFlags::ITALIC, };
presenter.current_style = Some(old);
presenter.emit_style_delta(old, new).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
assert!(
!output_str.contains("\x1b[0m"),
"No reset needed for color-only change"
);
assert!(
!output_str.contains("\x1b[1m"),
"Bold should not be re-emitted"
);
}
#[test]
fn sgr_delta_hot_path_both_colors_change() {
let mut presenter = test_presenter();
let old = CellStyle {
fg: PackedRgba::rgb(1, 2, 3),
bg: PackedRgba::rgb(4, 5, 6),
attrs: StyleFlags::UNDERLINE,
};
let new = CellStyle {
fg: PackedRgba::rgb(7, 8, 9),
bg: PackedRgba::rgb(10, 11, 12),
attrs: StyleFlags::UNDERLINE, };
presenter.current_style = Some(old);
presenter.emit_style_delta(old, new).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
}
#[test]
fn emit_style_full_default_is_just_reset() {
let mut presenter = test_presenter();
let default_style = CellStyle::default();
presenter.emit_style_full(default_style).unwrap();
let output = presenter.into_inner().unwrap();
assert_eq!(output, b"\x1b[0m");
}
#[test]
fn emit_style_full_with_all_properties() {
let mut presenter = test_presenter();
let style = CellStyle {
fg: PackedRgba::rgb(10, 20, 30),
bg: PackedRgba::rgb(40, 50, 60),
attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
};
presenter.emit_style_full(style).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("\x1b[0m"), "Should start with reset");
assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
}
#[test]
fn present_multiple_rows_different_strategies() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(80, 5);
for x in (0..20).step_by(2) {
buffer.set_raw(x, 0, Cell::from_char('D'));
}
buffer.set_raw(0, 2, Cell::from_char('L'));
buffer.set_raw(79, 2, Cell::from_char('R'));
buffer.set_raw(40, 4, Cell::from_char('M'));
let old = Buffer::new(80, 5);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains('D'));
assert!(output_str.contains('L'));
assert!(output_str.contains('R'));
assert!(output_str.contains('M'));
}
#[test]
fn zero_width_chars_replaced_with_placeholder() {
let mut presenter = test_presenter();
let mut buffer = Buffer::new(5, 1);
let zw_char = '\u{0301}';
assert_eq!(Cell::from_char(zw_char).content.width(), 0);
buffer.set_raw(0, 0, Cell::from_char(zw_char));
buffer.set_raw(1, 0, Cell::from_char('A'));
let old = Buffer::new(5, 1);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = get_output(presenter);
let output_str = String::from_utf8_lossy(&output);
assert!(
output_str.contains("\u{FFFD}"),
"Expected replacement character for zero-width content, got: {:?}",
output_str
);
assert!(
!output_str.contains(zw_char),
"Should not contain raw zero-width char"
);
assert!(
output_str.contains('A'),
"Should contain subsequent character 'A'"
);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::cell::{Cell, PackedRgba};
use crate::diff::BufferDiff;
use crate::terminal_model::TerminalModel;
use proptest::prelude::*;
fn test_presenter() -> Presenter<Vec<u8>> {
let caps = TerminalCapabilities::basic();
Presenter::new(Vec::new(), caps)
}
proptest! {
#[test]
fn presenter_roundtrip_characters(
width in 5u16..40,
height in 3u16..20,
num_chars in 1usize..50, ) {
let mut buffer = Buffer::new(width, height);
let mut changed_positions = std::collections::HashSet::new();
for i in 0..num_chars {
let x = (i * 7 + 3) as u16 % width;
let y = (i * 11 + 5) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
buffer.set_raw(x, y, Cell::from_char(ch));
changed_positions.insert((x, y));
}
let mut presenter = test_presenter();
let old = Buffer::new(width, height);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
let mut model = TerminalModel::new(width as usize, height as usize);
model.process(&output);
for &(x, y) in &changed_positions {
let buf_cell = buffer.get_unchecked(x, y);
let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
let mut expected_buf = [0u8; 4];
let expected_str = expected_ch.encode_utf8(&mut expected_buf);
if let Some(model_cell) = model.cell(x as usize, y as usize) {
prop_assert_eq!(
model_cell.text.as_str(),
expected_str,
"Character mismatch at ({}, {})", x, y
);
}
}
}
#[test]
fn style_reset_after_present(
width in 5u16..30,
height in 3u16..15,
num_styled in 1usize..20,
) {
let mut buffer = Buffer::new(width, height);
for i in 0..num_styled {
let x = (i * 7) as u16 % width;
let y = (i * 11) as u16 % height;
let fg = PackedRgba::rgb(
((i * 31) % 256) as u8,
((i * 47) % 256) as u8,
((i * 71) % 256) as u8,
);
buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
}
let mut presenter = test_presenter();
let old = Buffer::new(width, height);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
let output_str = String::from_utf8_lossy(&output);
prop_assert!(
output_str.contains("\x1b[0m"),
"Output should contain SGR reset"
);
}
#[test]
fn empty_diff_minimal_output(
width in 5u16..50,
height in 3u16..25,
) {
let buffer = Buffer::new(width, height);
let diff = BufferDiff::new();
let mut presenter = test_presenter();
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
prop_assert!(output.len() < 50, "Empty diff should have minimal output");
}
#[test]
fn diff_size_bounds(
width in 5u16..30,
height in 3u16..15,
) {
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for y in 0..height {
for x in 0..width {
new.set_raw(x, y, Cell::from_char('X'));
}
}
let diff = BufferDiff::compute(&old, &new);
prop_assert_eq!(
diff.len(),
(width as usize) * (height as usize),
"Full change should have all cells in diff"
);
}
#[test]
fn presenter_cursor_consistency(
width in 10u16..40,
height in 5u16..20,
num_runs in 1usize..10,
) {
let mut buffer = Buffer::new(width, height);
for i in 0..num_runs {
let start_x = (i * 5) as u16 % (width - 5);
let y = i as u16 % height;
for x in start_x..(start_x + 3) {
buffer.set_raw(x, y, Cell::from_char('A'));
}
}
let mut presenter = test_presenter();
let old = Buffer::new(width, height);
for _ in 0..3 {
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
}
let output = presenter.into_inner().unwrap();
prop_assert!(!output.is_empty(), "Should produce some output");
}
#[test]
fn sgr_delta_transition_equivalence(
width in 5u16..20,
height in 3u16..10,
num_styled in 2usize..15,
) {
let mut buffer = Buffer::new(width, height);
let mut expected: std::collections::HashMap<(u16, u16), char> =
std::collections::HashMap::new();
for i in 0..num_styled {
let x = (i * 3 + 1) as u16 % width;
let y = (i * 5 + 2) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
let fg = PackedRgba::rgb(
((i * 73) % 256) as u8,
((i * 137) % 256) as u8,
((i * 41) % 256) as u8,
);
let bg = if i % 3 == 0 {
PackedRgba::rgb(
((i * 29) % 256) as u8,
((i * 53) % 256) as u8,
((i * 97) % 256) as u8,
)
} else {
PackedRgba::TRANSPARENT
};
let flags_bits = ((i * 37) % 256) as u8;
let flags = StyleFlags::from_bits_truncate(flags_bits);
let cell = Cell::from_char(ch)
.with_fg(fg)
.with_bg(bg)
.with_attrs(CellAttrs::new(flags, 0));
buffer.set_raw(x, y, cell);
expected.insert((x, y), ch);
}
let mut presenter = test_presenter();
let old = Buffer::new(width, height);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
let mut model = TerminalModel::new(width as usize, height as usize);
model.process(&output);
for (&(x, y), &ch) in &expected {
let mut buf = [0u8; 4];
let expected_str = ch.encode_utf8(&mut buf);
if let Some(model_cell) = model.cell(x as usize, y as usize) {
prop_assert_eq!(
model_cell.text.as_str(),
expected_str,
"Character mismatch at ({}, {}) with delta engine", x, y
);
}
}
}
#[test]
fn dp_emit_equivalence(
width in 20u16..60,
height in 5u16..15,
num_changes in 5usize..30,
) {
let mut buffer = Buffer::new(width, height);
let mut expected: std::collections::HashMap<(u16, u16), char> =
std::collections::HashMap::new();
for i in 0..num_changes {
let x = (i * 7 + 3) as u16 % width;
let y = (i * 3 + 1) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
buffer.set_raw(x, y, Cell::from_char(ch));
expected.insert((x, y), ch);
}
let mut presenter = test_presenter();
let old = Buffer::new(width, height);
let diff = BufferDiff::compute(&old, &buffer);
presenter.present(&buffer, &diff).unwrap();
let output = presenter.into_inner().unwrap();
let mut model = TerminalModel::new(width as usize, height as usize);
model.process(&output);
for (&(x, y), &ch) in &expected {
let mut buf = [0u8; 4];
let expected_str = ch.encode_utf8(&mut buf);
if let Some(model_cell) = model.cell(x as usize, y as usize) {
prop_assert_eq!(
model_cell.text.as_str(),
expected_str,
"DP cost model: character mismatch at ({}, {})", x, y
);
}
}
}
}
}