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