Skip to main content

ftui_render/
presenter.rs

1#![forbid(unsafe_code)]
2
3//! Presenter: state-tracked ANSI emission.
4//!
5//! The Presenter transforms buffer diffs into minimal terminal output by tracking
6//! the current terminal state and only emitting sequences when changes are needed.
7//!
8//! # Design Principles
9//!
10//! - **State tracking**: Track current style, link, and cursor to avoid redundant output
11//! - **Run grouping**: Use ChangeRuns to minimize cursor positioning
12//! - **Single write**: Buffer all output and flush once per frame
13//! - **Synchronized output**: Use DEC 2026 to prevent flicker on supported terminals
14//!
15//! # Usage
16//!
17//! ```ignore
18//! use ftui_render::presenter::Presenter;
19//! use ftui_render::buffer::Buffer;
20//! use ftui_render::diff::BufferDiff;
21//! use ftui_core::terminal_capabilities::TerminalCapabilities;
22//!
23//! let caps = TerminalCapabilities::detect();
24//! let mut presenter = Presenter::new(std::io::stdout(), caps);
25//!
26//! let mut current = Buffer::new(80, 24);
27//! let mut next = Buffer::new(80, 24);
28//! // ... render widgets into `next` ...
29//!
30//! let diff = BufferDiff::compute(&current, &next);
31//! presenter.present(&next, &diff)?;
32//! std::mem::swap(&mut current, &mut next);
33//! ```
34
35use std::io::{self, BufWriter, Write};
36
37use crate::ansi::{self, EraseLineMode};
38use crate::buffer::Buffer;
39use crate::cell::{Cell, CellAttrs, GraphemeId, PackedRgba, StyleFlags};
40use crate::char_width;
41use crate::counting_writer::{CountingWriter, PresentStats, StatsCollector};
42use crate::diff::{BufferDiff, ChangeRun};
43use crate::display_width;
44use crate::grapheme_pool::GraphemePool;
45use crate::link_registry::LinkRegistry;
46use crate::sanitize::sanitize;
47
48pub use ftui_core::terminal_capabilities::TerminalCapabilities;
49
50/// Size of the internal write buffer (64KB).
51const BUFFER_CAPACITY: usize = 64 * 1024;
52/// Maximum hyperlink URL length allowed in OSC 8 payloads.
53const MAX_SAFE_HYPERLINK_URL_BYTES: usize = 4096;
54
55#[inline]
56fn is_safe_hyperlink_url(url: &str) -> bool {
57    url.len() <= MAX_SAFE_HYPERLINK_URL_BYTES && !url.chars().any(char::is_control)
58}
59
60// =============================================================================
61// DP Cost Model for ANSI Emission
62// =============================================================================
63
64/// Byte-cost estimates for ANSI cursor and output operations.
65///
66/// The cost model computes the cheapest emission plan for each row by comparing
67/// sparse-run emission (CUP per run) against merged write-through (one CUP,
68/// fill gaps with buffer content). This is a shortest-path problem on a small
69/// state graph per row.
70mod cost_model {
71    use smallvec::SmallVec;
72
73    use super::ChangeRun;
74
75    /// Number of decimal digits needed to represent `n`.
76    #[inline]
77    fn digit_count(n: u16) -> usize {
78        // Terminal coordinates and relative deltas are overwhelmingly small.
79        // Check the common low ranges first so the planner pays fewer compares
80        // on its hottest cost-model path.
81        if n < 10 {
82            1
83        } else if n < 100 {
84            2
85        } else if n < 1000 {
86            3
87        } else if n < 10000 {
88            4
89        } else {
90            5
91        }
92    }
93
94    /// Byte cost of CUP: `\x1b[{row+1};{col+1}H`
95    #[inline]
96    pub fn cup_cost(row: u16, col: u16) -> usize {
97        // CSI (2) + row digits + ';' (1) + col digits + 'H' (1)
98        4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
99    }
100
101    /// Byte cost of CHA (column-only): `\x1b[{col+1}G`
102    #[inline]
103    pub fn cha_cost(col: u16) -> usize {
104        // CSI (2) + col digits + 'G' (1)
105        3 + digit_count(col.saturating_add(1))
106    }
107
108    /// Byte cost of CUF (cursor forward): `\x1b[{n}C` or `\x1b[C` for n=1.
109    #[inline]
110    pub fn cuf_cost(n: u16) -> usize {
111        match n {
112            0 => 0,
113            1 => 3, // \x1b[C
114            _ => 3 + digit_count(n),
115        }
116    }
117
118    /// Byte cost of CUB (cursor back): `\x1b[{n}D` or `\x1b[D` for n=1.
119    #[inline]
120    pub fn cub_cost(n: u16) -> usize {
121        match n {
122            0 => 0,
123            1 => 3, // \x1b[D
124            _ => 3 + digit_count(n),
125        }
126    }
127
128    /// Cheapest cursor movement cost from (from_x, from_y) to (to_x, to_y).
129    /// Returns 0 if already at the target position.
130    pub fn cheapest_move_cost(
131        from_x: Option<u16>,
132        from_y: Option<u16>,
133        to_x: u16,
134        to_y: u16,
135    ) -> usize {
136        // Already at target?
137        if from_x == Some(to_x) && from_y == Some(to_y) {
138            return 0;
139        }
140
141        match (from_x, from_y) {
142            (Some(fx), Some(fy)) if fy == to_y => {
143                // On the same row, CHA strictly dominates CUP because CUP always
144                // pays the extra row field (`CSI row;col H`) while CHA only
145                // updates the column (`CSI col G`). Therefore the optimal move
146                // on a shared row is always CHA or a relative move.
147                let cha = cha_cost(to_x);
148                if to_x > fx {
149                    let cuf = cuf_cost(to_x - fx);
150                    cha.min(cuf)
151                } else if to_x < fx {
152                    let cub = cub_cost(fx - to_x);
153                    cha.min(cub)
154                } else {
155                    0
156                }
157            }
158            _ => cup_cost(to_y, to_x),
159        }
160    }
161
162    /// Planned contiguous span to emit on a single row.
163    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
164    pub struct RowSpan {
165        /// Row index.
166        pub y: u16,
167        /// Start column (inclusive).
168        pub x0: u16,
169        /// End column (inclusive).
170        pub x1: u16,
171    }
172
173    /// Row emission plan (possibly multiple merged spans).
174    ///
175    /// Uses SmallVec<[RowSpan; 8]> to avoid heap allocation for the common case
176    /// of sparse rows with several isolated runs. RowSpan is 6 bytes, so
177    /// 8 spans = 48 bytes inline.
178    #[derive(Debug, Clone, PartialEq, Eq)]
179    pub struct RowPlan {
180        spans: SmallVec<[RowSpan; 8]>,
181        total_cost: usize,
182    }
183
184    impl RowPlan {
185        #[inline]
186        #[must_use]
187        pub fn spans(&self) -> &[RowSpan] {
188            &self.spans
189        }
190
191        /// Total cost of this row plan (for strategy selection).
192        #[inline]
193        #[allow(dead_code)] // API for future diff strategy integration
194        pub fn total_cost(&self) -> usize {
195            self.total_cost
196        }
197    }
198
199    /// Reusable scratch buffers for `plan_row_reuse`, avoiding per-call heap
200    /// allocations. Store one instance in `Presenter` and pass it into every
201    /// `plan_row_reuse` call so that the buffers are reused across rows and
202    /// frames.
203    #[derive(Debug, Default)]
204    pub struct RowPlanScratch {
205        prefix_cells: Vec<usize>,
206        dp: Vec<usize>,
207        prev: Vec<usize>,
208    }
209
210    /// Compute the optimal emission plan for a set of runs on the same row.
211    ///
212    /// This is a shortest-path / DP partitioning problem over contiguous run
213    /// segments. Each segment may be emitted as a merged span (writing through
214    /// gaps). Single-run segments correspond to sparse emission.
215    ///
216    /// Gap cells cost ~1 byte each (character content), plus potential style
217    /// overhead estimated at 1 byte per gap cell (conservative).
218    #[allow(dead_code)]
219    pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
220        let mut scratch = RowPlanScratch::default();
221        plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
222    }
223
224    /// Like `plan_row` but reuses heap allocations via the provided scratch
225    /// buffers, eliminating per-call allocations in the hot path.
226    pub fn plan_row_reuse(
227        row_runs: &[ChangeRun],
228        prev_x: Option<u16>,
229        prev_y: Option<u16>,
230        scratch: &mut RowPlanScratch,
231    ) -> RowPlan {
232        if row_runs.is_empty() {
233            return RowPlan {
234                spans: SmallVec::new(),
235                total_cost: 0,
236            };
237        }
238
239        let row_y = row_runs[0].y;
240        let run_count = row_runs.len();
241
242        if run_count == 1 {
243            let run = row_runs[0];
244            let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
245            spans.push(RowSpan {
246                y: row_y,
247                x0: run.x0,
248                x1: run.x1,
249            });
250            return RowPlan {
251                spans,
252                total_cost: cheapest_move_cost(prev_x, prev_y, run.x0, row_y)
253                    .saturating_add(run.len()),
254            };
255        }
256
257        // Resize scratch buffers (no-op if already large enough).
258        scratch.prefix_cells.clear();
259        scratch.prefix_cells.resize(run_count + 1, 0);
260        scratch.dp.clear();
261        scratch.dp.resize(run_count, usize::MAX);
262        scratch.prev.clear();
263        scratch.prev.resize(run_count, 0);
264
265        // Prefix sum of changed cell counts for O(1) segment cost.
266        for (i, run) in row_runs.iter().enumerate() {
267            scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len();
268        }
269
270        // DP over segments: dp[j] is min cost to emit runs[0..=j].
271        for j in 0..run_count {
272            let mut best_cost = usize::MAX;
273            let mut best_i = j;
274
275            // Optimization: iterate backwards and break if the gap becomes too large.
276            // The gap cost grows linearly, while cursor movement cost is bounded (~10-15 bytes).
277            // Once the gap exceeds ~20 cells, merging is strictly worse than moving.
278            // We use 32 as a conservative safety bound.
279            for i in (0..=j).rev() {
280                let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
281                let total_cells =
282                    (row_runs[j].x1 as usize).saturating_sub(row_runs[i].x0 as usize) + 1;
283                let gap_cells = total_cells.saturating_sub(changed_cells);
284
285                if gap_cells > 32 {
286                    break;
287                }
288
289                let from_x = if i == 0 {
290                    prev_x
291                } else {
292                    Some(row_runs[i - 1].x1.saturating_add(1))
293                };
294                let from_y = if i == 0 { prev_y } else { Some(row_y) };
295
296                let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
297                let gap_overhead = gap_cells * 2; // conservative: char + style amortized
298                let emit_cost = changed_cells + gap_overhead;
299
300                let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
301                let cost = prev_cost
302                    .saturating_add(move_cost)
303                    .saturating_add(emit_cost);
304
305                if cost < best_cost {
306                    best_cost = cost;
307                    best_i = i;
308                }
309            }
310
311            scratch.dp[j] = best_cost;
312            scratch.prev[j] = best_i;
313        }
314
315        // Reconstruct spans from back to front.
316        let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
317        let mut j = run_count - 1;
318        loop {
319            let i = scratch.prev[j];
320            spans.push(RowSpan {
321                y: row_y,
322                x0: row_runs[i].x0,
323                x1: row_runs[j].x1,
324            });
325            if i == 0 {
326                break;
327            }
328            j = i - 1;
329        }
330        spans.reverse();
331
332        RowPlan {
333            spans,
334            total_cost: scratch.dp[run_count - 1],
335        }
336    }
337}
338
339/// Cached style state for comparison.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341struct CellStyle {
342    fg: PackedRgba,
343    bg: PackedRgba,
344    attrs: StyleFlags,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum PreparedContent {
349    Empty,
350    Char(char),
351    Grapheme(GraphemeId),
352}
353
354impl PreparedContent {
355    #[inline]
356    fn from_cell(cell: &Cell) -> (Self, usize) {
357        let content = cell.content;
358        if let Some(grapheme_id) = content.grapheme_id() {
359            (Self::Grapheme(grapheme_id), content.width())
360        } else if let Some(ch) = content.as_char() {
361            let width = if ch.is_ascii() {
362                match ch {
363                    '\t' | '\n' | '\r' => 1,
364                    ' '..='~' => 1,
365                    _ => 0,
366                }
367            } else {
368                char_width(ch)
369            };
370            (Self::Char(ch), width)
371        } else {
372            (Self::Empty, 0)
373        }
374    }
375}
376
377impl Default for CellStyle {
378    fn default() -> Self {
379        Self {
380            fg: PackedRgba::TRANSPARENT,
381            bg: PackedRgba::TRANSPARENT,
382            attrs: StyleFlags::empty(),
383        }
384    }
385}
386impl CellStyle {
387    fn from_cell(cell: &Cell) -> Self {
388        Self {
389            fg: cell.fg,
390            bg: cell.bg,
391            attrs: cell.attrs.flags(),
392        }
393    }
394}
395
396/// State-tracked ANSI presenter.
397///
398/// Transforms buffer diffs into minimal terminal output by tracking
399/// the current terminal state and only emitting necessary escape sequences.
400pub struct Presenter<W: Write> {
401    /// Buffered writer for efficient output, with byte counting.
402    writer: CountingWriter<BufWriter<W>>,
403    /// Current style state (None = unknown/reset).
404    current_style: Option<CellStyle>,
405    /// Current hyperlink ID (None = no link).
406    current_link: Option<u32>,
407    /// Current cursor X position (0-indexed). None = unknown.
408    cursor_x: Option<u16>,
409    /// Current cursor Y position (0-indexed). None = unknown.
410    cursor_y: Option<u16>,
411    /// Viewport Y offset (added to all row coordinates).
412    viewport_offset_y: u16,
413    /// Terminal capabilities for conditional output.
414    capabilities: TerminalCapabilities,
415    /// Cached hyperlink policy for the lifetime of this presenter.
416    hyperlinks_enabled: bool,
417    /// Reusable scratch buffers for the cost-model DP, avoiding per-row
418    /// heap allocations in the hot presentation path.
419    plan_scratch: cost_model::RowPlanScratch,
420    /// Reusable buffer for change runs, avoiding per-frame allocation.
421    runs_buf: Vec<ChangeRun>,
422}
423
424impl<W: Write> Presenter<W> {
425    /// Create a new presenter with the given writer and capabilities.
426    pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
427        Self {
428            writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
429            current_style: None,
430            current_link: None,
431            cursor_x: None,
432            cursor_y: None,
433            viewport_offset_y: 0,
434            hyperlinks_enabled: capabilities.use_hyperlinks(),
435            capabilities,
436            plan_scratch: cost_model::RowPlanScratch::default(),
437            runs_buf: Vec::new(),
438        }
439    }
440
441    /// Get mutable access to the innermost writer (`W`).
442    ///
443    /// This allows the caller to write raw data (e.g. logs) bypassing the
444    /// presenter's state tracking. Note that this may invalidate cursor
445    /// tracking if the raw writes move the cursor.
446    pub fn writer_mut(&mut self) -> &mut W {
447        self.writer.inner_mut().get_mut()
448    }
449
450    /// Get mutable access to the full counting writer stack.
451    ///
452    /// This exposes `CountingWriter<BufWriter<W>>` so callers can access
453    /// byte counting, buffered flush, etc.
454    pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
455        &mut self.writer
456    }
457
458    /// Set the viewport Y offset.
459    ///
460    /// All subsequent render operations will add this offset to row coordinates.
461    /// Useful for inline mode where the UI starts at a specific row.
462    pub fn set_viewport_offset_y(&mut self, offset: u16) {
463        self.viewport_offset_y = offset;
464    }
465
466    /// Get the terminal capabilities.
467    #[inline]
468    pub fn capabilities(&self) -> &TerminalCapabilities {
469        &self.capabilities
470    }
471
472    /// Present a frame using the given buffer and diff.
473    ///
474    /// This is the main entry point for rendering. It:
475    /// 1. Begins synchronized output (if supported)
476    /// 2. Emits changes based on the diff
477    /// 3. Resets style and closes links
478    /// 4. Ends synchronized output
479    /// 5. Flushes all buffered output
480    pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
481        self.present_with_pool(buffer, diff, None, None)
482    }
483
484    /// Present a frame with grapheme pool and link registry.
485    pub fn present_with_pool(
486        &mut self,
487        buffer: &Buffer,
488        diff: &BufferDiff,
489        pool: Option<&GraphemePool>,
490        links: Option<&LinkRegistry>,
491    ) -> io::Result<PresentStats> {
492        let bracket_supported = self.capabilities.use_sync_output();
493
494        #[cfg(feature = "tracing")]
495        let _span = tracing::info_span!(
496            "present",
497            width = buffer.width(),
498            height = buffer.height(),
499            changes = diff.len()
500        );
501        #[cfg(feature = "tracing")]
502        let _guard = _span.enter();
503
504        #[cfg(feature = "tracing")]
505        let fallback_used = !bracket_supported;
506        #[cfg(feature = "tracing")]
507        let _sync_span = tracing::info_span!(
508            "render.sync_bracket",
509            bracket_supported,
510            fallback_used,
511            frame_bytes = tracing::field::Empty,
512        );
513        #[cfg(feature = "tracing")]
514        let _sync_guard = _sync_span.enter();
515
516        // Calculate runs upfront for stats, reusing the runs buffer.
517        diff.runs_into(&mut self.runs_buf);
518        let run_count = self.runs_buf.len();
519        let cells_changed = diff.len();
520
521        // Start stats collection
522        self.writer.reset_counter();
523        let collector = StatsCollector::start(cells_changed, run_count);
524
525        // Begin synchronized output to prevent flicker.
526        // When sync brackets are supported, use DEC 2026 for atomic frame display.
527        // Otherwise, fall back to cursor-hiding to reduce visual flicker.
528        if bracket_supported {
529            if let Err(err) = ansi::sync_begin(&mut self.writer) {
530                // Begin writes can fail after partial bytes; best-effort close
531                // avoids leaving the terminal parser in sync-output mode.
532                let _ = ansi::sync_end(&mut self.writer);
533                let _ = self.writer.flush();
534                return Err(err);
535            }
536        } else {
537            #[cfg(feature = "tracing")]
538            tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
539            ansi::cursor_hide(&mut self.writer)?;
540        }
541
542        // Emit diff using run grouping for efficiency.
543        let emit_result = self.emit_diff_runs(buffer, pool, links);
544
545        // Always attempt to restore terminal state, even if diff emission failed.
546        let frame_end_result = self.finish_frame();
547
548        let bracket_end_result = if bracket_supported {
549            ansi::sync_end(&mut self.writer)
550        } else {
551            ansi::cursor_show(&mut self.writer)
552        };
553
554        let flush_result = self.writer.flush();
555
556        // Prioritize terminal-state restoration errors over emission errors:
557        // if cleanup fails (reset/link-close/sync-end/flush), callers need that
558        // failure surfaced immediately to avoid leaving the terminal wedged.
559        let cleanup_error = frame_end_result
560            .err()
561            .or_else(|| bracket_end_result.err())
562            .or_else(|| flush_result.err());
563        if let Some(err) = cleanup_error {
564            return Err(err);
565        }
566        emit_result?;
567
568        let stats = collector.finish(self.writer.bytes_written());
569
570        #[cfg(feature = "tracing")]
571        {
572            _sync_span.record("frame_bytes", stats.bytes_emitted);
573            stats.log();
574            tracing::trace!("frame presented");
575        }
576
577        Ok(stats)
578    }
579
580    /// Emit diff runs using the cost model and internal buffers.
581    ///
582    /// This allows advanced callers (like TerminalWriter) to drive the emission
583    /// phase manually while still benefiting from the optimization logic.
584    /// The caller must populate `self.runs_buf` before calling this (e.g. via `diff.runs_into`).
585    pub fn emit_diff_runs(
586        &mut self,
587        buffer: &Buffer,
588        pool: Option<&GraphemePool>,
589        links: Option<&LinkRegistry>,
590    ) -> io::Result<()> {
591        #[cfg(feature = "tracing")]
592        let _span = tracing::debug_span!("emit_diff");
593        #[cfg(feature = "tracing")]
594        let _guard = _span.enter();
595
596        #[cfg(feature = "tracing")]
597        tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
598
599        // Group runs by row and apply cost model per row
600        let mut i = 0;
601        while i < self.runs_buf.len() {
602            let row_y = self.runs_buf[i].y;
603
604            // Collect all runs on this row
605            let row_start = i;
606            while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
607                i += 1;
608            }
609            let row_runs = &self.runs_buf[row_start..i];
610
611            let plan = cost_model::plan_row_reuse(
612                row_runs,
613                self.cursor_x,
614                self.cursor_y,
615                &mut self.plan_scratch,
616            );
617
618            #[cfg(feature = "tracing")]
619            tracing::trace!(
620                row = row_y,
621                spans = plan.spans().len(),
622                cost = plan.total_cost(),
623                "row plan"
624            );
625
626            let row = buffer.row_cells(row_y);
627            for span in plan.spans() {
628                self.move_cursor_optimal(span.x0, span.y)?;
629                // Hot path: avoid recomputing `y * width + x` for every cell.
630                let start = span.x0 as usize;
631                let end = span.x1 as usize;
632                debug_assert!(start <= end);
633                debug_assert!(end < row.len());
634                let mut idx = start;
635                while idx <= end {
636                    let cell = &row[idx];
637                    self.emit_cell(idx as u16, cell, pool, links)?;
638
639                    // Repair invalid wide-char tails.
640                    //
641                    // Direct wide chars are always safe to repair because they can
642                    // only span a small, fixed number of cells. Grapheme-pool refs
643                    // may encode much wider payloads (up to 15 cells), so blindly
644                    // repairing all missing tails can erase unrelated content later in
645                    // the row. We only extend the repair to width-2 grapheme refs,
646                    // where clearing a single orphan tail cell is still bounded.
647                    let mut advance = 1usize;
648                    let width = cell.content.width();
649                    let should_repair_invalid_tail = cell.content.as_char().is_some()
650                        || (cell.content.is_grapheme() && width == 2);
651                    if width > 1 && should_repair_invalid_tail {
652                        for off in 1..width {
653                            let tx = idx + off;
654                            if tx >= row.len() {
655                                break;
656                            }
657                            if row[tx].is_continuation() {
658                                if tx <= end {
659                                    advance = advance.max(off + 1);
660                                }
661                                continue;
662                            }
663                            // Orphan detected: repair with a space.
664                            self.move_cursor_optimal(tx as u16, span.y)?;
665                            self.emit_orphan_continuation_space(tx as u16, links)?;
666                            if tx <= end {
667                                advance = advance.max(off + 1);
668                            }
669                        }
670                    }
671
672                    idx = idx.saturating_add(advance);
673                }
674            }
675        }
676        Ok(())
677    }
678
679    /// Prepare the runs buffer from a diff.
680    ///
681    /// Helper for external callers to populate the runs buffer before calling `emit_diff_runs`.
682    pub fn prepare_runs(&mut self, diff: &BufferDiff) {
683        diff.runs_into(&mut self.runs_buf);
684    }
685
686    /// Finish a frame by restoring neutral SGR state and closing any open link.
687    ///
688    /// Callers that drive emission manually through [`emit_diff_runs`] must
689    /// invoke this before returning control to non-UI terminal output.
690    pub fn finish_frame(&mut self) -> io::Result<()> {
691        let reset_result = ansi::sgr_reset(&mut self.writer);
692        self.current_style = None;
693
694        let hyperlink_close_result = if self.current_link.is_some() {
695            let res = ansi::hyperlink_end(&mut self.writer);
696            if res.is_ok() {
697                self.current_link = None;
698            }
699            Some(res)
700        } else {
701            None
702        };
703
704        if let Some(err) = reset_result
705            .err()
706            .or_else(|| hyperlink_close_result.and_then(Result::err))
707        {
708            return Err(err);
709        }
710
711        Ok(())
712    }
713
714    /// Best-effort frame cleanup used on error and drop paths.
715    pub fn finish_frame_best_effort(&mut self) {
716        let _ = ansi::sgr_reset(&mut self.writer);
717        self.current_style = None;
718
719        if self.current_link.is_some() {
720            let _ = ansi::hyperlink_end(&mut self.writer);
721            self.current_link = None;
722        }
723    }
724
725    /// Emit a single cell.
726    fn emit_cell(
727        &mut self,
728        x: u16,
729        cell: &Cell,
730        pool: Option<&GraphemePool>,
731        links: Option<&LinkRegistry>,
732    ) -> io::Result<()> {
733        // Drift protection: Ensure cursor is synchronized before emitting content.
734        // This catches cases where the previous emission (e.g. a wide char) advanced
735        // the cursor further than the buffer index advanced (e.g. because the
736        // continuation cell was missing/overwritten in an invalid buffer state).
737        //
738        // If we detect drift, we force a re-synchronization.
739        if let Some(cx) = self.cursor_x {
740            if cx != x && !cell.is_continuation() {
741                // Re-sync. We assume cursor_y is set because we are in a run.
742                if let Some(y) = self.cursor_y {
743                    self.move_cursor_optimal(x, y)?;
744                }
745            }
746        } else {
747            // No known cursor position: must sync.
748            if let Some(y) = self.cursor_y {
749                self.move_cursor_optimal(x, y)?;
750            }
751        }
752
753        // Continuation cells are the tail cells of wide glyphs. Emitting the
754        // head glyph already advanced the terminal cursor by the full width, so
755        // we normally skip emitting these cells.
756        //
757        // If we ever start emitting at a continuation cell (e.g. a run begins
758        // mid-wide-character), we must still advance the terminal cursor by one
759        // cell to keep subsequent emissions aligned. We write a space to clear
760        // any potential garbage (orphan cleanup) rather than just skipping with CUF.
761        if cell.is_continuation() {
762            match self.cursor_x {
763                // Cursor already advanced past this cell by a previously-emitted wide head.
764                Some(cx) if cx > x => return Ok(()),
765                Some(cx) => {
766                    // Cursor is positioned at (or before) this continuation cell:
767                    // Treat as orphan and overwrite with space to ensure clean state.
768                    if cx < x
769                        && let Some(y) = self.cursor_y
770                    {
771                        self.move_cursor_optimal(x, y)?;
772                    }
773                    return self.emit_orphan_continuation_space(x, links);
774                }
775                // Defensive: move_cursor_optimal should always set cursor_x before emit_cell is called.
776                None => {
777                    if let Some(y) = self.cursor_y {
778                        self.move_cursor_optimal(x, y)?;
779                    }
780                    return self.emit_orphan_continuation_space(x, links);
781                }
782            }
783        }
784
785        // Emit style changes if needed
786        self.emit_style_changes(cell)?;
787
788        // Emit link changes if needed
789        self.emit_link_changes(cell, links)?;
790
791        let (prepared_content, raw_width) = PreparedContent::from_cell(cell);
792
793        // Calculate effective width and check for zero-width content (e.g. combining marks)
794        // stored as standalone cells. These must be replaced to maintain grid alignment.
795        let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
796
797        if is_zero_width_content {
798            // Replace with U+FFFD Replacement Character (width 1)
799            self.writer.write_all(b"\xEF\xBF\xBD")?;
800        } else {
801            // Emit normal content
802            self.emit_content(prepared_content, raw_width, pool)?;
803        }
804
805        // Update cursor position (character output advances cursor)
806        if let Some(cx) = self.cursor_x {
807            // Empty cells are emitted as spaces (width 1).
808            // Zero-width content replaced by U+FFFD is width 1.
809            let width = if cell.is_empty() || is_zero_width_content {
810                1
811            } else {
812                raw_width
813            };
814            self.cursor_x = Some(cx.saturating_add(width as u16));
815        }
816
817        Ok(())
818    }
819
820    /// Clear a continuation cell with a visually neutral blank.
821    ///
822    /// This path intentionally resets style and closes hyperlinks first so the
823    /// cleanup space cannot inherit stale state from the previous emitted cell.
824    fn emit_orphan_continuation_space(
825        &mut self,
826        x: u16,
827        links: Option<&LinkRegistry>,
828    ) -> io::Result<()> {
829        let blank = Cell::default();
830        self.emit_style_changes(&blank)?;
831        self.emit_link_changes(&blank, links)?;
832        self.writer.write_all(b" ")?;
833        self.cursor_x = Some(x.saturating_add(1));
834        Ok(())
835    }
836
837    /// Emit style changes if the cell style differs from current.
838    ///
839    /// Uses SGR delta: instead of resetting and re-applying all style properties,
840    /// we compute the minimal set of changes needed (fg delta, bg delta, attr
841    /// toggles). Falls back to reset+apply only when a full reset would be cheaper.
842    fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
843        let new_style = CellStyle::from_cell(cell);
844
845        // Check if style changed
846        if self.current_style == Some(new_style) {
847            return Ok(());
848        }
849
850        match self.current_style {
851            None => {
852                // No known style state: re-establish a full terminal style baseline.
853                self.emit_style_full(new_style)?;
854            }
855            Some(old_style) => {
856                self.emit_style_delta(old_style, new_style)?;
857            }
858        }
859
860        self.current_style = Some(new_style);
861        Ok(())
862    }
863
864    /// Full style apply (reset + set all properties). Used when previous state is unknown.
865    fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
866        ansi::sgr_reset(&mut self.writer)?;
867        if style.fg.a() > 0 {
868            ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
869        }
870        if style.bg.a() > 0 {
871            ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
872        }
873        if !style.attrs.is_empty() {
874            ansi::sgr_flags(&mut self.writer, style.attrs)?;
875        }
876        Ok(())
877    }
878
879    #[inline]
880    fn dec_len_u8(value: u8) -> u32 {
881        if value >= 100 {
882            3
883        } else if value >= 10 {
884            2
885        } else {
886            1
887        }
888    }
889
890    #[inline]
891    fn sgr_code_len(code: u8) -> u32 {
892        2 + Self::dec_len_u8(code) + 1
893    }
894
895    #[inline]
896    fn sgr_flags_len(flags: StyleFlags) -> u32 {
897        if flags.is_empty() {
898            return 0;
899        }
900        let mut count = 0u32;
901        let mut digits = 0u32;
902        for (flag, codes) in ansi::FLAG_TABLE {
903            if flags.contains(flag) {
904                count += 1;
905                digits += Self::dec_len_u8(codes.on);
906            }
907        }
908        if count == 0 {
909            return 0;
910        }
911        3 + digits + (count - 1)
912    }
913
914    #[inline]
915    fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
916        if flags.is_empty() {
917            return 0;
918        }
919        let mut len = 0u32;
920        for (flag, codes) in ansi::FLAG_TABLE {
921            if flags.contains(flag) {
922                len += Self::sgr_code_len(codes.off);
923            }
924        }
925        len
926    }
927
928    #[inline]
929    fn sgr_rgb_len(color: PackedRgba) -> u32 {
930        10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
931    }
932
933    /// Emit minimal SGR delta between old and new styles.
934    ///
935    /// Computes which properties changed and emits only those.
936    /// Falls back to reset+apply when that would produce fewer bytes.
937    fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
938        let attrs_removed = old.attrs & !new.attrs;
939        let attrs_added = new.attrs & !old.attrs;
940        let fg_changed = old.fg != new.fg;
941        let bg_changed = old.bg != new.bg;
942
943        // Hot path for VFX-style workloads: attributes are unchanged and only
944        // colors vary. In this case, delta emission is always no worse than a
945        // reset+reapply baseline, so skip cost estimation and flag diff logic.
946        if old.attrs == new.attrs {
947            if fg_changed {
948                ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
949            }
950            if bg_changed {
951                ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
952            }
953            return Ok(());
954        }
955
956        let mut collateral = StyleFlags::empty();
957        if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
958            collateral |= StyleFlags::DIM;
959        }
960        if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
961            collateral |= StyleFlags::BOLD;
962        }
963
964        let mut delta_len = 0u32;
965        delta_len += Self::sgr_flags_off_len(attrs_removed);
966        delta_len += Self::sgr_flags_len(collateral);
967        delta_len += Self::sgr_flags_len(attrs_added);
968        if fg_changed {
969            delta_len += if new.fg.a() == 0 {
970                5
971            } else {
972                Self::sgr_rgb_len(new.fg)
973            };
974        }
975        if bg_changed {
976            delta_len += if new.bg.a() == 0 {
977                5
978            } else {
979                Self::sgr_rgb_len(new.bg)
980            };
981        }
982
983        let mut baseline_len = 4u32;
984        if new.fg.a() > 0 {
985            baseline_len += Self::sgr_rgb_len(new.fg);
986        }
987        if new.bg.a() > 0 {
988            baseline_len += Self::sgr_rgb_len(new.bg);
989        }
990        baseline_len += Self::sgr_flags_len(new.attrs);
991
992        if delta_len > baseline_len {
993            return self.emit_style_full(new);
994        }
995
996        // Handle attr removal: emit individual off codes
997        if !attrs_removed.is_empty() {
998            let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
999            // Re-enable any collaterally disabled flags
1000            if !collateral.is_empty() {
1001                ansi::sgr_flags(&mut self.writer, collateral)?;
1002            }
1003        }
1004
1005        // Handle attr addition: emit on codes for newly added flags
1006        if !attrs_added.is_empty() {
1007            ansi::sgr_flags(&mut self.writer, attrs_added)?;
1008        }
1009
1010        // Handle fg color change
1011        if fg_changed {
1012            ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
1013        }
1014
1015        // Handle bg color change
1016        if bg_changed {
1017            ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
1018        }
1019
1020        Ok(())
1021    }
1022
1023    /// Emit hyperlink changes if the cell link differs from current.
1024    fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
1025        // Respect capability policy so callers running in mux contexts don't
1026        // emit OSC 8 sequences even if the raw capability flag is set.
1027        if !self.hyperlinks_enabled {
1028            if self.current_link.is_none() {
1029                return Ok(());
1030            }
1031            if self.current_link.is_some() {
1032                ansi::hyperlink_end(&mut self.writer)?;
1033            }
1034            self.current_link = None;
1035            return Ok(());
1036        }
1037
1038        let raw_link_id = cell.attrs.link_id();
1039        let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1040            None
1041        } else {
1042            Some(raw_link_id)
1043        };
1044
1045        // Check if link changed
1046        if self.current_link == new_link {
1047            return Ok(());
1048        }
1049
1050        // Close current link if open
1051        if self.current_link.is_some() {
1052            ansi::hyperlink_end(&mut self.writer)?;
1053        }
1054
1055        // Open new link if present and resolvable
1056        let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
1057            && let Some(url) = registry.get(link_id)
1058            && is_safe_hyperlink_url(url)
1059        {
1060            ansi::hyperlink_start(&mut self.writer, url)?;
1061            true
1062        } else {
1063            false
1064        };
1065
1066        // Only track as current if we actually opened it
1067        self.current_link = if actually_opened { new_link } else { None };
1068        Ok(())
1069    }
1070
1071    /// Emit cell content after width/content classification.
1072    fn emit_content(
1073        &mut self,
1074        content: PreparedContent,
1075        raw_width: usize,
1076        pool: Option<&GraphemePool>,
1077    ) -> io::Result<()> {
1078        match content {
1079            PreparedContent::Grapheme(grapheme_id) => {
1080                if let Some(pool) = pool
1081                    && let Some(text) = pool.get(grapheme_id)
1082                {
1083                    let safe = sanitize(text);
1084                    if !safe.is_empty() && display_width(safe.as_ref()) == raw_width {
1085                        return self.writer.write_all(safe.as_bytes());
1086                    }
1087                }
1088                // Fallback when sanitization strips bytes or changes display width:
1089                // emit width-1 placeholders so the terminal cursor advances by the
1090                // exact number of cells encoded in the grapheme ID.
1091                if raw_width > 0 {
1092                    for _ in 0..raw_width {
1093                        self.writer.write_all(b"?")?;
1094                    }
1095                }
1096                Ok(())
1097            }
1098            PreparedContent::Char(ch) => {
1099                if ch.is_ascii() {
1100                    // Width-0 ASCII controls are filtered earlier via the
1101                    // replacement-character path. The remaining ASCII controls
1102                    // here are width-1 (`\n`/`\r`) and must still sanitize to
1103                    // a visually neutral single cell.
1104                    let byte = if ch.is_ascii_control() {
1105                        b' '
1106                    } else {
1107                        ch as u8
1108                    };
1109                    return self.writer.write_all(&[byte]);
1110                }
1111                // Sanitize control characters that would break the grid.
1112                let safe_ch = if ch.is_control() { ' ' } else { ch };
1113                let mut buf = [0u8; 4];
1114                let encoded = safe_ch.encode_utf8(&mut buf);
1115                self.writer.write_all(encoded.as_bytes())
1116            }
1117            PreparedContent::Empty => {
1118                // Empty cell - emit space
1119                self.writer.write_all(b" ")
1120            }
1121        }
1122    }
1123
1124    /// Move cursor to the specified position.
1125    fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
1126        // Skip if already at position
1127        if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1128            return Ok(());
1129        }
1130
1131        // Use CUP (cursor position) for absolute positioning
1132        ansi::cup(
1133            &mut self.writer,
1134            y.saturating_add(self.viewport_offset_y),
1135            x,
1136        )?;
1137        self.cursor_x = Some(x);
1138        self.cursor_y = Some(y);
1139        Ok(())
1140    }
1141
1142    /// Move cursor using the cheapest available operation.
1143    ///
1144    /// Compares CUP (absolute), CHA (column-only), and CUF/CUB (relative)
1145    /// to select the minimum-cost cursor movement.
1146    fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
1147        // Skip if already at position
1148        if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1149            return Ok(());
1150        }
1151
1152        // Decide cheapest move
1153        let same_row = self.cursor_y == Some(y);
1154        let actual_y = y.saturating_add(self.viewport_offset_y);
1155
1156        if same_row {
1157            if let Some(cx) = self.cursor_x {
1158                if x > cx {
1159                    // Forward
1160                    let dx = x - cx;
1161                    let cuf = cost_model::cuf_cost(dx);
1162                    let cha = cost_model::cha_cost(x);
1163                    let cup = cost_model::cup_cost(actual_y, x);
1164
1165                    if cuf <= cha && cuf <= cup {
1166                        ansi::cuf(&mut self.writer, dx)?;
1167                    } else if cha <= cup {
1168                        ansi::cha(&mut self.writer, x)?;
1169                    } else {
1170                        ansi::cup(&mut self.writer, actual_y, x)?;
1171                    }
1172                } else if x < cx {
1173                    // Backward
1174                    let dx = cx - x;
1175                    let cub = cost_model::cub_cost(dx);
1176                    let cha = cost_model::cha_cost(x);
1177                    let cup = cost_model::cup_cost(actual_y, x);
1178
1179                    if cha <= cub && cha <= cup {
1180                        ansi::cha(&mut self.writer, x)?;
1181                    } else if cub <= cup {
1182                        ansi::cub(&mut self.writer, dx)?;
1183                    } else {
1184                        ansi::cup(&mut self.writer, actual_y, x)?;
1185                    }
1186                } else {
1187                    // Same column (should have been caught by early check, but for safety)
1188                }
1189            } else {
1190                // Unknown x, same row (unlikely but possible if we only tracked y?)
1191                // Fallback to absolute
1192                ansi::cup(&mut self.writer, actual_y, x)?;
1193            }
1194        } else {
1195            // Different row: CUP is the only option
1196            ansi::cup(&mut self.writer, actual_y, x)?;
1197        }
1198
1199        self.cursor_x = Some(x);
1200        self.cursor_y = Some(y);
1201        Ok(())
1202    }
1203
1204    /// Clear the entire screen.
1205    pub fn clear_screen(&mut self) -> io::Result<()> {
1206        ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
1207        ansi::cup(&mut self.writer, 0, 0)?;
1208        self.cursor_x = Some(0);
1209        self.cursor_y = Some(0);
1210        self.writer.flush()
1211    }
1212
1213    /// Clear a single line.
1214    pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
1215        self.move_cursor_to(0, y)?;
1216        ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
1217        self.writer.flush()
1218    }
1219
1220    /// Hide the cursor.
1221    pub fn hide_cursor(&mut self) -> io::Result<()> {
1222        ansi::cursor_hide(&mut self.writer)?;
1223        self.writer.flush()
1224    }
1225
1226    /// Show the cursor.
1227    pub fn show_cursor(&mut self) -> io::Result<()> {
1228        ansi::cursor_show(&mut self.writer)?;
1229        self.writer.flush()
1230    }
1231
1232    /// Position the cursor at the specified coordinates.
1233    pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1234        self.move_cursor_to(x, y)?;
1235        self.writer.flush()
1236    }
1237
1238    /// Reset the presenter state.
1239    ///
1240    /// Useful after resize or when terminal state is unknown.
1241    pub fn reset(&mut self) {
1242        self.current_style = None;
1243        self.current_link = None;
1244        self.cursor_x = None;
1245        self.cursor_y = None;
1246    }
1247
1248    /// Flush any buffered output.
1249    pub fn flush(&mut self) -> io::Result<()> {
1250        self.writer.flush()
1251    }
1252
1253    /// Get the inner writer (consuming the presenter).
1254    ///
1255    /// Flushes any buffered data before returning the writer.
1256    pub fn into_inner(self) -> Result<W, io::Error> {
1257        self.writer
1258            .into_inner() // CountingWriter -> BufWriter<W>
1259            .into_inner() // BufWriter<W> -> Result<W, IntoInnerError>
1260            .map_err(|e| e.into_error())
1261    }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266    use super::*;
1267    use crate::cell::{CellAttrs, CellContent};
1268    use crate::link_registry::LinkRegistry;
1269
1270    fn test_presenter() -> Presenter<Vec<u8>> {
1271        let caps = TerminalCapabilities::basic();
1272        Presenter::new(Vec::new(), caps)
1273    }
1274
1275    fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1276        let mut caps = TerminalCapabilities::basic();
1277        caps.sync_output = true;
1278        Presenter::new(Vec::new(), caps)
1279    }
1280
1281    fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
1282        let mut caps = TerminalCapabilities::basic();
1283        caps.osc8_hyperlinks = true;
1284        Presenter::new(Vec::new(), caps)
1285    }
1286
1287    fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1288        presenter.into_inner().unwrap()
1289    }
1290
1291    fn legacy_plan_row(
1292        row_runs: &[ChangeRun],
1293        prev_x: Option<u16>,
1294        prev_y: Option<u16>,
1295    ) -> Vec<cost_model::RowSpan> {
1296        if row_runs.is_empty() {
1297            return Vec::new();
1298        }
1299
1300        if row_runs.len() == 1 {
1301            let run = row_runs[0];
1302            return vec![cost_model::RowSpan {
1303                y: run.y,
1304                x0: run.x0,
1305                x1: run.x1,
1306            }];
1307        }
1308
1309        let row_y = row_runs[0].y;
1310        let first_x = row_runs[0].x0;
1311        let last_x = row_runs[row_runs.len() - 1].x1;
1312
1313        // Estimate sparse cost: sum of move + content for each run
1314        let mut sparse_cost: usize = 0;
1315        let mut cursor_x = prev_x;
1316        let mut cursor_y = prev_y;
1317
1318        for run in row_runs {
1319            let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1320            let cells = (run.x1 as usize).saturating_sub(run.x0 as usize) + 1;
1321            sparse_cost += move_cost + cells;
1322            cursor_x = Some(run.x1.saturating_add(1));
1323            cursor_y = Some(row_y);
1324        }
1325
1326        // Estimate merged cost: one move + all cells from first to last
1327        let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1328        let total_cells = (last_x as usize).saturating_sub(first_x as usize) + 1;
1329        let changed_cells: usize = row_runs
1330            .iter()
1331            .map(|r| (r.x1 as usize).saturating_sub(r.x0 as usize) + 1)
1332            .sum();
1333        let gap_cells = total_cells.saturating_sub(changed_cells);
1334        let gap_overhead = gap_cells * 2;
1335        let merged_cost = merge_move + changed_cells + gap_overhead;
1336
1337        if merged_cost < sparse_cost {
1338            vec![cost_model::RowSpan {
1339                y: row_y,
1340                x0: first_x,
1341                x1: last_x,
1342            }]
1343        } else {
1344            row_runs
1345                .iter()
1346                .map(|run| cost_model::RowSpan {
1347                    y: run.y,
1348                    x0: run.x0,
1349                    x1: run.x1,
1350                })
1351                .collect()
1352        }
1353    }
1354
1355    fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1356        let mut presenter = test_presenter();
1357
1358        for span in spans {
1359            presenter
1360                .move_cursor_optimal(span.x0, span.y)
1361                .expect("cursor move should succeed");
1362            for x in span.x0..=span.x1 {
1363                let cell = buffer.get_unchecked(x, span.y);
1364                presenter
1365                    .emit_cell(x, cell, None, None)
1366                    .expect("emit_cell should succeed");
1367            }
1368        }
1369
1370        presenter
1371            .writer
1372            .write_all(b"\x1b[0m")
1373            .expect("reset should succeed");
1374
1375        presenter.into_inner().expect("presenter output")
1376    }
1377
1378    #[test]
1379    fn empty_diff_produces_minimal_output() {
1380        let mut presenter = test_presenter();
1381        let buffer = Buffer::new(10, 10);
1382        let diff = BufferDiff::new();
1383
1384        presenter.present(&buffer, &diff).unwrap();
1385        let output = get_output(presenter);
1386
1387        // Without sync, fallback hides cursor first, then SGR reset, then cursor show
1388        assert!(output.starts_with(ansi::CURSOR_HIDE));
1389        assert!(output.ends_with(ansi::CURSOR_SHOW));
1390        // SGR reset is still present between the cursor brackets
1391        assert!(
1392            output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
1393            "SGR reset should be present"
1394        );
1395    }
1396
1397    #[test]
1398    fn sync_output_wraps_frame() {
1399        let mut presenter = test_presenter_with_sync();
1400        let mut buffer = Buffer::new(3, 1);
1401        buffer.set_raw(0, 0, Cell::from_char('X'));
1402
1403        let old = Buffer::new(3, 1);
1404        let diff = BufferDiff::compute(&old, &buffer);
1405
1406        presenter.present(&buffer, &diff).unwrap();
1407        let output = get_output(presenter);
1408
1409        assert!(
1410            output.starts_with(ansi::SYNC_BEGIN),
1411            "sync output should begin with DEC 2026 begin"
1412        );
1413        assert!(
1414            output.ends_with(ansi::SYNC_END),
1415            "sync output should end with DEC 2026 end"
1416        );
1417    }
1418
1419    #[test]
1420    fn sync_output_obeys_mux_policy() {
1421        let caps = TerminalCapabilities::builder()
1422            .sync_output(true)
1423            .in_tmux(true)
1424            .build();
1425        let mut presenter = Presenter::new(Vec::new(), caps);
1426
1427        let mut buffer = Buffer::new(2, 1);
1428        buffer.set_raw(0, 0, Cell::from_char('X'));
1429        let old = Buffer::new(2, 1);
1430        let diff = BufferDiff::compute(&old, &buffer);
1431
1432        presenter.present(&buffer, &diff).unwrap();
1433        let output = get_output(presenter);
1434
1435        assert!(
1436            !output
1437                .windows(ansi::SYNC_BEGIN.len())
1438                .any(|w| w == ansi::SYNC_BEGIN),
1439            "tmux policy should suppress sync begin"
1440        );
1441        assert!(
1442            !output
1443                .windows(ansi::SYNC_END.len())
1444                .any(|w| w == ansi::SYNC_END),
1445            "tmux policy should suppress sync end"
1446        );
1447    }
1448
1449    #[test]
1450    fn hyperlink_sequences_emitted_and_closed() {
1451        let mut presenter = test_presenter_with_hyperlinks();
1452        let mut buffer = Buffer::new(3, 1);
1453
1454        let mut registry = LinkRegistry::new();
1455        let link_id = registry.register("https://example.com");
1456        let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1457        buffer.set_raw(0, 0, linked);
1458
1459        let old = Buffer::new(3, 1);
1460        let diff = BufferDiff::compute(&old, &buffer);
1461
1462        presenter
1463            .present_with_pool(&buffer, &diff, None, Some(&registry))
1464            .unwrap();
1465        let output = get_output(presenter);
1466
1467        let start = b"\x1b]8;;https://example.com\x07";
1468        let end = b"\x1b]8;;\x07";
1469
1470        let start_pos = output
1471            .windows(start.len())
1472            .position(|w| w == start)
1473            .expect("hyperlink start not found");
1474        let end_pos = output
1475            .windows(end.len())
1476            .position(|w| w == end)
1477            .expect("hyperlink end not found");
1478        let char_pos = output
1479            .iter()
1480            .position(|&b| b == b'L')
1481            .expect("linked character not found");
1482
1483        assert!(start_pos < char_pos, "link start should precede text");
1484        assert!(char_pos < end_pos, "link end should follow text");
1485    }
1486
1487    #[test]
1488    fn single_cell_change() {
1489        let mut presenter = test_presenter();
1490        let mut buffer = Buffer::new(10, 10);
1491        buffer.set_raw(5, 5, Cell::from_char('X'));
1492
1493        let old = Buffer::new(10, 10);
1494        let diff = BufferDiff::compute(&old, &buffer);
1495
1496        presenter.present(&buffer, &diff).unwrap();
1497        let output = get_output(presenter);
1498
1499        // Should contain cursor position and character
1500        let output_str = String::from_utf8_lossy(&output);
1501        assert!(output_str.contains("X"));
1502        assert!(output_str.contains("\x1b[")); // Contains escape sequences
1503    }
1504
1505    #[test]
1506    fn style_tracking_avoids_redundant_sgr() {
1507        let mut presenter = test_presenter();
1508        let mut buffer = Buffer::new(10, 1);
1509
1510        // Set multiple cells with same style
1511        let fg = PackedRgba::rgb(255, 0, 0);
1512        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1513        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1514        buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1515
1516        let old = Buffer::new(10, 1);
1517        let diff = BufferDiff::compute(&old, &buffer);
1518
1519        presenter.present(&buffer, &diff).unwrap();
1520        let output = get_output(presenter);
1521
1522        // Count SGR sequences (should be minimal due to style tracking)
1523        let output_str = String::from_utf8_lossy(&output);
1524        let sgr_count = output_str.matches("\x1b[38;2").count();
1525        // Should have exactly 1 fg color sequence (style set once, reused for ABC)
1526        assert_eq!(
1527            sgr_count, 1,
1528            "Expected 1 SGR fg sequence, got {}",
1529            sgr_count
1530        );
1531    }
1532
1533    #[test]
1534    fn reset_reapplies_style_after_clear() {
1535        let mut presenter = test_presenter();
1536        let mut buffer = Buffer::new(1, 1);
1537        let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1538        buffer.set_raw(0, 0, styled);
1539
1540        let old = Buffer::new(1, 1);
1541        let diff = BufferDiff::compute(&old, &buffer);
1542
1543        presenter.present(&buffer, &diff).unwrap();
1544        presenter.reset();
1545        presenter.present(&buffer, &diff).unwrap();
1546
1547        let output = get_output(presenter);
1548        let output_str = String::from_utf8_lossy(&output);
1549        let sgr_count = output_str.matches("\x1b[38;2").count();
1550
1551        assert_eq!(
1552            sgr_count, 2,
1553            "Expected style to be re-applied after reset, got {sgr_count} sequences"
1554        );
1555    }
1556
1557    #[test]
1558    fn cursor_position_optimized() {
1559        let mut presenter = test_presenter();
1560        let mut buffer = Buffer::new(10, 5);
1561
1562        // Set adjacent cells (should be one run)
1563        buffer.set_raw(3, 2, Cell::from_char('A'));
1564        buffer.set_raw(4, 2, Cell::from_char('B'));
1565        buffer.set_raw(5, 2, Cell::from_char('C'));
1566
1567        let old = Buffer::new(10, 5);
1568        let diff = BufferDiff::compute(&old, &buffer);
1569
1570        presenter.present(&buffer, &diff).unwrap();
1571        let output = get_output(presenter);
1572
1573        // Should have only one CUP sequence for the run
1574        let output_str = String::from_utf8_lossy(&output);
1575        let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1576
1577        // Content should be "ABC" somewhere in output
1578        assert!(
1579            output_str.contains("ABC")
1580                || (output_str.contains('A')
1581                    && output_str.contains('B')
1582                    && output_str.contains('C'))
1583        );
1584    }
1585
1586    #[test]
1587    fn sync_output_wrapped_when_supported() {
1588        let mut presenter = test_presenter_with_sync();
1589        let buffer = Buffer::new(10, 10);
1590        let diff = BufferDiff::new();
1591
1592        presenter.present(&buffer, &diff).unwrap();
1593        let output = get_output(presenter);
1594
1595        // Should have sync begin and end
1596        assert!(output.starts_with(ansi::SYNC_BEGIN));
1597        assert!(
1598            output
1599                .windows(ansi::SYNC_END.len())
1600                .any(|w| w == ansi::SYNC_END)
1601        );
1602    }
1603
1604    #[test]
1605    fn clear_screen_works() {
1606        let mut presenter = test_presenter();
1607        presenter.clear_screen().unwrap();
1608        let output = get_output(presenter);
1609
1610        // Should contain erase display sequence
1611        assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1612    }
1613
1614    #[test]
1615    fn cursor_visibility() {
1616        let mut presenter = test_presenter();
1617
1618        presenter.hide_cursor().unwrap();
1619        presenter.show_cursor().unwrap();
1620
1621        let output = get_output(presenter);
1622        let output_str = String::from_utf8_lossy(&output);
1623
1624        assert!(output_str.contains("\x1b[?25l")); // Hide
1625        assert!(output_str.contains("\x1b[?25h")); // Show
1626    }
1627
1628    #[test]
1629    fn reset_clears_state() {
1630        let mut presenter = test_presenter();
1631        presenter.cursor_x = Some(50);
1632        presenter.cursor_y = Some(20);
1633        presenter.current_style = Some(CellStyle::default());
1634
1635        presenter.reset();
1636
1637        assert!(presenter.cursor_x.is_none());
1638        assert!(presenter.cursor_y.is_none());
1639        assert!(presenter.current_style.is_none());
1640    }
1641
1642    #[test]
1643    fn position_cursor() {
1644        let mut presenter = test_presenter();
1645        presenter.position_cursor(10, 5).unwrap();
1646
1647        let output = get_output(presenter);
1648        // CUP is 1-indexed: row 6, col 11
1649        assert!(
1650            output
1651                .windows(b"\x1b[6;11H".len())
1652                .any(|w| w == b"\x1b[6;11H")
1653        );
1654    }
1655
1656    #[test]
1657    fn skip_cursor_move_when_already_at_position() {
1658        let mut presenter = test_presenter();
1659        presenter.cursor_x = Some(5);
1660        presenter.cursor_y = Some(3);
1661
1662        // Move to same position
1663        presenter.move_cursor_to(5, 3).unwrap();
1664
1665        // Should produce no output
1666        let output = get_output(presenter);
1667        assert!(output.is_empty());
1668    }
1669
1670    #[test]
1671    fn continuation_cells_skipped() {
1672        let mut presenter = test_presenter();
1673        let mut buffer = Buffer::new(10, 1);
1674
1675        // Set a wide character
1676        buffer.set_raw(0, 0, Cell::from_char('中'));
1677        // The next cell would be a continuation - simulate it
1678        buffer.set_raw(1, 0, Cell::CONTINUATION);
1679
1680        // Create a diff that includes both cells
1681        let old = Buffer::new(10, 1);
1682        let diff = BufferDiff::compute(&old, &buffer);
1683
1684        presenter.present(&buffer, &diff).unwrap();
1685        let output = get_output(presenter);
1686
1687        // Should contain the wide character
1688        let output_str = String::from_utf8_lossy(&output);
1689        assert!(output_str.contains('中'));
1690    }
1691
1692    #[test]
1693    fn continuation_at_run_start_clears_orphan_tail() {
1694        let mut presenter = test_presenter();
1695        let mut old = Buffer::new(3, 1);
1696        let mut new = Buffer::new(3, 1);
1697
1698        // Construct an inconsistent old/new pair that forces a diff which begins at a
1699        // continuation cell. This simulates starting emission mid-wide-character.
1700        //
1701        // In this case, the presenter should clear the orphan continuation cell so
1702        // stale terminal content cannot leak through.
1703        old.set_raw(0, 0, Cell::from_char('中'));
1704        new.set_raw(0, 0, Cell::from_char('中'));
1705        old.set_raw(1, 0, Cell::from_char('X'));
1706        new.set_raw(1, 0, Cell::CONTINUATION);
1707
1708        let diff = BufferDiff::compute(&old, &new);
1709        assert_eq!(diff.changes(), &[(1u16, 0u16)]);
1710
1711        presenter.present(&new, &diff).unwrap();
1712        let output = get_output(presenter);
1713
1714        assert!(
1715            output.contains(&b' '),
1716            "orphan continuation should be cleared with a space"
1717        );
1718    }
1719
1720    #[test]
1721    fn continuation_cleanup_resets_style_and_closes_link_before_space() {
1722        let mut presenter = test_presenter_with_hyperlinks();
1723        let mut links = LinkRegistry::new();
1724        let link_id = links.register("https://example.com");
1725
1726        let styled = Cell::from_char('X')
1727            .with_fg(PackedRgba::rgb(255, 0, 0))
1728            .with_bg(PackedRgba::rgb(0, 0, 255))
1729            .with_attrs(CellAttrs::new(StyleFlags::UNDERLINE, link_id));
1730        presenter.current_style = Some(CellStyle::from_cell(&styled));
1731        presenter.current_link = Some(link_id);
1732        presenter.cursor_x = Some(0);
1733        presenter.cursor_y = Some(0);
1734
1735        presenter
1736            .emit_cell(0, &Cell::CONTINUATION, None, Some(&links))
1737            .unwrap();
1738        let output = presenter.into_inner().unwrap();
1739
1740        let reset = b"\x1b[0m";
1741        let close = b"\x1b]8;;\x07";
1742        let reset_pos = output
1743            .windows(reset.len())
1744            .position(|window| window == reset)
1745            .expect("continuation cleanup should reset SGR state");
1746        let close_pos = output
1747            .windows(close.len())
1748            .position(|window| window == close)
1749            .expect("continuation cleanup should close OSC 8");
1750        let space_pos = output
1751            .iter()
1752            .position(|&byte| byte == b' ')
1753            .expect("continuation cleanup should emit a space");
1754
1755        assert!(
1756            reset_pos < space_pos,
1757            "cleanup reset must precede the blank"
1758        );
1759        assert!(
1760            close_pos < space_pos,
1761            "cleanup link close must precede the blank"
1762        );
1763    }
1764
1765    #[test]
1766    fn wide_char_missing_continuation_causes_drift() {
1767        let mut presenter = test_presenter();
1768        let mut buffer = Buffer::new(10, 1);
1769
1770        // Bug scenario: User sets wide char but forgets continuation
1771        buffer.set_raw(0, 0, Cell::from_char('中'));
1772        // (1,0) remains empty (space), instead of CONTINUATION
1773
1774        let old = Buffer::new(10, 1);
1775        let diff = BufferDiff::compute(&old, &buffer);
1776
1777        presenter.present(&buffer, &diff).unwrap();
1778        let output = get_output(presenter);
1779
1780        // Expected behavior with fix:
1781        // 1. Emit '中' at 0. Cursor -> 2.
1782        // 2. Loop visits 1. Cell is ' '.
1783        // 3. Drift check sees x=1, cx=2. Mismatch!
1784        // 4. Force move to 1. Emits CUP or CHA (CHA is cheaper: \x1b[2G).
1785        // 5. Emit ' '. Cursor -> 2.
1786
1787        // Without fix, it would just emit ' ' at 2.
1788
1789        let output_str = String::from_utf8_lossy(&output);
1790
1791        // Assert we see the wide char
1792        assert!(output_str.contains('中'));
1793
1794        // Assert we see a back-step or positioning sequence.
1795        // CHA 2 is "\x1b[2G". CUB 1 is "\x1b[D".
1796        // The cost model might choose CUB 1 (3 bytes) vs CHA 2 (4 bytes).
1797        // So check for either.
1798
1799        let has_correction = output_str.contains("\x1b[D")
1800            || output_str.contains("\x1b[2G")
1801            || output_str.contains("\x1b[1;2H");
1802
1803        assert!(
1804            has_correction,
1805            "Presenter should correct cursor drift when wide char tail is missing. Output: {:?}",
1806            output_str
1807        );
1808    }
1809
1810    #[test]
1811    fn hyperlink_emitted_with_registry() {
1812        let mut presenter = test_presenter_with_hyperlinks();
1813        let mut buffer = Buffer::new(10, 1);
1814        let mut links = LinkRegistry::new();
1815
1816        let link_id = links.register("https://example.com");
1817        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1818        buffer.set_raw(0, 0, cell);
1819
1820        let old = Buffer::new(10, 1);
1821        let diff = BufferDiff::compute(&old, &buffer);
1822
1823        presenter
1824            .present_with_pool(&buffer, &diff, None, Some(&links))
1825            .unwrap();
1826        let output = get_output(presenter);
1827        let output_str = String::from_utf8_lossy(&output);
1828
1829        // OSC 8 open with URL
1830        assert!(
1831            output_str.contains("\x1b]8;;https://example.com\x07"),
1832            "Expected OSC 8 open, got: {:?}",
1833            output_str
1834        );
1835        // OSC 8 close (empty URL)
1836        assert!(
1837            output_str.contains("\x1b]8;;\x07"),
1838            "Expected OSC 8 close, got: {:?}",
1839            output_str
1840        );
1841    }
1842
1843    #[test]
1844    fn hyperlink_not_emitted_without_registry() {
1845        let mut presenter = test_presenter_with_hyperlinks();
1846        let mut buffer = Buffer::new(10, 1);
1847
1848        // Set a link ID without providing a registry
1849        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1850        buffer.set_raw(0, 0, cell);
1851
1852        let old = Buffer::new(10, 1);
1853        let diff = BufferDiff::compute(&old, &buffer);
1854
1855        // Present without link registry
1856        presenter.present(&buffer, &diff).unwrap();
1857        let output = get_output(presenter);
1858        let output_str = String::from_utf8_lossy(&output);
1859
1860        // No OSC 8 sequences should appear
1861        assert!(
1862            !output_str.contains("\x1b]8;"),
1863            "OSC 8 should not appear without registry, got: {:?}",
1864            output_str
1865        );
1866    }
1867
1868    #[test]
1869    fn hyperlink_not_emitted_for_unknown_id() {
1870        let mut presenter = test_presenter_with_hyperlinks();
1871        let mut buffer = Buffer::new(10, 1);
1872        let links = LinkRegistry::new();
1873
1874        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1875        buffer.set_raw(0, 0, cell);
1876
1877        let old = Buffer::new(10, 1);
1878        let diff = BufferDiff::compute(&old, &buffer);
1879
1880        presenter
1881            .present_with_pool(&buffer, &diff, None, Some(&links))
1882            .unwrap();
1883        let output = get_output(presenter);
1884        let output_str = String::from_utf8_lossy(&output);
1885
1886        assert!(
1887            !output_str.contains("\x1b]8;"),
1888            "OSC 8 should not appear for unknown link IDs, got: {:?}",
1889            output_str
1890        );
1891        assert!(output_str.contains('L'));
1892    }
1893
1894    #[test]
1895    fn hyperlink_closed_at_frame_end() {
1896        let mut presenter = test_presenter_with_hyperlinks();
1897        let mut buffer = Buffer::new(10, 1);
1898        let mut links = LinkRegistry::new();
1899
1900        let link_id = links.register("https://example.com");
1901        // Set all cells with the same link
1902        for x in 0..5 {
1903            buffer.set_raw(
1904                x,
1905                0,
1906                Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1907            );
1908        }
1909
1910        let old = Buffer::new(10, 1);
1911        let diff = BufferDiff::compute(&old, &buffer);
1912
1913        presenter
1914            .present_with_pool(&buffer, &diff, None, Some(&links))
1915            .unwrap();
1916        let output = get_output(presenter);
1917
1918        // The close sequence should appear (frame end cleanup)
1919        let close_seq = b"\x1b]8;;\x07";
1920        assert!(
1921            output.windows(close_seq.len()).any(|w| w == close_seq),
1922            "Link must be closed at frame end"
1923        );
1924    }
1925
1926    #[test]
1927    fn hyperlink_transitions_between_links() {
1928        let mut presenter = test_presenter_with_hyperlinks();
1929        let mut buffer = Buffer::new(10, 1);
1930        let mut links = LinkRegistry::new();
1931
1932        let link_a = links.register("https://a.com");
1933        let link_b = links.register("https://b.com");
1934
1935        buffer.set_raw(
1936            0,
1937            0,
1938            Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1939        );
1940        buffer.set_raw(
1941            1,
1942            0,
1943            Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1944        );
1945        buffer.set_raw(2, 0, Cell::from_char('C')); // no link
1946
1947        let old = Buffer::new(10, 1);
1948        let diff = BufferDiff::compute(&old, &buffer);
1949
1950        presenter
1951            .present_with_pool(&buffer, &diff, None, Some(&links))
1952            .unwrap();
1953        let output = get_output(presenter);
1954        let output_str = String::from_utf8_lossy(&output);
1955
1956        // Both links should appear
1957        assert!(output_str.contains("https://a.com"));
1958        assert!(output_str.contains("https://b.com"));
1959
1960        // Close sequence must appear at least once (transition or frame end)
1961        let close_count = output_str.matches("\x1b]8;;\x07").count();
1962        assert!(
1963            close_count >= 2,
1964            "Expected at least 2 link close sequences (transition + frame end), got {}",
1965            close_count
1966        );
1967    }
1968
1969    #[test]
1970    fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
1971        let caps = TerminalCapabilities::builder()
1972            .osc8_hyperlinks(true)
1973            .in_tmux(true)
1974            .build();
1975        let mut presenter = Presenter::new(Vec::new(), caps);
1976        let mut buffer = Buffer::new(3, 1);
1977        let mut links = LinkRegistry::new();
1978        let link_id = links.register("https://example.com");
1979        buffer.set_raw(
1980            0,
1981            0,
1982            Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1983        );
1984
1985        let old = Buffer::new(3, 1);
1986        let diff = BufferDiff::compute(&old, &buffer);
1987        presenter
1988            .present_with_pool(&buffer, &diff, None, Some(&links))
1989            .unwrap();
1990
1991        let output = get_output(presenter);
1992        let output_str = String::from_utf8_lossy(&output);
1993        assert!(
1994            !output_str.contains("\x1b]8;"),
1995            "tmux policy should suppress OSC 8 sequences"
1996        );
1997        assert!(output_str.contains('L'));
1998    }
1999
2000    #[test]
2001    fn hyperlink_disabled_policy_noops_when_no_link_is_open() {
2002        let mut presenter = test_presenter();
2003        presenter
2004            .emit_link_changes(&Cell::from_char('X'), None)
2005            .unwrap();
2006        assert!(presenter.into_inner().unwrap().is_empty());
2007    }
2008
2009    #[test]
2010    fn hyperlink_disabled_policy_still_closes_stale_open_link() {
2011        let mut presenter = test_presenter();
2012        presenter.current_link = Some(7);
2013        presenter
2014            .emit_link_changes(&Cell::from_char('X'), None)
2015            .unwrap();
2016        assert_eq!(presenter.into_inner().unwrap(), b"\x1b]8;;\x07");
2017    }
2018
2019    #[test]
2020    fn hyperlink_unsafe_url_not_emitted() {
2021        let mut presenter = test_presenter_with_hyperlinks();
2022        let mut buffer = Buffer::new(3, 1);
2023        let mut links = LinkRegistry::new();
2024        let link_id = links.register("https://example.com/\x1b[?2026h");
2025        buffer.set_raw(
2026            0,
2027            0,
2028            Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2029        );
2030
2031        let old = Buffer::new(3, 1);
2032        let diff = BufferDiff::compute(&old, &buffer);
2033        presenter
2034            .present_with_pool(&buffer, &diff, None, Some(&links))
2035            .unwrap();
2036
2037        let output = get_output(presenter);
2038        let output_str = String::from_utf8_lossy(&output);
2039        assert!(
2040            !output_str.contains("\x1b]8;;https://example.com/"),
2041            "unsafe hyperlink URL should be suppressed"
2042        );
2043        assert!(
2044            !output_str.contains("\x1b[?2026h"),
2045            "control payload must never be emitted via OSC 8"
2046        );
2047        assert!(output_str.contains('X'));
2048    }
2049
2050    #[test]
2051    fn hyperlink_overlong_url_not_emitted() {
2052        let mut presenter = test_presenter_with_hyperlinks();
2053        let mut buffer = Buffer::new(3, 1);
2054        let mut links = LinkRegistry::new();
2055        let long_url = format!(
2056            "https://example.com/{}",
2057            "a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
2058        );
2059        let link_id = links.register(&long_url);
2060        buffer.set_raw(
2061            0,
2062            0,
2063            Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2064        );
2065
2066        let old = Buffer::new(3, 1);
2067        let diff = BufferDiff::compute(&old, &buffer);
2068        presenter
2069            .present_with_pool(&buffer, &diff, None, Some(&links))
2070            .unwrap();
2071
2072        let output = get_output(presenter);
2073        let output_str = String::from_utf8_lossy(&output);
2074        assert!(
2075            !output_str.contains("\x1b]8;;https://example.com/"),
2076            "overlong hyperlink URL should be suppressed"
2077        );
2078        assert!(output_str.contains('Y'));
2079    }
2080
2081    // =========================================================================
2082    // Single-write-per-frame behavior tests
2083    // =========================================================================
2084
2085    #[test]
2086    fn sync_output_not_wrapped_when_unsupported() {
2087        // When sync_output capability is false, sync sequences should NOT appear
2088        let mut presenter = test_presenter(); // basic caps, sync_output = false
2089        let buffer = Buffer::new(10, 10);
2090        let diff = BufferDiff::new();
2091
2092        presenter.present(&buffer, &diff).unwrap();
2093        let output = get_output(presenter);
2094
2095        // Should NOT contain sync sequences
2096        assert!(
2097            !output
2098                .windows(ansi::SYNC_BEGIN.len())
2099                .any(|w| w == ansi::SYNC_BEGIN),
2100            "Sync begin should not appear when sync_output is disabled"
2101        );
2102        assert!(
2103            !output
2104                .windows(ansi::SYNC_END.len())
2105                .any(|w| w == ansi::SYNC_END),
2106            "Sync end should not appear when sync_output is disabled"
2107        );
2108
2109        // Instead, cursor-hide fallback should be used
2110        assert!(
2111            output.starts_with(ansi::CURSOR_HIDE),
2112            "Fallback should start with cursor hide"
2113        );
2114        assert!(
2115            output.ends_with(ansi::CURSOR_SHOW),
2116            "Fallback should end with cursor show"
2117        );
2118    }
2119
2120    #[test]
2121    fn present_flushes_buffered_output() {
2122        // Verify that present() flushes all buffered output by checking
2123        // that the output contains expected content after present()
2124        let mut presenter = test_presenter();
2125        let mut buffer = Buffer::new(5, 1);
2126        buffer.set_raw(0, 0, Cell::from_char('T'));
2127        buffer.set_raw(1, 0, Cell::from_char('E'));
2128        buffer.set_raw(2, 0, Cell::from_char('S'));
2129        buffer.set_raw(3, 0, Cell::from_char('T'));
2130
2131        let old = Buffer::new(5, 1);
2132        let diff = BufferDiff::compute(&old, &buffer);
2133
2134        presenter.present(&buffer, &diff).unwrap();
2135        let output = get_output(presenter);
2136        let output_str = String::from_utf8_lossy(&output);
2137
2138        // All characters should be present in output (flushed)
2139        assert!(
2140            output_str.contains("TEST"),
2141            "Expected 'TEST' in flushed output"
2142        );
2143    }
2144
2145    #[test]
2146    fn present_stats_reports_cells_and_bytes() {
2147        let mut presenter = test_presenter();
2148        let mut buffer = Buffer::new(10, 1);
2149
2150        // Set 5 cells
2151        for i in 0..5 {
2152            buffer.set_raw(i, 0, Cell::from_char('X'));
2153        }
2154
2155        let old = Buffer::new(10, 1);
2156        let diff = BufferDiff::compute(&old, &buffer);
2157
2158        let stats = presenter.present(&buffer, &diff).unwrap();
2159
2160        // Stats should reflect the changes
2161        assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
2162        assert!(stats.bytes_emitted > 0, "Expected some bytes written");
2163        assert!(stats.run_count >= 1, "Expected at least 1 run");
2164    }
2165
2166    // =========================================================================
2167    // Cursor tracking tests
2168    // =========================================================================
2169
2170    #[test]
2171    fn cursor_tracking_after_wide_char() {
2172        let mut presenter = test_presenter();
2173        presenter.cursor_x = Some(0);
2174        presenter.cursor_y = Some(0);
2175
2176        let mut buffer = Buffer::new(10, 1);
2177        // Wide char at x=0 should advance cursor by 2
2178        buffer.set_raw(0, 0, Cell::from_char('中'));
2179        buffer.set_raw(1, 0, Cell::CONTINUATION);
2180        // Narrow char at x=2
2181        buffer.set_raw(2, 0, Cell::from_char('A'));
2182
2183        let old = Buffer::new(10, 1);
2184        let diff = BufferDiff::compute(&old, &buffer);
2185
2186        presenter.present(&buffer, &diff).unwrap();
2187
2188        // After presenting, cursor should be at x=3 (0 + 2 for wide + 1 for 'A')
2189        // Note: cursor_x gets reset during present(), but we can verify output order
2190        let output = get_output(presenter);
2191        let output_str = String::from_utf8_lossy(&output);
2192
2193        // Both characters should appear
2194        assert!(output_str.contains('中'));
2195        assert!(output_str.contains('A'));
2196    }
2197
2198    #[test]
2199    fn cursor_position_after_multiple_runs() {
2200        let mut presenter = test_presenter();
2201        let mut buffer = Buffer::new(20, 3);
2202
2203        // Create two separate runs on different rows
2204        buffer.set_raw(0, 0, Cell::from_char('A'));
2205        buffer.set_raw(1, 0, Cell::from_char('B'));
2206        buffer.set_raw(5, 2, Cell::from_char('X'));
2207        buffer.set_raw(6, 2, Cell::from_char('Y'));
2208
2209        let old = Buffer::new(20, 3);
2210        let diff = BufferDiff::compute(&old, &buffer);
2211
2212        presenter.present(&buffer, &diff).unwrap();
2213        let output = get_output(presenter);
2214        let output_str = String::from_utf8_lossy(&output);
2215
2216        // All characters should be present
2217        assert!(output_str.contains('A'));
2218        assert!(output_str.contains('B'));
2219        assert!(output_str.contains('X'));
2220        assert!(output_str.contains('Y'));
2221
2222        // Should have multiple CUP sequences (one per run)
2223        let cup_count = output_str.matches("\x1b[").count();
2224        assert!(
2225            cup_count >= 2,
2226            "Expected at least 2 escape sequences for multiple runs"
2227        );
2228    }
2229
2230    // =========================================================================
2231    // Style tracking tests
2232    // =========================================================================
2233
2234    #[test]
2235    fn style_with_all_flags() {
2236        let mut presenter = test_presenter();
2237        let mut buffer = Buffer::new(5, 1);
2238
2239        // Create a cell with all style flags
2240        let all_flags = StyleFlags::BOLD
2241            | StyleFlags::DIM
2242            | StyleFlags::ITALIC
2243            | StyleFlags::UNDERLINE
2244            | StyleFlags::BLINK
2245            | StyleFlags::REVERSE
2246            | StyleFlags::STRIKETHROUGH;
2247
2248        let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
2249        buffer.set_raw(0, 0, cell);
2250
2251        let old = Buffer::new(5, 1);
2252        let diff = BufferDiff::compute(&old, &buffer);
2253
2254        presenter.present(&buffer, &diff).unwrap();
2255        let output = get_output(presenter);
2256        let output_str = String::from_utf8_lossy(&output);
2257
2258        // Should contain the character and SGR sequences
2259        assert!(output_str.contains('X'));
2260        // Should have SGR with multiple attributes (1;2;3;4;5;7;9m pattern)
2261        assert!(output_str.contains("\x1b["), "Expected SGR sequences");
2262    }
2263
2264    #[test]
2265    fn style_transitions_between_different_colors() {
2266        let mut presenter = test_presenter();
2267        let mut buffer = Buffer::new(3, 1);
2268
2269        // Three cells with different foreground colors
2270        buffer.set_raw(
2271            0,
2272            0,
2273            Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
2274        );
2275        buffer.set_raw(
2276            1,
2277            0,
2278            Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
2279        );
2280        buffer.set_raw(
2281            2,
2282            0,
2283            Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
2284        );
2285
2286        let old = Buffer::new(3, 1);
2287        let diff = BufferDiff::compute(&old, &buffer);
2288
2289        presenter.present(&buffer, &diff).unwrap();
2290        let output = get_output(presenter);
2291        let output_str = String::from_utf8_lossy(&output);
2292
2293        // All colors should appear in the output
2294        assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
2295        assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
2296        assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
2297    }
2298
2299    // =========================================================================
2300    // Link tracking tests
2301    // =========================================================================
2302
2303    #[test]
2304    fn link_at_buffer_boundaries() {
2305        let mut presenter = test_presenter_with_hyperlinks();
2306        let mut buffer = Buffer::new(5, 1);
2307        let mut links = LinkRegistry::new();
2308
2309        let link_id = links.register("https://boundary.test");
2310
2311        // Link at first cell
2312        buffer.set_raw(
2313            0,
2314            0,
2315            Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2316        );
2317        // Link at last cell
2318        buffer.set_raw(
2319            4,
2320            0,
2321            Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2322        );
2323
2324        let old = Buffer::new(5, 1);
2325        let diff = BufferDiff::compute(&old, &buffer);
2326
2327        presenter
2328            .present_with_pool(&buffer, &diff, None, Some(&links))
2329            .unwrap();
2330        let output = get_output(presenter);
2331        let output_str = String::from_utf8_lossy(&output);
2332
2333        // Link URL should appear
2334        assert!(output_str.contains("https://boundary.test"));
2335        // Characters should appear
2336        assert!(output_str.contains('F'));
2337        assert!(output_str.contains('L'));
2338    }
2339
2340    #[test]
2341    fn link_state_cleared_after_reset() {
2342        let mut presenter = test_presenter();
2343        let mut links = LinkRegistry::new();
2344        let link_id = links.register("https://example.com");
2345
2346        // Simulate having an open link
2347        presenter.current_link = Some(link_id);
2348        presenter.current_style = Some(CellStyle::default());
2349        presenter.cursor_x = Some(5);
2350        presenter.cursor_y = Some(3);
2351
2352        presenter.reset();
2353
2354        // All state should be cleared
2355        assert!(
2356            presenter.current_link.is_none(),
2357            "current_link should be None after reset"
2358        );
2359        assert!(
2360            presenter.current_style.is_none(),
2361            "current_style should be None after reset"
2362        );
2363        assert!(
2364            presenter.cursor_x.is_none(),
2365            "cursor_x should be None after reset"
2366        );
2367        assert!(
2368            presenter.cursor_y.is_none(),
2369            "cursor_y should be None after reset"
2370        );
2371    }
2372
2373    #[test]
2374    fn link_transitions_linked_unlinked_linked() {
2375        let mut presenter = test_presenter_with_hyperlinks();
2376        let mut buffer = Buffer::new(5, 1);
2377        let mut links = LinkRegistry::new();
2378
2379        let link_id = links.register("https://toggle.test");
2380
2381        // Linked -> Unlinked -> Linked pattern
2382        buffer.set_raw(
2383            0,
2384            0,
2385            Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2386        );
2387        buffer.set_raw(1, 0, Cell::from_char('B')); // no link
2388        buffer.set_raw(
2389            2,
2390            0,
2391            Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2392        );
2393
2394        let old = Buffer::new(5, 1);
2395        let diff = BufferDiff::compute(&old, &buffer);
2396
2397        presenter
2398            .present_with_pool(&buffer, &diff, None, Some(&links))
2399            .unwrap();
2400        let output = get_output(presenter);
2401        let output_str = String::from_utf8_lossy(&output);
2402
2403        // Link URL should appear at least twice (once for A, once for C)
2404        let url_count = output_str.matches("https://toggle.test").count();
2405        assert!(
2406            url_count >= 2,
2407            "Expected link to open at least twice, got {} occurrences",
2408            url_count
2409        );
2410
2411        // Close sequence should appear (after A, and at frame end)
2412        let close_count = output_str.matches("\x1b]8;;\x07").count();
2413        assert!(
2414            close_count >= 2,
2415            "Expected at least 2 link closes, got {}",
2416            close_count
2417        );
2418    }
2419
2420    // =========================================================================
2421    // Multiple frame tests
2422    // =========================================================================
2423
2424    #[test]
2425    fn multiple_presents_maintain_correct_state() {
2426        let mut presenter = test_presenter();
2427        let mut buffer = Buffer::new(10, 1);
2428
2429        // First frame
2430        buffer.set_raw(0, 0, Cell::from_char('1'));
2431        let old = Buffer::new(10, 1);
2432        let diff = BufferDiff::compute(&old, &buffer);
2433        presenter.present(&buffer, &diff).unwrap();
2434
2435        // Second frame - change a different cell
2436        let prev = buffer.clone();
2437        buffer.set_raw(1, 0, Cell::from_char('2'));
2438        let diff = BufferDiff::compute(&prev, &buffer);
2439        presenter.present(&buffer, &diff).unwrap();
2440
2441        // Third frame - change another cell
2442        let prev = buffer.clone();
2443        buffer.set_raw(2, 0, Cell::from_char('3'));
2444        let diff = BufferDiff::compute(&prev, &buffer);
2445        presenter.present(&buffer, &diff).unwrap();
2446
2447        let output = get_output(presenter);
2448        let output_str = String::from_utf8_lossy(&output);
2449
2450        // All numbers should appear in final output
2451        assert!(output_str.contains('1'));
2452        assert!(output_str.contains('2'));
2453        assert!(output_str.contains('3'));
2454    }
2455
2456    // =========================================================================
2457    // SGR Delta Engine tests (bd-4kq0.2.1)
2458    // =========================================================================
2459
2460    #[test]
2461    fn sgr_delta_fg_only_change_no_reset() {
2462        // When only fg changes, delta should NOT emit reset
2463        let mut presenter = test_presenter();
2464        let mut buffer = Buffer::new(3, 1);
2465
2466        let fg1 = PackedRgba::rgb(255, 0, 0);
2467        let fg2 = PackedRgba::rgb(0, 255, 0);
2468        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2469        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2470
2471        let old = Buffer::new(3, 1);
2472        let diff = BufferDiff::compute(&old, &buffer);
2473
2474        presenter.present(&buffer, &diff).unwrap();
2475        let output = get_output(presenter);
2476        let output_str = String::from_utf8_lossy(&output);
2477
2478        // Count SGR resets - the first cell needs a reset (from None state),
2479        // but the second cell should use delta (no reset)
2480        let reset_count = output_str.matches("\x1b[0m").count();
2481        // One reset at start (for first cell from unknown state) + one at frame end
2482        assert_eq!(
2483            reset_count, 2,
2484            "Expected 2 resets (initial + frame end), got {} in: {:?}",
2485            reset_count, output_str
2486        );
2487    }
2488
2489    #[test]
2490    fn sgr_delta_bg_only_change_no_reset() {
2491        let mut presenter = test_presenter();
2492        let mut buffer = Buffer::new(3, 1);
2493
2494        let bg1 = PackedRgba::rgb(0, 0, 255);
2495        let bg2 = PackedRgba::rgb(255, 255, 0);
2496        buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2497        buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2498
2499        let old = Buffer::new(3, 1);
2500        let diff = BufferDiff::compute(&old, &buffer);
2501
2502        presenter.present(&buffer, &diff).unwrap();
2503        let output = get_output(presenter);
2504        let output_str = String::from_utf8_lossy(&output);
2505
2506        // Only 2 resets: initial cell + frame end
2507        let reset_count = output_str.matches("\x1b[0m").count();
2508        assert_eq!(
2509            reset_count, 2,
2510            "Expected 2 resets, got {} in: {:?}",
2511            reset_count, output_str
2512        );
2513    }
2514
2515    #[test]
2516    fn sgr_delta_attr_addition_no_reset() {
2517        let mut presenter = test_presenter();
2518        let mut buffer = Buffer::new(3, 1);
2519
2520        // First cell: bold. Second cell: bold + italic
2521        let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2522        let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2523        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2524        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2525
2526        let old = Buffer::new(3, 1);
2527        let diff = BufferDiff::compute(&old, &buffer);
2528
2529        presenter.present(&buffer, &diff).unwrap();
2530        let output = get_output(presenter);
2531        let output_str = String::from_utf8_lossy(&output);
2532
2533        // Second cell should add italic (code 3) without reset
2534        let reset_count = output_str.matches("\x1b[0m").count();
2535        assert_eq!(
2536            reset_count, 2,
2537            "Expected 2 resets, got {} in: {:?}",
2538            reset_count, output_str
2539        );
2540        // Should contain italic-on code for the delta
2541        assert!(
2542            output_str.contains("\x1b[3m"),
2543            "Expected italic-on sequence in: {:?}",
2544            output_str
2545        );
2546    }
2547
2548    #[test]
2549    fn sgr_delta_attr_removal_uses_off_code() {
2550        let mut presenter = test_presenter();
2551        let mut buffer = Buffer::new(3, 1);
2552
2553        // First cell: bold+italic. Second cell: bold only
2554        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2555        let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2556        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2557        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2558
2559        let old = Buffer::new(3, 1);
2560        let diff = BufferDiff::compute(&old, &buffer);
2561
2562        presenter.present(&buffer, &diff).unwrap();
2563        let output = get_output(presenter);
2564        let output_str = String::from_utf8_lossy(&output);
2565
2566        // Should contain italic-off code (23) for delta
2567        assert!(
2568            output_str.contains("\x1b[23m"),
2569            "Expected italic-off sequence in: {:?}",
2570            output_str
2571        );
2572        // Only 2 resets (initial + frame end), not 3
2573        let reset_count = output_str.matches("\x1b[0m").count();
2574        assert_eq!(
2575            reset_count, 2,
2576            "Expected 2 resets, got {} in: {:?}",
2577            reset_count, output_str
2578        );
2579    }
2580
2581    #[test]
2582    fn sgr_delta_bold_dim_collateral_re_enables() {
2583        // Bold off (code 22) also disables Dim. If Dim should remain,
2584        // the delta engine must re-enable it.
2585        let mut presenter = test_presenter();
2586        let mut buffer = Buffer::new(3, 1);
2587
2588        // First cell: Bold + Dim. Second cell: Dim only
2589        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2590        let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2591        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2592        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2593
2594        let old = Buffer::new(3, 1);
2595        let diff = BufferDiff::compute(&old, &buffer);
2596
2597        presenter.present(&buffer, &diff).unwrap();
2598        let output = get_output(presenter);
2599        let output_str = String::from_utf8_lossy(&output);
2600
2601        // Should contain bold-off (22) and then dim re-enable (2)
2602        assert!(
2603            output_str.contains("\x1b[22m"),
2604            "Expected bold-off (22) in: {:?}",
2605            output_str
2606        );
2607        assert!(
2608            output_str.contains("\x1b[2m"),
2609            "Expected dim re-enable (2) in: {:?}",
2610            output_str
2611        );
2612    }
2613
2614    #[test]
2615    fn sgr_delta_same_style_no_output() {
2616        let mut presenter = test_presenter();
2617        let mut buffer = Buffer::new(3, 1);
2618
2619        let fg = PackedRgba::rgb(255, 0, 0);
2620        let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2621        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2622        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2623        buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2624
2625        let old = Buffer::new(3, 1);
2626        let diff = BufferDiff::compute(&old, &buffer);
2627
2628        presenter.present(&buffer, &diff).unwrap();
2629        let output = get_output(presenter);
2630        let output_str = String::from_utf8_lossy(&output);
2631
2632        // Only 1 fg color sequence (style set once for all three cells)
2633        let fg_count = output_str.matches("38;2;255;0;0").count();
2634        assert_eq!(
2635            fg_count, 1,
2636            "Expected 1 fg sequence, got {} in: {:?}",
2637            fg_count, output_str
2638        );
2639    }
2640
2641    #[test]
2642    fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2643        // Test that delta output is never larger than reset+apply would be
2644        // for a variety of style transitions
2645        let transitions: Vec<(CellStyle, CellStyle)> = vec![
2646            // Only fg change
2647            (
2648                CellStyle {
2649                    fg: PackedRgba::rgb(255, 0, 0),
2650                    bg: PackedRgba::TRANSPARENT,
2651                    attrs: StyleFlags::empty(),
2652                },
2653                CellStyle {
2654                    fg: PackedRgba::rgb(0, 255, 0),
2655                    bg: PackedRgba::TRANSPARENT,
2656                    attrs: StyleFlags::empty(),
2657                },
2658            ),
2659            // Only bg change
2660            (
2661                CellStyle {
2662                    fg: PackedRgba::TRANSPARENT,
2663                    bg: PackedRgba::rgb(255, 0, 0),
2664                    attrs: StyleFlags::empty(),
2665                },
2666                CellStyle {
2667                    fg: PackedRgba::TRANSPARENT,
2668                    bg: PackedRgba::rgb(0, 0, 255),
2669                    attrs: StyleFlags::empty(),
2670                },
2671            ),
2672            // Only attr addition
2673            (
2674                CellStyle {
2675                    fg: PackedRgba::rgb(100, 100, 100),
2676                    bg: PackedRgba::TRANSPARENT,
2677                    attrs: StyleFlags::BOLD,
2678                },
2679                CellStyle {
2680                    fg: PackedRgba::rgb(100, 100, 100),
2681                    bg: PackedRgba::TRANSPARENT,
2682                    attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2683                },
2684            ),
2685            // Attr removal
2686            (
2687                CellStyle {
2688                    fg: PackedRgba::rgb(100, 100, 100),
2689                    bg: PackedRgba::TRANSPARENT,
2690                    attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2691                },
2692                CellStyle {
2693                    fg: PackedRgba::rgb(100, 100, 100),
2694                    bg: PackedRgba::TRANSPARENT,
2695                    attrs: StyleFlags::BOLD,
2696                },
2697            ),
2698        ];
2699
2700        for (old_style, new_style) in &transitions {
2701            // Measure delta cost
2702            let delta_buf = {
2703                let mut delta_presenter = {
2704                    let caps = TerminalCapabilities::basic();
2705                    Presenter::new(Vec::new(), caps)
2706                };
2707                delta_presenter.current_style = Some(*old_style);
2708                delta_presenter
2709                    .emit_style_delta(*old_style, *new_style)
2710                    .unwrap();
2711                delta_presenter.into_inner().unwrap()
2712            };
2713
2714            // Measure reset+apply cost
2715            let reset_buf = {
2716                let mut reset_presenter = {
2717                    let caps = TerminalCapabilities::basic();
2718                    Presenter::new(Vec::new(), caps)
2719                };
2720                reset_presenter.emit_style_full(*new_style).unwrap();
2721                reset_presenter.into_inner().unwrap()
2722            };
2723
2724            assert!(
2725                delta_buf.len() <= reset_buf.len(),
2726                "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2727                 Delta: {:?}\nReset: {:?}",
2728                delta_buf.len(),
2729                reset_buf.len(),
2730                old_style,
2731                new_style,
2732                String::from_utf8_lossy(&delta_buf),
2733                String::from_utf8_lossy(&reset_buf),
2734            );
2735        }
2736    }
2737
2738    /// Generate a deterministic JSONL evidence ledger proving the SGR delta engine
2739    /// emits fewer (or equal) bytes than reset+apply for every transition.
2740    ///
2741    /// Each line is a JSON object with:
2742    ///   seed, from_fg, from_bg, from_attrs, to_fg, to_bg, to_attrs,
2743    ///   delta_bytes, baseline_bytes, cost_delta, used_fallback
2744    #[test]
2745    fn sgr_delta_evidence_ledger() {
2746        use std::io::Write as _;
2747
2748        // Deterministic seed for reproducibility
2749        const SEED: u64 = 0xDEAD_BEEF_CAFE;
2750
2751        // Simple LCG for deterministic pseudorandom values
2752        let mut rng_state = SEED;
2753        let mut next_u64 = || -> u64 {
2754            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2755            rng_state
2756        };
2757
2758        let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2759            let v = rng();
2760            let fg = if v & 1 == 0 {
2761                PackedRgba::TRANSPARENT
2762            } else {
2763                let r = ((v >> 8) & 0xFF) as u8;
2764                let g = ((v >> 16) & 0xFF) as u8;
2765                let b = ((v >> 24) & 0xFF) as u8;
2766                PackedRgba::rgb(r, g, b)
2767            };
2768            let v2 = rng();
2769            let bg = if v2 & 1 == 0 {
2770                PackedRgba::TRANSPARENT
2771            } else {
2772                let r = ((v2 >> 8) & 0xFF) as u8;
2773                let g = ((v2 >> 16) & 0xFF) as u8;
2774                let b = ((v2 >> 24) & 0xFF) as u8;
2775                PackedRgba::rgb(r, g, b)
2776            };
2777            let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2778            CellStyle { fg, bg, attrs }
2779        };
2780
2781        let mut ledger = Vec::new();
2782        let num_transitions = 200;
2783
2784        for i in 0..num_transitions {
2785            let old_style = random_style(&mut next_u64);
2786            let new_style = random_style(&mut next_u64);
2787
2788            // Measure delta cost
2789            let mut delta_p = {
2790                let caps = TerminalCapabilities::basic();
2791                Presenter::new(Vec::new(), caps)
2792            };
2793            delta_p.current_style = Some(old_style);
2794            delta_p.emit_style_delta(old_style, new_style).unwrap();
2795            let delta_out = delta_p.into_inner().unwrap();
2796
2797            // Measure reset+apply cost
2798            let mut reset_p = {
2799                let caps = TerminalCapabilities::basic();
2800                Presenter::new(Vec::new(), caps)
2801            };
2802            reset_p.emit_style_full(new_style).unwrap();
2803            let reset_out = reset_p.into_inner().unwrap();
2804
2805            let delta_bytes = delta_out.len();
2806            let baseline_bytes = reset_out.len();
2807
2808            // Compute whether fallback was used (delta >= baseline means fallback likely)
2809            let attrs_removed = old_style.attrs & !new_style.attrs;
2810            let removed_count = attrs_removed.bits().count_ones();
2811            let fg_changed = old_style.fg != new_style.fg;
2812            let bg_changed = old_style.bg != new_style.bg;
2813            let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2814
2815            // Assert cost dominance
2816            assert!(
2817                delta_bytes <= baseline_bytes,
2818                "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2819            );
2820
2821            // Emit JSONL record
2822            writeln!(
2823                &mut ledger,
2824                "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2825                 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2826                 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2827                 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2828                old_style.fg,
2829                old_style.bg,
2830                old_style.attrs.bits(),
2831                new_style.fg,
2832                new_style.bg,
2833                new_style.attrs.bits(),
2834                baseline_bytes as isize - delta_bytes as isize,
2835            )
2836            .unwrap();
2837        }
2838
2839        // Verify we produced valid JSONL (every line parses)
2840        let text = String::from_utf8(ledger).unwrap();
2841        let lines: Vec<&str> = text.lines().collect();
2842        assert_eq!(lines.len(), num_transitions);
2843
2844        // Verify aggregate: total savings should be non-negative
2845        let mut total_saved: isize = 0;
2846        for line in &lines {
2847            // Quick parse of cost_delta field
2848            let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2849            let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2850            let cd: isize = line[cd_start..cd_end].parse().unwrap();
2851            total_saved += cd;
2852        }
2853        assert!(
2854            total_saved >= 0,
2855            "Total byte savings should be non-negative, got {total_saved}"
2856        );
2857    }
2858
2859    /// E2E style stress test: scripted style churn across a full buffer
2860    /// with byte metrics proving delta engine correctness under load.
2861    #[test]
2862    fn e2e_style_stress_with_byte_metrics() {
2863        let width = 40u16;
2864        let height = 10u16;
2865
2866        // Build a buffer with maximum style diversity
2867        let mut buffer = Buffer::new(width, height);
2868        for y in 0..height {
2869            for x in 0..width {
2870                let i = (y as usize * width as usize + x as usize) as u8;
2871                let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2872                let bg = if i.is_multiple_of(4) {
2873                    PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2874                } else {
2875                    PackedRgba::TRANSPARENT
2876                };
2877                let flags = StyleFlags::from_bits_truncate(i % 128);
2878                let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2879                let cell = Cell::from_char(ch)
2880                    .with_fg(fg)
2881                    .with_bg(bg)
2882                    .with_attrs(CellAttrs::new(flags, 0));
2883                buffer.set_raw(x, y, cell);
2884            }
2885        }
2886
2887        // Present from blank (first frame)
2888        let blank = Buffer::new(width, height);
2889        let diff = BufferDiff::compute(&blank, &buffer);
2890        let mut presenter = test_presenter();
2891        presenter.present(&buffer, &diff).unwrap();
2892        let frame1_bytes = presenter.into_inner().unwrap().len();
2893
2894        // Build second buffer: shift all styles by one position (churn)
2895        let mut buffer2 = Buffer::new(width, height);
2896        for y in 0..height {
2897            for x in 0..width {
2898                let i = (y as usize * width as usize + x as usize + 1) as u8;
2899                let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2900                let bg = if i.is_multiple_of(4) {
2901                    PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2902                } else {
2903                    PackedRgba::TRANSPARENT
2904                };
2905                let flags = StyleFlags::from_bits_truncate(i % 128);
2906                let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2907                let cell = Cell::from_char(ch)
2908                    .with_fg(fg)
2909                    .with_bg(bg)
2910                    .with_attrs(CellAttrs::new(flags, 0));
2911                buffer2.set_raw(x, y, cell);
2912            }
2913        }
2914
2915        // Second frame: incremental update should use delta engine
2916        let diff2 = BufferDiff::compute(&buffer, &buffer2);
2917        let mut presenter2 = test_presenter();
2918        presenter2.present(&buffer2, &diff2).unwrap();
2919        let frame2_bytes = presenter2.into_inner().unwrap().len();
2920
2921        // Incremental should be smaller than full redraw since delta
2922        // engine can reuse partial style state
2923        assert!(
2924            frame2_bytes > 0,
2925            "Second frame should produce output for style churn"
2926        );
2927        assert!(!diff2.is_empty(), "Style shift should produce changes");
2928
2929        // Verify frame2 is at most frame1 size (delta should never be worse
2930        // than a full redraw for the same number of changed cells)
2931        // Note: frame2 may differ in size due to different diff (changed cells
2932        // vs all cells), so just verify it's reasonable.
2933        assert!(
2934            frame2_bytes <= frame1_bytes * 2,
2935            "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2936        );
2937    }
2938
2939    // =========================================================================
2940    // DP Cost Model Tests (bd-4kq0.2.2)
2941    // =========================================================================
2942
2943    #[test]
2944    fn cost_model_empty_row_single_run() {
2945        // Single run on a row should always use Sparse (no merge benefit)
2946        let runs = [ChangeRun::new(5, 10, 20)];
2947        let plan = cost_model::plan_row(&runs, None, None);
2948        assert_eq!(plan.spans().len(), 1);
2949        assert_eq!(plan.spans()[0].x0, 10);
2950        assert_eq!(plan.spans()[0].x1, 20);
2951        assert!(plan.total_cost() > 0);
2952    }
2953
2954    #[test]
2955    fn cost_model_full_row_merges() {
2956        // Two small runs far apart on same row - gap is smaller than 2x CUP overhead
2957        // Runs at columns 0-2 and 77-79 on an 80-col row
2958        // Sparse: CUP + 3 cells + CUP + 3 cells
2959        // Merged: CUP + 80 cells but with gap overhead
2960        // This should stay sparse since the gap is very large
2961        let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2962        let plan = cost_model::plan_row(&runs, None, None);
2963        // Large gap (74 cells * 2 overhead = 148) vs CUP savings (~8) => no merge.
2964        assert_eq!(plan.spans().len(), 2);
2965        assert_eq!(plan.spans()[0].x0, 0);
2966        assert_eq!(plan.spans()[0].x1, 2);
2967        assert_eq!(plan.spans()[1].x0, 77);
2968        assert_eq!(plan.spans()[1].x1, 79);
2969    }
2970
2971    #[test]
2972    fn cost_model_adjacent_runs_merge() {
2973        // Many single-cell runs with 1-cell gaps should merge
2974        // 8 single-cell runs at columns 10, 12, 14, 16, 18, 20, 22, 24
2975        let runs = [
2976            ChangeRun::new(3, 10, 10),
2977            ChangeRun::new(3, 12, 12),
2978            ChangeRun::new(3, 14, 14),
2979            ChangeRun::new(3, 16, 16),
2980            ChangeRun::new(3, 18, 18),
2981            ChangeRun::new(3, 20, 20),
2982            ChangeRun::new(3, 22, 22),
2983            ChangeRun::new(3, 24, 24),
2984        ];
2985        let plan = cost_model::plan_row(&runs, None, None);
2986        // Sparse: 1 CUP + 7 CUF(2) * 4 bytes + 8 cells = ~7+28+8 = 43
2987        // Merged: 1 CUP + 8 changed + 7 gap * 2 = 7+8+14 = 29
2988        assert_eq!(plan.spans().len(), 1);
2989        assert_eq!(plan.spans()[0].x0, 10);
2990        assert_eq!(plan.spans()[0].x1, 24);
2991    }
2992
2993    #[test]
2994    fn cost_model_single_cell_stays_sparse() {
2995        let runs = [ChangeRun::new(0, 40, 40)];
2996        let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2997        assert_eq!(plan.spans().len(), 1);
2998        assert_eq!(plan.spans()[0].x0, 40);
2999        assert_eq!(plan.spans()[0].x1, 40);
3000    }
3001
3002    #[test]
3003    fn cost_model_cup_vs_cha_vs_cuf() {
3004        // CUF should be cheapest for small forward moves on same row
3005        assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
3006        assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
3007
3008        // CHA should be cheapest for backward moves on same row (vs CUP)
3009        let cha = cost_model::cha_cost(5);
3010        let cup = cost_model::cup_cost(0, 5);
3011        assert!(cha <= cup);
3012
3013        // Cheapest move from known position (same row, forward 1)
3014        let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
3015        assert_eq!(cost, 3); // CUF(1) = "\x1b[C" = 3 bytes
3016    }
3017
3018    #[test]
3019    fn cost_model_digit_estimation_accuracy() {
3020        // Verify CUP cost estimates are accurate by comparing to actual output
3021        let mut buf = Vec::new();
3022        ansi::cup(&mut buf, 0, 0).unwrap();
3023        assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
3024
3025        buf.clear();
3026        ansi::cup(&mut buf, 9, 9).unwrap();
3027        assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
3028
3029        buf.clear();
3030        ansi::cup(&mut buf, 99, 99).unwrap();
3031        assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
3032
3033        buf.clear();
3034        ansi::cha(&mut buf, 0).unwrap();
3035        assert_eq!(buf.len(), cost_model::cha_cost(0));
3036
3037        buf.clear();
3038        ansi::cuf(&mut buf, 1).unwrap();
3039        assert_eq!(buf.len(), cost_model::cuf_cost(1));
3040
3041        buf.clear();
3042        ansi::cuf(&mut buf, 10).unwrap();
3043        assert_eq!(buf.len(), cost_model::cuf_cost(10));
3044    }
3045
3046    #[test]
3047    fn cost_model_merged_row_produces_correct_output() {
3048        // Verify that merged emission produces the same visual result as sparse
3049        let width = 30u16;
3050        let mut buffer = Buffer::new(width, 1);
3051
3052        // Set up scattered changes: columns 5, 10, 15, 20
3053        for col in [5u16, 10, 15, 20] {
3054            let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3055            buffer.set_raw(col, 0, Cell::from_char(ch));
3056        }
3057
3058        let old = Buffer::new(width, 1);
3059        let diff = BufferDiff::compute(&old, &buffer);
3060
3061        // Present and verify output contains expected characters
3062        let mut presenter = test_presenter();
3063        presenter.present(&buffer, &diff).unwrap();
3064        let output = presenter.into_inner().unwrap();
3065        let output_str = String::from_utf8_lossy(&output);
3066
3067        for col in [5u16, 10, 15, 20] {
3068            let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3069            assert!(
3070                output_str.contains(ch),
3071                "Missing character '{ch}' at col {col} in output"
3072            );
3073        }
3074    }
3075
3076    #[test]
3077    fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
3078        // Verify move_cursor_optimal uses CUF for small forward moves
3079        let mut presenter = test_presenter();
3080        presenter.cursor_x = Some(5);
3081        presenter.cursor_y = Some(0);
3082        presenter.move_cursor_optimal(6, 0).unwrap();
3083        let output = presenter.into_inner().unwrap();
3084        // CUF(1) = "\x1b[C"
3085        assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
3086    }
3087
3088    #[test]
3089    fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
3090        let mut presenter = test_presenter();
3091        presenter.cursor_x = Some(10);
3092        presenter.cursor_y = Some(3);
3093
3094        let target_x = 2;
3095        let target_y = 3;
3096        let cha_cost = cost_model::cha_cost(target_x);
3097        let cup_cost = cost_model::cup_cost(target_y, target_x);
3098        assert!(
3099            cha_cost <= cup_cost,
3100            "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
3101        );
3102
3103        presenter.move_cursor_optimal(target_x, target_y).unwrap();
3104        let output = presenter.into_inner().unwrap();
3105        let mut expected = Vec::new();
3106        ansi::cha(&mut expected, target_x).unwrap();
3107        assert_eq!(output, expected, "Should use CHA for backward move");
3108    }
3109
3110    #[test]
3111    fn cost_model_optimal_cursor_uses_cup_on_row_change() {
3112        let mut presenter = test_presenter();
3113        presenter.cursor_x = Some(4);
3114        presenter.cursor_y = Some(1);
3115
3116        presenter.move_cursor_optimal(7, 4).unwrap();
3117        let output = presenter.into_inner().unwrap();
3118        let mut expected = Vec::new();
3119        ansi::cup(&mut expected, 4, 7).unwrap();
3120        assert_eq!(output, expected, "Should use CUP when row changes");
3121    }
3122
3123    #[test]
3124    fn cost_model_chooses_full_row_when_cheaper() {
3125        // Create a scenario where merged is definitely cheaper:
3126        // 10 single-cell runs with 1-cell gaps on the same row
3127        let width = 40u16;
3128        let mut buffer = Buffer::new(width, 1);
3129
3130        // Every other column: 0, 2, 4, 6, 8, 10, 12, 14, 16, 18
3131        for col in (0..20).step_by(2) {
3132            buffer.set_raw(col, 0, Cell::from_char('X'));
3133        }
3134
3135        let old = Buffer::new(width, 1);
3136        let diff = BufferDiff::compute(&old, &buffer);
3137        let runs = diff.runs();
3138
3139        // The cost model should merge (many small gaps < many CUP costs)
3140        let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3141        if row_runs.len() > 1 {
3142            let plan = cost_model::plan_row(&row_runs, None, None);
3143            assert!(
3144                plan.spans().len() == 1,
3145                "Expected single merged span for many small runs, got {} spans",
3146                plan.spans().len()
3147            );
3148            assert_eq!(plan.spans()[0].x0, 0);
3149            assert_eq!(plan.spans()[0].x1, 18);
3150        }
3151    }
3152
3153    #[test]
3154    fn perf_cost_model_overhead() {
3155        // Verify the cost model planning is fast (microsecond scale)
3156        use std::time::Instant;
3157
3158        let runs: Vec<ChangeRun> = (0..100)
3159            .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
3160            .collect();
3161
3162        let (iterations, max_ms) = if cfg!(debug_assertions) {
3163            (1_000, 1_000u128)
3164        } else {
3165            (10_000, 500u128)
3166        };
3167
3168        let start = Instant::now();
3169        for _ in 0..iterations {
3170            let _ = cost_model::plan_row(&runs, None, None);
3171        }
3172        let elapsed = start.elapsed();
3173
3174        // Keep this generous in debug builds to avoid flaky perf assertions.
3175        assert!(
3176            elapsed.as_millis() < max_ms,
3177            "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
3178        );
3179    }
3180
3181    #[test]
3182    fn perf_legacy_vs_dp_worst_case_sparse() {
3183        use std::time::Instant;
3184
3185        let width = 200u16;
3186        let height = 1u16;
3187        let mut buffer = Buffer::new(width, height);
3188
3189        // Two dense clusters with a large gap between them.
3190        for col in (0..40).step_by(2) {
3191            buffer.set_raw(col, 0, Cell::from_char('X'));
3192        }
3193        for col in (160..200).step_by(2) {
3194            buffer.set_raw(col, 0, Cell::from_char('Y'));
3195        }
3196
3197        let blank = Buffer::new(width, height);
3198        let diff = BufferDiff::compute(&blank, &buffer);
3199        let runs = diff.runs();
3200        let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3201
3202        let dp_plan = cost_model::plan_row(&row_runs, None, None);
3203        let legacy_spans = legacy_plan_row(&row_runs, None, None);
3204
3205        let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
3206        let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
3207
3208        assert!(
3209            dp_output.len() <= legacy_output.len(),
3210            "DP output should be <= legacy output (dp={}, legacy={})",
3211            dp_output.len(),
3212            legacy_output.len()
3213        );
3214
3215        let (iterations, max_ms) = if cfg!(debug_assertions) {
3216            (1_000, 1_000u128)
3217        } else {
3218            (10_000, 500u128)
3219        };
3220        let start = Instant::now();
3221        for _ in 0..iterations {
3222            let _ = cost_model::plan_row(&row_runs, None, None);
3223        }
3224        let dp_elapsed = start.elapsed();
3225
3226        let start = Instant::now();
3227        for _ in 0..iterations {
3228            let _ = legacy_plan_row(&row_runs, None, None);
3229        }
3230        let legacy_elapsed = start.elapsed();
3231
3232        assert!(
3233            dp_elapsed.as_millis() < max_ms,
3234            "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
3235        );
3236
3237        let _ = legacy_elapsed;
3238    }
3239
3240    // =========================================================================
3241    // Presenter Perf + Golden Outputs (bd-4kq0.2.3)
3242    // =========================================================================
3243
3244    /// Build a deterministic "style-heavy" scene: every cell has a unique style.
3245    fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
3246        let mut buffer = Buffer::new(width, height);
3247        let mut rng = seed;
3248        let mut next = || -> u64 {
3249            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3250            rng
3251        };
3252        for y in 0..height {
3253            for x in 0..width {
3254                let v = next();
3255                let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3256                let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
3257                let bg = if v & 3 == 0 {
3258                    PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
3259                } else {
3260                    PackedRgba::TRANSPARENT
3261                };
3262                let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
3263                let cell = Cell::from_char(ch)
3264                    .with_fg(fg)
3265                    .with_bg(bg)
3266                    .with_attrs(CellAttrs::new(flags, 0));
3267                buffer.set_raw(x, y, cell);
3268            }
3269        }
3270        buffer
3271    }
3272
3273    /// Build a "sparse-update" scene: only ~10% of cells differ between frames.
3274    fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
3275        let mut buffer = base.clone();
3276        let width = base.width();
3277        let height = base.height();
3278        let mut rng = seed;
3279        let mut next = || -> u64 {
3280            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3281            rng
3282        };
3283        let change_count = (width as usize * height as usize) / 10;
3284        for _ in 0..change_count {
3285            let v = next();
3286            let x = (v % width as u64) as u16;
3287            let y = ((v >> 16) % height as u64) as u16;
3288            let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
3289            buffer.set_raw(x, y, Cell::from_char(ch));
3290        }
3291        buffer
3292    }
3293
3294    #[test]
3295    fn snapshot_presenter_equivalence() {
3296        // Golden snapshot: style-heavy 40x10 scene with deterministic seed.
3297        // The output hash must be stable across runs.
3298        let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
3299        let blank = Buffer::new(40, 10);
3300        let diff = BufferDiff::compute(&blank, &buffer);
3301
3302        let mut presenter = test_presenter();
3303        presenter.present(&buffer, &diff).unwrap();
3304        let output = presenter.into_inner().unwrap();
3305
3306        // Compute checksum for golden comparison
3307        let checksum = {
3308            let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
3309            for &byte in &output {
3310                hash ^= byte as u64;
3311                hash = hash.wrapping_mul(0x100000001b3); // FNV prime
3312            }
3313            hash
3314        };
3315
3316        // Verify determinism: same seed + scene = same output
3317        let mut presenter2 = test_presenter();
3318        presenter2.present(&buffer, &diff).unwrap();
3319        let output2 = presenter2.into_inner().unwrap();
3320        assert_eq!(output, output2, "Presenter output must be deterministic");
3321
3322        // Log golden checksum for the record
3323        let _ = checksum; // Used in JSONL test below
3324    }
3325
3326    #[test]
3327    fn perf_presenter_microbench() {
3328        use std::env;
3329        use std::io::Write as _;
3330        use std::time::Instant;
3331
3332        let width = 120u16;
3333        let height = 40u16;
3334        let seed = 0x00BE_EFCA_FE42;
3335        let scene = build_style_heavy_scene(width, height, seed);
3336        let blank = Buffer::new(width, height);
3337        let diff_full = BufferDiff::compute(&blank, &scene);
3338
3339        // Also build a sparse update scene
3340        let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
3341        let diff_sparse = BufferDiff::compute(&scene, &scene2);
3342
3343        let mut jsonl = Vec::new();
3344        let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
3345            .ok()
3346            .and_then(|value| value.parse::<u32>().ok())
3347            .unwrap_or(50);
3348
3349        let runs_full = diff_full.runs();
3350        let runs_sparse = diff_sparse.runs();
3351
3352        let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
3353            let mut idx = 0;
3354            let mut total_cost = 0usize;
3355            let mut span_count = 0usize;
3356            let mut prev_x = None;
3357            let mut prev_y = None;
3358
3359            while idx < runs.len() {
3360                let y = runs[idx].y;
3361                let start = idx;
3362                while idx < runs.len() && runs[idx].y == y {
3363                    idx += 1;
3364                }
3365
3366                let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
3367                span_count += plan.spans().len();
3368                total_cost = total_cost.saturating_add(plan.total_cost());
3369                if let Some(last) = plan.spans().last() {
3370                    prev_x = Some(last.x1);
3371                    prev_y = Some(y);
3372                }
3373            }
3374
3375            (total_cost, span_count)
3376        };
3377
3378        for i in 0..iterations {
3379            let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
3380                (&diff_full, &scene, &runs_full, "full")
3381            } else {
3382                (&diff_sparse, &scene2, &runs_sparse, "sparse")
3383            };
3384
3385            let plan_start = Instant::now();
3386            let (plan_cost, plan_spans) = plan_rows(runs_ref);
3387            let plan_time_us = plan_start.elapsed().as_micros() as u64;
3388
3389            let mut presenter = test_presenter();
3390            let start = Instant::now();
3391            let stats = presenter.present(buf_ref, diff_ref).unwrap();
3392            let elapsed_us = start.elapsed().as_micros() as u64;
3393            let output = presenter.into_inner().unwrap();
3394
3395            // FNV-1a checksum
3396            let checksum = {
3397                let mut hash: u64 = 0xcbf29ce484222325;
3398                for &b in &output {
3399                    hash ^= b as u64;
3400                    hash = hash.wrapping_mul(0x100000001b3);
3401                }
3402                hash
3403            };
3404
3405            writeln!(
3406                &mut jsonl,
3407                "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
3408                 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
3409                 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
3410                 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
3411                 \"emit_time_us\":{elapsed_us},\
3412                 \"checksum\":\"{checksum:016x}\"}}",
3413                stats.cells_changed, stats.run_count, stats.bytes_emitted,
3414            )
3415            .unwrap();
3416        }
3417
3418        let text = String::from_utf8(jsonl).unwrap();
3419        let lines: Vec<&str> = text.lines().collect();
3420        assert_eq!(lines.len(), iterations as usize);
3421
3422        // Parse and verify: full frames should be deterministic (same checksum)
3423        let full_checksums: Vec<&str> = lines
3424            .iter()
3425            .filter(|l| l.contains("\"full\""))
3426            .map(|l| {
3427                let start = l.find("\"checksum\":\"").unwrap() + 12;
3428                let end = l[start..].find('"').unwrap() + start;
3429                &l[start..end]
3430            })
3431            .collect();
3432        assert!(full_checksums.len() > 1);
3433        assert!(
3434            full_checksums.windows(2).all(|w| w[0] == w[1]),
3435            "Full frame checksums should be identical across runs"
3436        );
3437
3438        // Sparse frame bytes should be less than full frame bytes
3439        let full_bytes: Vec<u64> = lines
3440            .iter()
3441            .filter(|l| l.contains("\"full\""))
3442            .map(|l| {
3443                let start = l.find("\"bytes\":").unwrap() + 8;
3444                let end = l[start..].find(',').unwrap() + start;
3445                l[start..end].parse::<u64>().unwrap()
3446            })
3447            .collect();
3448        let sparse_bytes: Vec<u64> = lines
3449            .iter()
3450            .filter(|l| l.contains("\"sparse\""))
3451            .map(|l| {
3452                let start = l.find("\"bytes\":").unwrap() + 8;
3453                let end = l[start..].find(',').unwrap() + start;
3454                l[start..end].parse::<u64>().unwrap()
3455            })
3456            .collect();
3457
3458        let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
3459        let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
3460        assert!(
3461            avg_sparse < avg_full,
3462            "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3463        );
3464    }
3465
3466    #[test]
3467    fn perf_emit_style_delta_microbench() {
3468        use std::env;
3469        use std::io::Write as _;
3470        use std::time::Instant;
3471
3472        let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3473            .ok()
3474            .and_then(|value| value.parse::<u32>().ok())
3475            .unwrap_or(200);
3476        let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3477        let emit_json = mode != "raw";
3478
3479        let mut styles = Vec::with_capacity(128);
3480        let mut rng = 0x00A5_A51E_AF42_u64;
3481        let mut next = || -> u64 {
3482            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3483            rng
3484        };
3485
3486        for _ in 0..128 {
3487            let v = next();
3488            let fg = PackedRgba::rgb(
3489                (v & 0xFF) as u8,
3490                ((v >> 8) & 0xFF) as u8,
3491                ((v >> 16) & 0xFF) as u8,
3492            );
3493            let bg = PackedRgba::rgb(
3494                ((v >> 24) & 0xFF) as u8,
3495                ((v >> 32) & 0xFF) as u8,
3496                ((v >> 40) & 0xFF) as u8,
3497            );
3498            let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3499            let cell = Cell::from_char('A')
3500                .with_fg(fg)
3501                .with_bg(bg)
3502                .with_attrs(CellAttrs::new(flags, 0));
3503            styles.push(CellStyle::from_cell(&cell));
3504        }
3505
3506        let mut presenter = test_presenter();
3507        let mut jsonl = Vec::new();
3508        let mut sink = 0u64;
3509
3510        for i in 0..iterations {
3511            let old = styles[i as usize % styles.len()];
3512            let new = styles[(i as usize + 1) % styles.len()];
3513
3514            presenter.writer.reset_counter();
3515            presenter.writer.inner_mut().get_mut().clear();
3516
3517            let start = Instant::now();
3518            presenter.emit_style_delta(old, new).unwrap();
3519            let elapsed_us = start.elapsed().as_micros() as u64;
3520            let bytes = presenter.writer.bytes_written();
3521
3522            if emit_json {
3523                writeln!(
3524                    &mut jsonl,
3525                    "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3526                )
3527                .unwrap();
3528            } else {
3529                sink = sink.wrapping_add(elapsed_us ^ bytes);
3530            }
3531        }
3532
3533        if emit_json {
3534            let text = String::from_utf8(jsonl).unwrap();
3535            let lines: Vec<&str> = text.lines().collect();
3536            assert_eq!(lines.len() as u32, iterations);
3537        } else {
3538            std::hint::black_box(sink);
3539        }
3540    }
3541
3542    #[test]
3543    fn e2e_presenter_stress_deterministic() {
3544        // Deterministic stress test: seeded style churn across multiple frames,
3545        // verifying no visual divergence via terminal model.
3546        use crate::terminal_model::TerminalModel;
3547
3548        let width = 60u16;
3549        let height = 20u16;
3550        let num_frames = 10;
3551
3552        let mut prev_buffer = Buffer::new(width, height);
3553        let mut presenter = test_presenter();
3554        let mut model = TerminalModel::new(width as usize, height as usize);
3555        let mut rng = 0x5D2E_55DE_5D42_u64;
3556        let mut next = || -> u64 {
3557            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3558            rng
3559        };
3560
3561        for _frame in 0..num_frames {
3562            // Build next frame: modify ~20% of cells each time
3563            let mut buffer = prev_buffer.clone();
3564            let changes = (width as usize * height as usize) / 5;
3565            for _ in 0..changes {
3566                let v = next();
3567                let x = (v % width as u64) as u16;
3568                let y = ((v >> 16) % height as u64) as u16;
3569                let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3570                let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3571                let cell = Cell::from_char(ch).with_fg(fg);
3572                buffer.set_raw(x, y, cell);
3573            }
3574
3575            let diff = BufferDiff::compute(&prev_buffer, &buffer);
3576            presenter.present(&buffer, &diff).unwrap();
3577
3578            prev_buffer = buffer;
3579        }
3580
3581        // Get all output and verify final frame via terminal model
3582        let output = presenter.into_inner().unwrap();
3583        model.process(&output);
3584
3585        // Verify a sampling of cells match the final buffer
3586        let mut checked = 0;
3587        for y in 0..height {
3588            for x in 0..width {
3589                let buf_cell = prev_buffer.get_unchecked(x, y);
3590                if !buf_cell.is_empty()
3591                    && let Some(model_cell) = model.cell(x as usize, y as usize)
3592                {
3593                    let expected = buf_cell.content.as_char().unwrap_or(' ');
3594                    let mut buf = [0u8; 4];
3595                    let expected_str = expected.encode_utf8(&mut buf);
3596                    if model_cell.text.as_str() == expected_str {
3597                        checked += 1;
3598                    }
3599                }
3600            }
3601        }
3602
3603        // At least 80% of non-empty cells should match (some may be
3604        // overwritten by cursor positioning sequences in the model)
3605        let total_nonempty = (0..height)
3606            .flat_map(|y| (0..width).map(move |x| (x, y)))
3607            .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3608            .count();
3609
3610        assert!(
3611            checked > total_nonempty * 80 / 100,
3612            "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3613        );
3614    }
3615
3616    #[test]
3617    fn style_state_persists_across_frames() {
3618        let mut presenter = test_presenter();
3619        let fg = PackedRgba::rgb(100, 150, 200);
3620
3621        // First frame - set style
3622        let mut buffer = Buffer::new(5, 1);
3623        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3624        let old = Buffer::new(5, 1);
3625        let diff = BufferDiff::compute(&old, &buffer);
3626        presenter.present(&buffer, &diff).unwrap();
3627
3628        // Style should be tracked (but reset at frame end per the implementation)
3629        // After present(), current_style is None due to sgr_reset at frame end
3630        assert!(
3631            presenter.current_style.is_none(),
3632            "Style should be reset after frame end"
3633        );
3634    }
3635
3636    // =========================================================================
3637    // Edge-case tests (bd-27tya)
3638    // =========================================================================
3639
3640    // --- Cost model boundary values ---
3641
3642    #[test]
3643    fn cost_cup_zero_zero() {
3644        // CUP at (0,0) → "\x1b[1;1H" = 6 bytes
3645        assert_eq!(cost_model::cup_cost(0, 0), 6);
3646    }
3647
3648    #[test]
3649    fn cost_cup_max_max() {
3650        // CUP at (u16::MAX, u16::MAX) → "\x1b[65536;65536H"
3651        // 2 (CSI) + 5 (row digits) + 1 (;) + 5 (col digits) + 1 (H) = 14
3652        assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
3653    }
3654
3655    #[test]
3656    fn cost_cha_zero() {
3657        // CHA at col 0 → "\x1b[1G" = 4 bytes
3658        assert_eq!(cost_model::cha_cost(0), 4);
3659    }
3660
3661    #[test]
3662    fn cost_cha_max() {
3663        // CHA at col u16::MAX → "\x1b[65536G" = 8 bytes
3664        assert_eq!(cost_model::cha_cost(u16::MAX), 8);
3665    }
3666
3667    #[test]
3668    fn cost_cuf_zero_is_free() {
3669        assert_eq!(cost_model::cuf_cost(0), 0);
3670    }
3671
3672    #[test]
3673    fn cost_cuf_one_is_three() {
3674        // CUF(1) = "\x1b[C" = 3 bytes
3675        assert_eq!(cost_model::cuf_cost(1), 3);
3676    }
3677
3678    #[test]
3679    fn cost_cuf_two_has_digit() {
3680        // CUF(2) = "\x1b[2C" = 4 bytes
3681        assert_eq!(cost_model::cuf_cost(2), 4);
3682    }
3683
3684    #[test]
3685    fn cost_cuf_max() {
3686        // CUF(u16::MAX) = "\x1b[65535C" = 3 + 5 = 8 bytes
3687        assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
3688    }
3689
3690    #[test]
3691    fn cost_cheapest_move_already_at_target() {
3692        assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
3693    }
3694
3695    #[test]
3696    fn cost_cheapest_move_unknown_position() {
3697        // When from is unknown, can only use CUP
3698        let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
3699        assert_eq!(cost, cost_model::cup_cost(3, 5));
3700    }
3701
3702    #[test]
3703    fn cost_cheapest_move_known_y_unknown_x() {
3704        // from_x=None, from_y=Some → still uses CUP
3705        let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
3706        assert_eq!(cost, cost_model::cup_cost(3, 5));
3707    }
3708
3709    #[test]
3710    fn cost_cheapest_move_backward_same_row() {
3711        // On the same row, CUP is strictly dominated by CHA.
3712        let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
3713        let cha = cost_model::cha_cost(5);
3714        let cub = cost_model::cub_cost(45);
3715        assert_eq!(cost, cha.min(cub));
3716        assert!(cost_model::cup_cost(0, 5) > cha);
3717    }
3718
3719    #[test]
3720    fn cost_cheapest_move_forward_same_row() {
3721        let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 50, 0);
3722        let cha = cost_model::cha_cost(50);
3723        let cuf = cost_model::cuf_cost(45);
3724        assert_eq!(cost, cha.min(cuf));
3725        assert!(cost_model::cup_cost(0, 50) > cha);
3726    }
3727
3728    #[test]
3729    fn cost_cheapest_move_same_row_same_col() {
3730        // Same (x, y) via the (fx, fy) == (to_x, to_y) check
3731        assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
3732    }
3733
3734    // --- CUP/CHA/CUF cost accuracy across digit boundaries ---
3735
3736    #[test]
3737    fn cost_cup_digit_boundaries() {
3738        let mut buf = Vec::new();
3739        for (row, col) in [
3740            (0u16, 0u16),
3741            (8, 8),
3742            (9, 9),
3743            (98, 98),
3744            (99, 99),
3745            (998, 998),
3746            (999, 999),
3747            (9998, 9998),
3748            (9999, 9999),
3749            (u16::MAX, u16::MAX),
3750        ] {
3751            buf.clear();
3752            ansi::cup(&mut buf, row, col).unwrap();
3753            assert_eq!(
3754                buf.len(),
3755                cost_model::cup_cost(row, col),
3756                "CUP cost mismatch at ({row}, {col})"
3757            );
3758        }
3759    }
3760
3761    #[test]
3762    fn cost_cha_digit_boundaries() {
3763        let mut buf = Vec::new();
3764        for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
3765            buf.clear();
3766            ansi::cha(&mut buf, col).unwrap();
3767            assert_eq!(
3768                buf.len(),
3769                cost_model::cha_cost(col),
3770                "CHA cost mismatch at col {col}"
3771            );
3772        }
3773    }
3774
3775    #[test]
3776    fn cost_cuf_digit_boundaries() {
3777        let mut buf = Vec::new();
3778        for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
3779            buf.clear();
3780            ansi::cuf(&mut buf, n).unwrap();
3781            assert_eq!(
3782                buf.len(),
3783                cost_model::cuf_cost(n),
3784                "CUF cost mismatch for n={n}"
3785            );
3786        }
3787    }
3788
3789    // --- RowPlan scratch reuse ---
3790
3791    #[test]
3792    fn plan_row_reuse_matches_plan_row() {
3793        let runs = [
3794            ChangeRun::new(5, 2, 4),
3795            ChangeRun::new(5, 8, 10),
3796            ChangeRun::new(5, 20, 25),
3797        ];
3798        let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
3799        let mut scratch = cost_model::RowPlanScratch::default();
3800        let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
3801        assert_eq!(plan1, plan2);
3802    }
3803
3804    #[test]
3805    fn plan_row_reuse_single_run_matches_plan_row() {
3806        let runs = [ChangeRun::new(7, 18, 24)];
3807        let plan1 = cost_model::plan_row(&runs, Some(2), Some(7));
3808        let mut scratch = cost_model::RowPlanScratch::default();
3809        let plan2 = cost_model::plan_row_reuse(&runs, Some(2), Some(7), &mut scratch);
3810        assert_eq!(plan1, plan2);
3811        assert_eq!(
3812            plan2.total_cost(),
3813            cost_model::cheapest_move_cost(Some(2), Some(7), 18, 7) + runs[0].len()
3814        );
3815    }
3816
3817    #[test]
3818    fn plan_row_reuse_across_different_sizes() {
3819        // Use scratch with a large row first, then a small row
3820        let mut scratch = cost_model::RowPlanScratch::default();
3821
3822        let large_runs: Vec<ChangeRun> = (0..20)
3823            .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
3824            .collect();
3825        let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
3826        assert!(!plan_large.spans().is_empty());
3827
3828        let small_runs = [ChangeRun::new(1, 5, 8)];
3829        let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
3830        assert_eq!(plan_small.spans().len(), 1);
3831        assert_eq!(plan_small.spans()[0].x0, 5);
3832        assert_eq!(plan_small.spans()[0].x1, 8);
3833    }
3834
3835    // --- DP gap boundary (exactly 32 and 33 cells) ---
3836
3837    #[test]
3838    fn plan_row_gap_exactly_32_cells() {
3839        // Two runs with exactly 32-cell gap: run at 0-0 and 33-33
3840        // gap = 33 - 0 + 1 - 2 = 32 cells
3841        let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
3842        let plan = cost_model::plan_row(&runs, None, None);
3843        // 32-cell gap is at the break boundary; the DP may still consider merging
3844        // since the check is `gap_cells > 32` (strictly greater)
3845        // gap = 34 total - 2 changed = 32, which is NOT > 32, so merge is considered
3846        assert!(
3847            plan.spans().len() <= 2,
3848            "32-cell gap should still consider merge"
3849        );
3850    }
3851
3852    #[test]
3853    fn plan_row_gap_33_cells_stays_sparse() {
3854        // Two runs with 33-cell gap: run at 0-0 and 34-34
3855        // gap = 34 - 0 + 1 - 2 = 33 > 32, so merge is NOT considered
3856        let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
3857        let plan = cost_model::plan_row(&runs, None, None);
3858        assert_eq!(
3859            plan.spans().len(),
3860            2,
3861            "33-cell gap should stay sparse (gap > 32 breaks)"
3862        );
3863    }
3864
3865    // --- SmallVec spill: >4 separate spans ---
3866
3867    #[test]
3868    fn plan_row_many_sparse_spans() {
3869        // 6 runs with 34+ cell gaps between them (each gap > 32, no merging)
3870        let runs = [
3871            ChangeRun::new(0, 0, 0),
3872            ChangeRun::new(0, 40, 40),
3873            ChangeRun::new(0, 80, 80),
3874            ChangeRun::new(0, 120, 120),
3875            ChangeRun::new(0, 160, 160),
3876            ChangeRun::new(0, 200, 200),
3877        ];
3878        let plan = cost_model::plan_row(&runs, None, None);
3879        // All gaps are > 32, so no merging possible
3880        assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
3881    }
3882
3883    // --- CellStyle ---
3884
3885    #[test]
3886    fn cell_style_default_is_transparent_no_attrs() {
3887        let style = CellStyle::default();
3888        assert_eq!(style.fg, PackedRgba::TRANSPARENT);
3889        assert_eq!(style.bg, PackedRgba::TRANSPARENT);
3890        assert!(style.attrs.is_empty());
3891    }
3892
3893    #[test]
3894    fn cell_style_from_cell_captures_all() {
3895        let fg = PackedRgba::rgb(10, 20, 30);
3896        let bg = PackedRgba::rgb(40, 50, 60);
3897        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
3898        let cell = Cell::from_char('X')
3899            .with_fg(fg)
3900            .with_bg(bg)
3901            .with_attrs(CellAttrs::new(flags, 5));
3902        let style = CellStyle::from_cell(&cell);
3903        assert_eq!(style.fg, fg);
3904        assert_eq!(style.bg, bg);
3905        assert_eq!(style.attrs, flags);
3906    }
3907
3908    #[test]
3909    fn cell_style_eq_and_clone() {
3910        let a = CellStyle {
3911            fg: PackedRgba::rgb(1, 2, 3),
3912            bg: PackedRgba::TRANSPARENT,
3913            attrs: StyleFlags::DIM,
3914        };
3915        let b = a;
3916        assert_eq!(a, b);
3917    }
3918
3919    // --- SGR length estimation ---
3920
3921    #[test]
3922    fn sgr_flags_len_empty() {
3923        assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
3924    }
3925
3926    #[test]
3927    fn sgr_flags_len_single() {
3928        // Single flag: "\x1b[1m" = 4 bytes → 3 + digits(code) + 0 separators
3929        let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
3930        assert!(len > 0);
3931        // Verify by actually emitting
3932        let mut buf = Vec::new();
3933        ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
3934        assert_eq!(len as usize, buf.len());
3935    }
3936
3937    #[test]
3938    fn sgr_flags_len_multiple() {
3939        let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
3940        let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
3941        let mut buf = Vec::new();
3942        ansi::sgr_flags(&mut buf, flags).unwrap();
3943        assert_eq!(len as usize, buf.len());
3944    }
3945
3946    #[test]
3947    fn sgr_flags_off_len_empty() {
3948        assert_eq!(
3949            Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
3950            0
3951        );
3952    }
3953
3954    #[test]
3955    fn sgr_rgb_len_matches_actual() {
3956        let color = PackedRgba::rgb(0, 0, 0);
3957        let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3958        // "\x1b[38;2;0;0;0m" = 2(CSI) + "38;2;" + "0;0;0" + "m" but sgr_rgb_len
3959        // is used for cost comparison, not exact output. Just check > 0.
3960        assert!(estimated > 0);
3961    }
3962
3963    #[test]
3964    fn sgr_rgb_len_large_values() {
3965        let color = PackedRgba::rgb(255, 255, 255);
3966        let small_color = PackedRgba::rgb(0, 0, 0);
3967        let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3968        let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
3969        // 255,255,255 has more digits than 0,0,0
3970        assert!(large_len > small_len);
3971    }
3972
3973    #[test]
3974    fn dec_len_u8_boundaries() {
3975        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
3976        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
3977        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
3978        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
3979        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
3980        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
3981    }
3982
3983    // --- Style delta corner cases ---
3984
3985    #[test]
3986    fn sgr_delta_all_attrs_removed_at_once() {
3987        let mut presenter = test_presenter();
3988        let all_flags = StyleFlags::BOLD
3989            | StyleFlags::DIM
3990            | StyleFlags::ITALIC
3991            | StyleFlags::UNDERLINE
3992            | StyleFlags::BLINK
3993            | StyleFlags::REVERSE
3994            | StyleFlags::STRIKETHROUGH;
3995        let old = CellStyle {
3996            fg: PackedRgba::rgb(100, 100, 100),
3997            bg: PackedRgba::TRANSPARENT,
3998            attrs: all_flags,
3999        };
4000        let new = CellStyle {
4001            fg: PackedRgba::rgb(100, 100, 100),
4002            bg: PackedRgba::TRANSPARENT,
4003            attrs: StyleFlags::empty(),
4004        };
4005
4006        presenter.current_style = Some(old);
4007        presenter.emit_style_delta(old, new).unwrap();
4008        let output = presenter.into_inner().unwrap();
4009
4010        // Should either use individual off codes or fall back to full reset
4011        // Either way, output should be non-empty
4012        assert!(!output.is_empty());
4013    }
4014
4015    #[test]
4016    fn sgr_delta_fg_to_transparent() {
4017        let mut presenter = test_presenter();
4018        let old = CellStyle {
4019            fg: PackedRgba::rgb(200, 100, 50),
4020            bg: PackedRgba::TRANSPARENT,
4021            attrs: StyleFlags::empty(),
4022        };
4023        let new = CellStyle {
4024            fg: PackedRgba::TRANSPARENT,
4025            bg: PackedRgba::TRANSPARENT,
4026            attrs: StyleFlags::empty(),
4027        };
4028
4029        presenter.current_style = Some(old);
4030        presenter.emit_style_delta(old, new).unwrap();
4031        let output = presenter.into_inner().unwrap();
4032        let output_str = String::from_utf8_lossy(&output);
4033
4034        // When going to TRANSPARENT fg, the delta should emit the default fg code
4035        // or reset. Either way, output should be non-empty.
4036        assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
4037    }
4038
4039    #[test]
4040    fn sgr_delta_bg_to_transparent() {
4041        let mut presenter = test_presenter();
4042        let old = CellStyle {
4043            fg: PackedRgba::TRANSPARENT,
4044            bg: PackedRgba::rgb(30, 60, 90),
4045            attrs: StyleFlags::empty(),
4046        };
4047        let new = CellStyle {
4048            fg: PackedRgba::TRANSPARENT,
4049            bg: PackedRgba::TRANSPARENT,
4050            attrs: StyleFlags::empty(),
4051        };
4052
4053        presenter.current_style = Some(old);
4054        presenter.emit_style_delta(old, new).unwrap();
4055        let output = presenter.into_inner().unwrap();
4056        assert!(!output.is_empty(), "Should emit bg removal");
4057    }
4058
4059    #[test]
4060    fn sgr_delta_dim_removed_bold_stays() {
4061        // Reverse of the bold-dim collateral test: removing DIM while BOLD stays.
4062        // DIM off (code 22) also disables BOLD. If BOLD should remain,
4063        // the delta engine must re-enable BOLD.
4064        let mut presenter = test_presenter();
4065        let mut buffer = Buffer::new(3, 1);
4066
4067        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
4068        let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
4069        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
4070        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
4071
4072        let old = Buffer::new(3, 1);
4073        let diff = BufferDiff::compute(&old, &buffer);
4074
4075        presenter.present(&buffer, &diff).unwrap();
4076        let output = get_output(presenter);
4077        let output_str = String::from_utf8_lossy(&output);
4078
4079        // Should contain dim-off (22) and then bold re-enable (1)
4080        assert!(
4081            output_str.contains("\x1b[22m"),
4082            "Expected dim-off (22) in: {output_str:?}"
4083        );
4084        assert!(
4085            output_str.contains("\x1b[1m"),
4086            "Expected bold re-enable (1) in: {output_str:?}"
4087        );
4088    }
4089
4090    #[test]
4091    fn sgr_delta_fallback_to_full_reset_when_cheaper() {
4092        // Many attrs removed + colors changed → delta is expensive, full reset is cheaper
4093        let mut presenter = test_presenter();
4094        let old = CellStyle {
4095            fg: PackedRgba::rgb(10, 20, 30),
4096            bg: PackedRgba::rgb(40, 50, 60),
4097            attrs: StyleFlags::BOLD
4098                | StyleFlags::DIM
4099                | StyleFlags::ITALIC
4100                | StyleFlags::UNDERLINE
4101                | StyleFlags::STRIKETHROUGH,
4102        };
4103        let new = CellStyle {
4104            fg: PackedRgba::TRANSPARENT,
4105            bg: PackedRgba::TRANSPARENT,
4106            attrs: StyleFlags::empty(),
4107        };
4108
4109        presenter.current_style = Some(old);
4110        presenter.emit_style_delta(old, new).unwrap();
4111        let output = presenter.into_inner().unwrap();
4112        let output_str = String::from_utf8_lossy(&output);
4113
4114        // With everything removed and going to default, full reset ("\x1b[0m") is cheapest
4115        assert!(
4116            output_str.contains("\x1b[0m"),
4117            "Expected full reset fallback: {output_str:?}"
4118        );
4119    }
4120
4121    // --- Content emission edge cases ---
4122
4123    #[test]
4124    fn emit_cell_control_char_replaced_with_fffd() {
4125        let mut presenter = test_presenter();
4126        presenter.cursor_x = Some(0);
4127        presenter.cursor_y = Some(0);
4128
4129        // Control character '\x01' has width 0, not empty, not continuation.
4130        // The zero-width-content path replaces it with U+FFFD.
4131        let cell = Cell::from_char('\x01');
4132        presenter.emit_cell(0, &cell, None, None).unwrap();
4133        let output = presenter.into_inner().unwrap();
4134        let output_str = String::from_utf8_lossy(&output);
4135
4136        // Should emit U+FFFD (replacement character), not the raw control char
4137        assert!(
4138            output_str.contains('\u{FFFD}'),
4139            "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
4140        );
4141        assert!(
4142            !output.contains(&0x01),
4143            "Raw control char should not appear"
4144        );
4145    }
4146
4147    #[test]
4148    fn emit_content_empty_cell_emits_space() {
4149        let mut presenter = test_presenter();
4150        presenter.cursor_x = Some(0);
4151        presenter.cursor_y = Some(0);
4152
4153        let cell = Cell::default();
4154        assert!(cell.is_empty());
4155        presenter.emit_cell(0, &cell, None, None).unwrap();
4156        let output = presenter.into_inner().unwrap();
4157        assert!(output.contains(&b' '), "Empty cell should emit space");
4158    }
4159
4160    #[test]
4161    fn emit_content_ascii_char_emits_single_byte() {
4162        let mut presenter = test_presenter();
4163        presenter
4164            .emit_content(PreparedContent::Char('A'), 1, None)
4165            .unwrap();
4166        let output = presenter.into_inner().unwrap();
4167        assert_eq!(output, b"A");
4168    }
4169
4170    #[test]
4171    fn emit_content_ascii_control_sanitizes_to_space() {
4172        let mut presenter = test_presenter();
4173        presenter
4174            .emit_content(PreparedContent::Char('\n'), 1, None)
4175            .unwrap();
4176        let output = presenter.into_inner().unwrap();
4177        assert_eq!(output, b" ");
4178    }
4179
4180    #[test]
4181    fn prepared_content_ascii_widths_match_char_width_contract() {
4182        for ch in ['A', ' ', '\n', '\r', '\x1f', '\x7f'] {
4183            let cell = Cell::from_char(ch);
4184            let (prepared, width) = PreparedContent::from_cell(&cell);
4185            assert_eq!(prepared, PreparedContent::Char(ch));
4186            assert_eq!(width, char_width(ch), "width mismatch for {ch:?}");
4187        }
4188    }
4189
4190    #[test]
4191    fn prepared_content_tab_uses_canonicalized_space() {
4192        let cell = Cell::from_char('\t');
4193        let (prepared, width) = PreparedContent::from_cell(&cell);
4194        assert_eq!(prepared, PreparedContent::Char(' '));
4195        assert_eq!(width, 1);
4196    }
4197
4198    #[test]
4199    fn prepared_content_nul_uses_empty_cell_representation() {
4200        let cell = Cell::from_char('\0');
4201        let (prepared, width) = PreparedContent::from_cell(&cell);
4202        assert_eq!(prepared, PreparedContent::Empty);
4203        assert_eq!(width, 0);
4204    }
4205
4206    #[test]
4207    fn emit_content_grapheme_sanitizes_escape_sequences() {
4208        let mut presenter = test_presenter();
4209        presenter.cursor_x = Some(0);
4210        presenter.cursor_y = Some(0);
4211
4212        let mut pool = GraphemePool::new();
4213        let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
4214        let cell = Cell::new(CellContent::from_grapheme(gid));
4215        presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
4216
4217        let output = presenter.into_inner().unwrap();
4218        let output_str = String::from_utf8_lossy(&output);
4219        assert!(
4220            output_str.contains("AB"),
4221            "sanitized grapheme should preserve visible payload"
4222        );
4223        assert!(
4224            !output_str.contains("\x1b[31m"),
4225            "raw escape sequence must not be emitted"
4226        );
4227    }
4228
4229    #[test]
4230    fn emit_content_grapheme_width_mismatch_uses_placeholders() {
4231        let mut presenter = test_presenter();
4232        let mut pool = GraphemePool::new();
4233        let gid = pool.intern("A\x07", 2);
4234
4235        presenter
4236            .emit_content(PreparedContent::Grapheme(gid), 2, Some(&pool))
4237            .unwrap();
4238
4239        let output = presenter.into_inner().unwrap();
4240        assert_eq!(output, b"??");
4241    }
4242
4243    #[test]
4244    fn wide_grapheme_tail_repair_does_not_blank_unrelated_following_cells() {
4245        let mut presenter = test_presenter();
4246        let mut pool = GraphemePool::new();
4247        let gid = pool.intern("XYZ", 3);
4248        let mut buffer = Buffer::new(8, 1);
4249
4250        buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
4251        buffer.set_raw(1, 0, Cell::from_char('a'));
4252        buffer.set_raw(2, 0, Cell::from_char('b'));
4253        buffer.set_raw(3, 0, Cell::from_char('c'));
4254
4255        let old = Buffer::new(8, 1);
4256        let diff = BufferDiff::compute(&old, &buffer);
4257
4258        presenter
4259            .present_with_pool(&buffer, &diff, Some(&pool), None)
4260            .unwrap();
4261
4262        let output = presenter.into_inner().unwrap();
4263        let output_str = String::from_utf8_lossy(&output);
4264        let visible = sanitize(output_str.as_ref());
4265
4266        assert!(
4267            visible.contains("XYZabc"),
4268            "width-3 grapheme repair must not erase following cells: {:?}",
4269            visible
4270        );
4271    }
4272
4273    // --- Continuation cell cursor_x variants ---
4274
4275    #[test]
4276    fn continuation_cell_cursor_x_none() {
4277        let mut presenter = test_presenter();
4278        // cursor_x = None -> defensive path, clears orphan continuation.
4279        presenter.cursor_x = None;
4280        presenter.cursor_y = Some(0);
4281
4282        let cell = Cell::CONTINUATION;
4283        presenter.emit_cell(5, &cell, None, None).unwrap();
4284        let output = presenter.into_inner().unwrap();
4285
4286        // Should emit a clearing space.
4287        assert!(
4288            output.contains(&b' '),
4289            "Should emit a space for continuation with unknown cursor_x"
4290        );
4291    }
4292
4293    #[test]
4294    fn continuation_cell_cursor_already_past() {
4295        let mut presenter = test_presenter();
4296        // cursor_x > cell x → cursor already advanced past, skip
4297        presenter.cursor_x = Some(10);
4298        presenter.cursor_y = Some(0);
4299
4300        let cell = Cell::CONTINUATION;
4301        presenter.emit_cell(5, &cell, None, None).unwrap();
4302        let output = presenter.into_inner().unwrap();
4303
4304        // Should produce no output (cursor already past)
4305        assert!(
4306            output.is_empty(),
4307            "Should skip continuation when cursor is past it"
4308        );
4309    }
4310
4311    // --- clear_line ---
4312
4313    #[test]
4314    fn clear_line_positions_cursor_and_erases() {
4315        let mut presenter = test_presenter();
4316        presenter.clear_line(5).unwrap();
4317        let output = get_output(presenter);
4318        let output_str = String::from_utf8_lossy(&output);
4319
4320        // Should contain CUP to row 5 col 0 and erase line
4321        assert!(
4322            output_str.contains("\x1b[2K"),
4323            "Should contain erase line sequence"
4324        );
4325    }
4326
4327    // --- into_inner ---
4328
4329    #[test]
4330    fn into_inner_returns_accumulated_output() {
4331        let mut presenter = test_presenter();
4332        presenter.position_cursor(0, 0).unwrap();
4333        let inner = presenter.into_inner().unwrap();
4334        assert!(!inner.is_empty(), "into_inner should return buffered data");
4335    }
4336
4337    // --- move_cursor_optimal edge cases ---
4338
4339    #[test]
4340    fn move_cursor_optimal_same_row_forward_large() {
4341        let mut presenter = test_presenter();
4342        presenter.cursor_x = Some(0);
4343        presenter.cursor_y = Some(0);
4344
4345        // Forward by 100 columns. CUF(100) vs CHA(100) vs CUP(0,100)
4346        presenter.move_cursor_optimal(100, 0).unwrap();
4347        let output = presenter.into_inner().unwrap();
4348
4349        // Verify the output picks the cheapest move
4350        let cuf = cost_model::cuf_cost(100);
4351        let cha = cost_model::cha_cost(100);
4352        let cup = cost_model::cup_cost(0, 100);
4353        let cheapest = cuf.min(cha).min(cup);
4354        assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
4355    }
4356
4357    #[test]
4358    fn move_cursor_optimal_same_row_backward_to_zero() {
4359        let mut presenter = test_presenter();
4360        presenter.cursor_x = Some(50);
4361        presenter.cursor_y = Some(0);
4362
4363        presenter.move_cursor_optimal(0, 0).unwrap();
4364        let output = presenter.into_inner().unwrap();
4365
4366        // CHA(0) → "\x1b[1G" = 4 bytes, CUP(0,0) = "\x1b[1;1H" = 6 bytes
4367        // CHA should win
4368        let mut expected = Vec::new();
4369        ansi::cha(&mut expected, 0).unwrap();
4370        assert_eq!(output, expected, "Should use CHA for backward to col 0");
4371    }
4372
4373    #[test]
4374    fn move_cursor_optimal_unknown_cursor_uses_cup() {
4375        let mut presenter = test_presenter();
4376        // cursor_x and cursor_y are None
4377        presenter.move_cursor_optimal(10, 5).unwrap();
4378        let output = presenter.into_inner().unwrap();
4379        let mut expected = Vec::new();
4380        ansi::cup(&mut expected, 5, 10).unwrap();
4381        assert_eq!(output, expected, "Should use CUP when cursor is unknown");
4382    }
4383
4384    // --- Present with sync: verify wrap order ---
4385
4386    #[test]
4387    fn sync_wrap_order_begin_content_reset_end() {
4388        let mut presenter = test_presenter_with_sync();
4389        let mut buffer = Buffer::new(3, 1);
4390        buffer.set_raw(0, 0, Cell::from_char('Z'));
4391
4392        let old = Buffer::new(3, 1);
4393        let diff = BufferDiff::compute(&old, &buffer);
4394
4395        presenter.present(&buffer, &diff).unwrap();
4396        let output = get_output(presenter);
4397
4398        let sync_begin_pos = output
4399            .windows(ansi::SYNC_BEGIN.len())
4400            .position(|w| w == ansi::SYNC_BEGIN)
4401            .expect("sync begin missing");
4402        let z_pos = output
4403            .iter()
4404            .position(|&b| b == b'Z')
4405            .expect("character Z missing");
4406        let reset_pos = output
4407            .windows(b"\x1b[0m".len())
4408            .rposition(|w| w == b"\x1b[0m")
4409            .expect("SGR reset missing");
4410        let sync_end_pos = output
4411            .windows(ansi::SYNC_END.len())
4412            .rposition(|w| w == ansi::SYNC_END)
4413            .expect("sync end missing");
4414
4415        assert!(sync_begin_pos < z_pos, "sync begin before content");
4416        assert!(z_pos < reset_pos, "content before reset");
4417        assert!(reset_pos < sync_end_pos, "reset before sync end");
4418    }
4419
4420    // --- Multi-frame style state ---
4421
4422    #[test]
4423    fn style_none_after_each_frame() {
4424        let mut presenter = test_presenter();
4425        let fg = PackedRgba::rgb(255, 128, 64);
4426
4427        for _ in 0..5 {
4428            let mut buffer = Buffer::new(3, 1);
4429            buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
4430            let old = Buffer::new(3, 1);
4431            let diff = BufferDiff::compute(&old, &buffer);
4432            presenter.present(&buffer, &diff).unwrap();
4433
4434            // After each present(), current_style should be None (reset at frame end)
4435            assert!(
4436                presenter.current_style.is_none(),
4437                "Style should be None after frame end"
4438            );
4439            assert!(
4440                presenter.current_link.is_none(),
4441                "Link should be None after frame end"
4442            );
4443        }
4444    }
4445
4446    // --- Link state after present with open link ---
4447
4448    #[test]
4449    fn link_closed_at_frame_end_even_if_all_cells_linked() {
4450        let mut presenter = test_presenter();
4451        let mut buffer = Buffer::new(3, 1);
4452        let mut links = LinkRegistry::new();
4453        let link_id = links.register("https://all-linked.test");
4454
4455        // All cells have the same link
4456        for x in 0..3 {
4457            buffer.set_raw(
4458                x,
4459                0,
4460                Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
4461            );
4462        }
4463
4464        let old = Buffer::new(3, 1);
4465        let diff = BufferDiff::compute(&old, &buffer);
4466        presenter
4467            .present_with_pool(&buffer, &diff, None, Some(&links))
4468            .unwrap();
4469
4470        // After present, current_link must be None (closed at frame end)
4471        assert!(
4472            presenter.current_link.is_none(),
4473            "Link must be closed at frame end"
4474        );
4475    }
4476
4477    // --- PresentStats ---
4478
4479    #[test]
4480    fn present_stats_empty_diff() {
4481        let mut presenter = test_presenter();
4482        let buffer = Buffer::new(10, 10);
4483        let diff = BufferDiff::new();
4484        let stats = presenter.present(&buffer, &diff).unwrap();
4485
4486        assert_eq!(stats.cells_changed, 0);
4487        assert_eq!(stats.run_count, 0);
4488        // bytes_emitted includes the SGR reset
4489        assert!(stats.bytes_emitted > 0);
4490    }
4491
4492    #[test]
4493    fn present_stats_full_row() {
4494        let mut presenter = test_presenter();
4495        let mut buffer = Buffer::new(10, 1);
4496        for x in 0..10 {
4497            buffer.set_raw(x, 0, Cell::from_char('A'));
4498        }
4499        let old = Buffer::new(10, 1);
4500        let diff = BufferDiff::compute(&old, &buffer);
4501        let stats = presenter.present(&buffer, &diff).unwrap();
4502
4503        assert_eq!(stats.cells_changed, 10);
4504        assert!(stats.run_count >= 1);
4505        assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
4506    }
4507
4508    // --- Capabilities accessor ---
4509
4510    #[test]
4511    fn capabilities_accessor() {
4512        let mut caps = TerminalCapabilities::basic();
4513        caps.sync_output = true;
4514        let presenter = Presenter::new(Vec::<u8>::new(), caps);
4515        assert!(presenter.capabilities().sync_output);
4516    }
4517
4518    // --- Flush ---
4519
4520    #[test]
4521    fn flush_succeeds_on_empty_presenter() {
4522        let mut presenter = test_presenter();
4523        presenter.flush().unwrap();
4524        let output = get_output(presenter);
4525        assert!(output.is_empty());
4526    }
4527
4528    // --- RowPlan total_cost ---
4529
4530    #[test]
4531    fn row_plan_total_cost_matches_dp() {
4532        let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
4533        let plan = cost_model::plan_row(&runs, None, None);
4534        assert!(plan.total_cost() > 0);
4535        // The total cost includes move costs + cell costs
4536        // Just verify it's consistent (non-zero) and accessible
4537    }
4538
4539    // --- Style delta: same attrs, only colors change (hot path) ---
4540
4541    #[test]
4542    fn sgr_delta_hot_path_only_fg_change() {
4543        let mut presenter = test_presenter();
4544        let old = CellStyle {
4545            fg: PackedRgba::rgb(255, 0, 0),
4546            bg: PackedRgba::rgb(0, 0, 0),
4547            attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4548        };
4549        let new = CellStyle {
4550            fg: PackedRgba::rgb(0, 255, 0),
4551            bg: PackedRgba::rgb(0, 0, 0),
4552            attrs: StyleFlags::BOLD | StyleFlags::ITALIC, // same attrs
4553        };
4554
4555        presenter.current_style = Some(old);
4556        presenter.emit_style_delta(old, new).unwrap();
4557        let output = presenter.into_inner().unwrap();
4558        let output_str = String::from_utf8_lossy(&output);
4559
4560        // Only fg should change, no reset
4561        assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
4562        assert!(
4563            !output_str.contains("\x1b[0m"),
4564            "No reset needed for color-only change"
4565        );
4566        // Should NOT re-emit attrs
4567        assert!(
4568            !output_str.contains("\x1b[1m"),
4569            "Bold should not be re-emitted"
4570        );
4571    }
4572
4573    #[test]
4574    fn sgr_delta_hot_path_both_colors_change() {
4575        let mut presenter = test_presenter();
4576        let old = CellStyle {
4577            fg: PackedRgba::rgb(1, 2, 3),
4578            bg: PackedRgba::rgb(4, 5, 6),
4579            attrs: StyleFlags::UNDERLINE,
4580        };
4581        let new = CellStyle {
4582            fg: PackedRgba::rgb(7, 8, 9),
4583            bg: PackedRgba::rgb(10, 11, 12),
4584            attrs: StyleFlags::UNDERLINE, // same
4585        };
4586
4587        presenter.current_style = Some(old);
4588        presenter.emit_style_delta(old, new).unwrap();
4589        let output = presenter.into_inner().unwrap();
4590        let output_str = String::from_utf8_lossy(&output);
4591
4592        assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
4593        assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
4594        assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
4595    }
4596
4597    // --- Style full apply ---
4598
4599    #[test]
4600    fn emit_style_full_default_is_just_reset() {
4601        let mut presenter = test_presenter();
4602        let default_style = CellStyle::default();
4603        presenter.emit_style_full(default_style).unwrap();
4604        let output = presenter.into_inner().unwrap();
4605
4606        // Default style (transparent fg/bg, no attrs) should just be reset
4607        assert_eq!(output, b"\x1b[0m");
4608    }
4609
4610    #[test]
4611    fn emit_style_full_with_all_properties() {
4612        let mut presenter = test_presenter();
4613        let style = CellStyle {
4614            fg: PackedRgba::rgb(10, 20, 30),
4615            bg: PackedRgba::rgb(40, 50, 60),
4616            attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4617        };
4618        presenter.emit_style_full(style).unwrap();
4619        let output = presenter.into_inner().unwrap();
4620        let output_str = String::from_utf8_lossy(&output);
4621
4622        // Should have reset + fg + bg + attrs
4623        assert!(output_str.contains("\x1b[0m"), "Should start with reset");
4624        assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
4625        assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
4626    }
4627
4628    // --- Multiple rows with different strategies ---
4629
4630    #[test]
4631    fn present_multiple_rows_different_strategies() {
4632        let mut presenter = test_presenter();
4633        let mut buffer = Buffer::new(80, 5);
4634
4635        // Row 0: dense changes (should merge)
4636        for x in (0..20).step_by(2) {
4637            buffer.set_raw(x, 0, Cell::from_char('D'));
4638        }
4639        // Row 2: sparse changes (large gap, should stay sparse)
4640        buffer.set_raw(0, 2, Cell::from_char('L'));
4641        buffer.set_raw(79, 2, Cell::from_char('R'));
4642        // Row 4: single cell
4643        buffer.set_raw(40, 4, Cell::from_char('M'));
4644
4645        let old = Buffer::new(80, 5);
4646        let diff = BufferDiff::compute(&old, &buffer);
4647        presenter.present(&buffer, &diff).unwrap();
4648        let output = get_output(presenter);
4649        let output_str = String::from_utf8_lossy(&output);
4650
4651        assert!(output_str.contains('D'));
4652        assert!(output_str.contains('L'));
4653        assert!(output_str.contains('R'));
4654        assert!(output_str.contains('M'));
4655    }
4656
4657    #[test]
4658    fn zero_width_chars_replaced_with_placeholder() {
4659        let mut presenter = test_presenter();
4660        let mut buffer = Buffer::new(5, 1);
4661
4662        // U+0301 is COMBINING ACUTE ACCENT (width 0).
4663        // It is not empty, not continuation, not grapheme (unless pooled).
4664        // Storing it directly as a char means it's a standalone cell content.
4665        let zw_char = '\u{0301}';
4666
4667        // Ensure our assumption about width is correct for this environment
4668        assert_eq!(Cell::from_char(zw_char).content.width(), 0);
4669
4670        buffer.set_raw(0, 0, Cell::from_char(zw_char));
4671        buffer.set_raw(1, 0, Cell::from_char('A'));
4672
4673        let old = Buffer::new(5, 1);
4674        let diff = BufferDiff::compute(&old, &buffer);
4675
4676        presenter.present(&buffer, &diff).unwrap();
4677        let output = get_output(presenter);
4678        let output_str = String::from_utf8_lossy(&output);
4679
4680        // Should contain U+FFFD (Replacement Character)
4681        assert!(
4682            output_str.contains("\u{FFFD}"),
4683            "Expected replacement character for zero-width content, got: {:?}",
4684            output_str
4685        );
4686
4687        // Should NOT contain the raw combining mark
4688        assert!(
4689            !output_str.contains(zw_char),
4690            "Should not contain raw zero-width char"
4691        );
4692
4693        // Should contain 'A' (verify cursor sync didn't swallow it)
4694        assert!(
4695            output_str.contains('A'),
4696            "Should contain subsequent character 'A'"
4697        );
4698    }
4699}
4700
4701#[cfg(test)]
4702mod proptests {
4703    use super::*;
4704    use crate::cell::{Cell, PackedRgba};
4705    use crate::diff::BufferDiff;
4706    use crate::terminal_model::TerminalModel;
4707    use proptest::prelude::*;
4708
4709    /// Create a presenter for testing.
4710    fn test_presenter() -> Presenter<Vec<u8>> {
4711        let caps = TerminalCapabilities::basic();
4712        Presenter::new(Vec::new(), caps)
4713    }
4714
4715    proptest! {
4716        /// Property: Presenter output, when applied to terminal model, produces
4717        /// the correct characters for changed cells.
4718        #[test]
4719        fn presenter_roundtrip_characters(
4720            width in 5u16..40,
4721            height in 3u16..20,
4722            num_chars in 1usize..50, // At least 1 char to have meaningful diff
4723        ) {
4724            let mut buffer = Buffer::new(width, height);
4725            let mut changed_positions = std::collections::HashSet::new();
4726
4727            // Fill some cells with ASCII chars
4728            for i in 0..num_chars {
4729                let x = (i * 7 + 3) as u16 % width;
4730                let y = (i * 11 + 5) as u16 % height;
4731                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4732                buffer.set_raw(x, y, Cell::from_char(ch));
4733                changed_positions.insert((x, y));
4734            }
4735
4736            // Present full buffer
4737            let mut presenter = test_presenter();
4738            let old = Buffer::new(width, height);
4739            let diff = BufferDiff::compute(&old, &buffer);
4740            presenter.present(&buffer, &diff).unwrap();
4741            let output = presenter.into_inner().unwrap();
4742
4743            // Apply to terminal model
4744            let mut model = TerminalModel::new(width as usize, height as usize);
4745            model.process(&output);
4746
4747            // Verify ONLY changed characters match (model may have different default)
4748            for &(x, y) in &changed_positions {
4749                let buf_cell = buffer.get_unchecked(x, y);
4750                let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
4751                let mut expected_buf = [0u8; 4];
4752                let expected_str = expected_ch.encode_utf8(&mut expected_buf);
4753
4754                if let Some(model_cell) = model.cell(x as usize, y as usize) {
4755                    prop_assert_eq!(
4756                        model_cell.text.as_str(),
4757                        expected_str,
4758                        "Character mismatch at ({}, {})", x, y
4759                    );
4760                }
4761            }
4762        }
4763
4764        /// Property: After complete frame presentation, SGR is reset.
4765        #[test]
4766        fn style_reset_after_present(
4767            width in 5u16..30,
4768            height in 3u16..15,
4769            num_styled in 1usize..20,
4770        ) {
4771            let mut buffer = Buffer::new(width, height);
4772
4773            // Add some styled cells
4774            for i in 0..num_styled {
4775                let x = (i * 7) as u16 % width;
4776                let y = (i * 11) as u16 % height;
4777                let fg = PackedRgba::rgb(
4778                    ((i * 31) % 256) as u8,
4779                    ((i * 47) % 256) as u8,
4780                    ((i * 71) % 256) as u8,
4781                );
4782                buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4783            }
4784
4785            // Present
4786            let mut presenter = test_presenter();
4787            let old = Buffer::new(width, height);
4788            let diff = BufferDiff::compute(&old, &buffer);
4789            presenter.present(&buffer, &diff).unwrap();
4790            let output = presenter.into_inner().unwrap();
4791            let output_str = String::from_utf8_lossy(&output);
4792
4793            // Output should end with SGR reset sequence
4794            prop_assert!(
4795                output_str.contains("\x1b[0m"),
4796                "Output should contain SGR reset"
4797            );
4798        }
4799
4800        /// Property: Presenter handles empty diff correctly.
4801        #[test]
4802        fn empty_diff_minimal_output(
4803            width in 5u16..50,
4804            height in 3u16..25,
4805        ) {
4806            let buffer = Buffer::new(width, height);
4807            let diff = BufferDiff::new(); // Empty diff
4808
4809            let mut presenter = test_presenter();
4810            presenter.present(&buffer, &diff).unwrap();
4811            let output = presenter.into_inner().unwrap();
4812
4813            // Output should only be SGR reset (or very minimal)
4814            // No cursor moves or cell content for empty diff
4815            prop_assert!(output.len() < 50, "Empty diff should have minimal output");
4816        }
4817
4818        /// Property: Full buffer change produces diff with all cells.
4819        ///
4820        /// When every cell differs, the diff should contain exactly
4821        /// width * height changes.
4822        #[test]
4823        fn diff_size_bounds(
4824            width in 5u16..30,
4825            height in 3u16..15,
4826        ) {
4827            // Full change buffer
4828            let old = Buffer::new(width, height);
4829            let mut new = Buffer::new(width, height);
4830
4831            for y in 0..height {
4832                for x in 0..width {
4833                    new.set_raw(x, y, Cell::from_char('X'));
4834                }
4835            }
4836
4837            let diff = BufferDiff::compute(&old, &new);
4838
4839            // Diff should capture all cells
4840            prop_assert_eq!(
4841                diff.len(),
4842                (width as usize) * (height as usize),
4843                "Full change should have all cells in diff"
4844            );
4845        }
4846
4847        /// Property: Presenter cursor state is consistent after operations.
4848        #[test]
4849        fn presenter_cursor_consistency(
4850            width in 10u16..40,
4851            height in 5u16..20,
4852            num_runs in 1usize..10,
4853        ) {
4854            let mut buffer = Buffer::new(width, height);
4855
4856            // Create some runs of changes
4857            for i in 0..num_runs {
4858                let start_x = (i * 5) as u16 % (width - 5);
4859                let y = i as u16 % height;
4860                for x in start_x..(start_x + 3) {
4861                    buffer.set_raw(x, y, Cell::from_char('A'));
4862                }
4863            }
4864
4865            // Multiple presents should work correctly
4866            let mut presenter = test_presenter();
4867            let old = Buffer::new(width, height);
4868
4869            for _ in 0..3 {
4870                let diff = BufferDiff::compute(&old, &buffer);
4871                presenter.present(&buffer, &diff).unwrap();
4872            }
4873
4874            // Should not panic and produce valid output
4875            let output = presenter.into_inner().unwrap();
4876            prop_assert!(!output.is_empty(), "Should produce some output");
4877        }
4878
4879        /// Property (bd-4kq0.2.1): SGR delta produces identical visual styling
4880        /// as reset+apply for random style transitions. Verified via terminal
4881        /// model roundtrip.
4882        #[test]
4883        fn sgr_delta_transition_equivalence(
4884            width in 5u16..20,
4885            height in 3u16..10,
4886            num_styled in 2usize..15,
4887        ) {
4888            let mut buffer = Buffer::new(width, height);
4889            // Track final character at each position (later writes overwrite earlier)
4890            let mut expected: std::collections::HashMap<(u16, u16), char> =
4891                std::collections::HashMap::new();
4892
4893            // Create cells with varying styles to exercise delta engine
4894            for i in 0..num_styled {
4895                let x = (i * 3 + 1) as u16 % width;
4896                let y = (i * 5 + 2) as u16 % height;
4897                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4898                let fg = PackedRgba::rgb(
4899                    ((i * 73) % 256) as u8,
4900                    ((i * 137) % 256) as u8,
4901                    ((i * 41) % 256) as u8,
4902                );
4903                let bg = if i % 3 == 0 {
4904                    PackedRgba::rgb(
4905                        ((i * 29) % 256) as u8,
4906                        ((i * 53) % 256) as u8,
4907                        ((i * 97) % 256) as u8,
4908                    )
4909                } else {
4910                    PackedRgba::TRANSPARENT
4911                };
4912                let flags_bits = ((i * 37) % 256) as u8;
4913                let flags = StyleFlags::from_bits_truncate(flags_bits);
4914                let cell = Cell::from_char(ch)
4915                    .with_fg(fg)
4916                    .with_bg(bg)
4917                    .with_attrs(CellAttrs::new(flags, 0));
4918                buffer.set_raw(x, y, cell);
4919                expected.insert((x, y), ch);
4920            }
4921
4922            // Present with delta engine
4923            let mut presenter = test_presenter();
4924            let old = Buffer::new(width, height);
4925            let diff = BufferDiff::compute(&old, &buffer);
4926            presenter.present(&buffer, &diff).unwrap();
4927            let output = presenter.into_inner().unwrap();
4928
4929            // Apply to terminal model and verify characters
4930            let mut model = TerminalModel::new(width as usize, height as usize);
4931            model.process(&output);
4932
4933            for (&(x, y), &ch) in &expected {
4934                let mut buf = [0u8; 4];
4935                let expected_str = ch.encode_utf8(&mut buf);
4936
4937                if let Some(model_cell) = model.cell(x as usize, y as usize) {
4938                    prop_assert_eq!(
4939                        model_cell.text.as_str(),
4940                        expected_str,
4941                        "Character mismatch at ({}, {}) with delta engine", x, y
4942                    );
4943                }
4944            }
4945        }
4946
4947        /// Property (bd-4kq0.2.2): DP cost model produces correct output
4948        /// regardless of which row strategy is chosen (sparse vs merged).
4949        /// Verified via terminal model roundtrip with scattered runs.
4950        #[test]
4951        fn dp_emit_equivalence(
4952            width in 20u16..60,
4953            height in 5u16..15,
4954            num_changes in 5usize..30,
4955        ) {
4956            let mut buffer = Buffer::new(width, height);
4957            let mut expected: std::collections::HashMap<(u16, u16), char> =
4958                std::collections::HashMap::new();
4959
4960            // Create scattered changes that will trigger both sparse and merged strategies
4961            for i in 0..num_changes {
4962                let x = (i * 7 + 3) as u16 % width;
4963                let y = (i * 3 + 1) as u16 % height;
4964                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4965                buffer.set_raw(x, y, Cell::from_char(ch));
4966                expected.insert((x, y), ch);
4967            }
4968
4969            // Present with DP cost model
4970            let mut presenter = test_presenter();
4971            let old = Buffer::new(width, height);
4972            let diff = BufferDiff::compute(&old, &buffer);
4973            presenter.present(&buffer, &diff).unwrap();
4974            let output = presenter.into_inner().unwrap();
4975
4976            // Apply to terminal model and verify all characters are correct
4977            let mut model = TerminalModel::new(width as usize, height as usize);
4978            model.process(&output);
4979
4980            for (&(x, y), &ch) in &expected {
4981                let mut buf = [0u8; 4];
4982                let expected_str = ch.encode_utf8(&mut buf);
4983
4984                if let Some(model_cell) = model.cell(x as usize, y as usize) {
4985                    prop_assert_eq!(
4986                        model_cell.text.as_str(),
4987                        expected_str,
4988                        "DP cost model: character mismatch at ({}, {})", x, y
4989                    );
4990                }
4991            }
4992        }
4993    }
4994}