Skip to main content

muxr_client/
frame_buffer.rs

1use std::io::Write;
2use std::sync::Arc;
3
4use crossterm::Command;
5use crossterm::QueueableCommand;
6use crossterm::cursor::Hide;
7use crossterm::cursor::MoveTo;
8use crossterm::cursor::Show;
9use crossterm::style::Attribute;
10use crossterm::style::Color;
11use crossterm::style::Print;
12use crossterm::style::ResetColor;
13use crossterm::style::SetAttribute;
14use crossterm::style::SetBackgroundColor;
15use crossterm::style::SetForegroundColor;
16use muxr_core::RenderCell;
17use muxr_core::RenderCellWidth;
18use muxr_core::RenderColor;
19use muxr_core::RenderCursor;
20use muxr_core::RenderCursorShape;
21use muxr_core::RenderDiff;
22use muxr_core::RenderRowSpan;
23use muxr_core::RenderStyle;
24use muxr_core::RenderTextStyle;
25use muxr_core::RenderUpdate;
26use muxr_core::TerminalSize;
27use rootcause::prelude::ResultExt;
28use rootcause::report;
29
30use crate::copy_selection::SelectionRange;
31
32const OSC8_CLOSE: &[u8] = b"\x1b]8;;\x1b\\";
33const OSC8_OPEN_PREFIX: &[u8] = b"\x1b]8;;";
34const OSC8_TERMINATOR: &[u8] = b"\x1b\\";
35const MAX_RETAINED_TEXT_RUN_BYTES: usize = 8 * 1_024;
36
37#[derive(Clone, Debug, Default, Eq, PartialEq)]
38pub struct FrameBuffer {
39    cursor: Option<RenderCursor>,
40    rows: Vec<Arc<[RenderCell]>>,
41    seq: Option<u64>,
42    size: Option<TerminalSize>,
43}
44
45impl FrameBuffer {
46    pub fn apply(&mut self, update: RenderUpdate) -> rootcause::Result<ApplyOutcome> {
47        match update {
48            RenderUpdate::Baseline(baseline) => {
49                let (seq, size, cursor, spans) = baseline.into_parts();
50                // RenderBaseline construction validates ordered full-width rows; clone them directly instead of
51                // allocating a blank frame and overwriting every cell.
52                let rows = spans.iter().map(|span| Arc::from(span.cells().to_vec())).collect();
53
54                self.cursor = Some(cursor.clone());
55                self.rows = rows;
56                self.seq = Some(seq);
57                self.size = Some(size);
58                Ok(ApplyOutcome::Applied(RenderFrameChanges {
59                    cursor,
60                    scope: RenderFrameScope::Full,
61                    rows: spans,
62                }))
63            }
64            RenderUpdate::Diff(diff) => self.apply_diff(diff),
65        }
66    }
67
68    fn apply_diff(&mut self, diff: RenderDiff) -> rootcause::Result<ApplyOutcome> {
69        let (base_seq, seq, size, cursor, spans) = diff.into_parts();
70        if self.seq != Some(base_seq) || self.size.as_ref() != Some(&size) {
71            return Ok(ApplyOutcome::NeedsResync);
72        }
73
74        // Validate the full diff before mutating so a stale frame cannot leave a partial buffer.
75        for row in &spans {
76            validate_span_against_rows(&self.rows, row)?;
77        }
78        for row in &spans {
79            apply_span_to_rows(&mut self.rows, row)?;
80        }
81
82        self.cursor = Some(cursor.clone());
83        self.seq = Some(seq);
84        self.size = Some(size);
85        Ok(ApplyOutcome::Applied(RenderFrameChanges {
86            cursor,
87            scope: RenderFrameScope::Partial,
88            rows: spans,
89        }))
90    }
91
92    pub fn row_redraw_changes(&self, changed_rows: &[u16]) -> rootcause::Result<Option<RenderFrameChanges>> {
93        let Some(cursor) = self.cursor.clone() else {
94            return Ok(None);
95        };
96        let Some(size) = &self.size else {
97            return Ok(None);
98        };
99
100        let mut rows = Vec::new();
101        for row in changed_rows {
102            if *row >= size.rows() {
103                continue;
104            }
105            let Some(cells) = self.rows.get(usize::from(*row)) else {
106                return Err(report!("muxr frame buffer row is missing").attach(format!("row={row}")));
107            };
108            rows.push(RenderRowSpan::new(*row, 0, cells.as_ref().to_vec())?);
109        }
110
111        Ok(Some(RenderFrameChanges {
112            cursor,
113            scope: RenderFrameScope::Partial,
114            rows,
115        }))
116    }
117
118    #[must_use]
119    pub fn changed_rows_since(&self, previous: &Self) -> Option<Vec<u16>> {
120        if self.size != previous.size {
121            return None;
122        }
123        let rows = self
124            .rows
125            .iter()
126            .zip(&previous.rows)
127            .enumerate()
128            .filter_map(|(row, (next, previous))| (!Arc::ptr_eq(next, previous)).then_some(row))
129            .map(u16::try_from)
130            .collect::<Result<Vec<_>, _>>()
131            .ok()?;
132        (self.rows.len() == previous.rows.len()).then_some(rows)
133    }
134
135    #[must_use]
136    pub fn cursor_matches(&self, previous: &Self) -> bool {
137        self.cursor == previous.cursor
138    }
139
140    pub fn full_redraw_changes(&self) -> Option<RenderFrameChanges> {
141        let cursor = self.cursor.clone()?;
142        let rows = self
143            .rows
144            .iter()
145            .enumerate()
146            .map(|(row, cells)| RenderRowSpan::new(u16::try_from(row).ok()?, 0, cells.as_ref().to_vec()).ok())
147            .collect::<Option<Vec<_>>>()?;
148        Some(RenderFrameChanges {
149            cursor,
150            scope: RenderFrameScope::Full,
151            rows,
152        })
153    }
154
155    #[must_use]
156    pub fn cell(&self, row: u16, col: u16) -> Option<&RenderCell> {
157        self.rows.get(usize::from(row))?.get(usize::from(col))
158    }
159
160    #[must_use]
161    pub const fn size(&self) -> Option<&TerminalSize> {
162        self.size.as_ref()
163    }
164}
165
166#[derive(Debug, Default)]
167pub struct TerminalUpdateEncoder {
168    active_style: RenderStyle,
169    text_run: String,
170}
171
172impl TerminalUpdateEncoder {
173    pub fn encode(&mut self, output: &mut impl Write, render: TerminalRender<'_>) -> rootcause::Result<()> {
174        self.begin_update();
175        let encode_result = self.encode_update(output, render);
176        self.finish_update();
177        encode_result
178    }
179
180    fn encode_update(&mut self, output: &mut impl Write, render: TerminalRender<'_>) -> rootcause::Result<()> {
181        if render.frame_buffer.cursor.as_ref() != Some(&render.changes.cursor) {
182            return Err(report!("muxr render changes do not match current frame buffer cursor"));
183        }
184        // Diffs still move the real terminal cursor while repainting dirty rows; hide it until the final pane cursor
185        // position is restored so intermediate write positions cannot flash across panes.
186        queue_cmd(output, Hide)?;
187        reset_style(output)?;
188        for row in &render.changes.rows {
189            self.encode_row(output, row, render.origin, render.selection)?;
190        }
191        reset_style(output)?;
192        render_cursor(output, &render.changes.cursor, render.origin.row, render.origin.col)?;
193        Ok(())
194    }
195
196    fn encode_row(
197        &mut self,
198        output: &mut impl Write,
199        row: &RenderRowSpan,
200        origin: TerminalOrigin,
201        selection: Option<SelectionHighlight<'_>>,
202    ) -> rootcause::Result<()> {
203        let rendered_row = row
204            .row()
205            .checked_add(origin.row)
206            .ok_or_else(|| report!("muxr render row offset overflowed"))?;
207        let rendered_col = row
208            .col()
209            .checked_add(origin.col)
210            .ok_or_else(|| report!("muxr render column offset overflowed"))?;
211        queue_cmd(output, MoveTo(rendered_col, rendered_row))?;
212        let mut run_style = None;
213        self.text_run.clear();
214        for (index, cell) in row.cells().iter().enumerate() {
215            if matches!(cell.width(), RenderCellWidth::WideContinuation) {
216                continue;
217            }
218
219            let cell_col = row
220                .col()
221                .checked_add(u16::try_from(index).context("muxr render cell index overflowed")?)
222                .ok_or_else(|| report!("muxr render cell column overflowed"))?;
223            let cell_style = Self::selected_style(cell.style(), selection, row.row(), cell_col);
224            let cell_run_style = RenderRunStyle {
225                hyperlink_uri: cell.hyperlink().map(muxr_core::RenderHyperlink::uri),
226                style: cell_style,
227            };
228            if run_style != Some(cell_run_style) {
229                self.flush_text_run(output, run_style)?;
230                run_style = Some(cell_run_style);
231            }
232            if cell.text().is_empty() {
233                self.text_run.push(' ');
234            } else {
235                self.text_run.push_str(cell.text());
236            }
237        }
238        self.flush_text_run(output, run_style)
239    }
240
241    fn flush_text_run(
242        &mut self,
243        output: &mut impl Write,
244        run_style: Option<RenderRunStyle<'_>>,
245    ) -> rootcause::Result<()> {
246        if self.text_run.is_empty() {
247            return Ok(());
248        }
249        let Some(style) = run_style else {
250            return Err(report!("muxr render text run is missing style"));
251        };
252
253        apply_style_transition(output, &mut self.active_style, style.style)?;
254        if let Some(uri) = style.hyperlink_uri {
255            queue_hyperlink_start(output, uri)?;
256        }
257        queue_cmd(output, Print(self.text_run.as_str()))?;
258        if style.hyperlink_uri.is_some() {
259            queue_hyperlink_end(output)?;
260        }
261        self.text_run.clear();
262        Ok(())
263    }
264
265    fn begin_update(&mut self) {
266        self.active_style = RenderStyle::default();
267        self.text_run.clear();
268    }
269
270    fn finish_update(&mut self) {
271        self.active_style = RenderStyle::default();
272        self.text_run.clear();
273        // Reuse normal text runs without retaining a one-off oversized row for the rest of the client session.
274        if self.text_run.capacity() > MAX_RETAINED_TEXT_RUN_BYTES {
275            self.text_run = String::new();
276        }
277    }
278
279    fn selected_style(
280        style: RenderStyle,
281        selection: Option<SelectionHighlight<'_>>,
282        row: u16,
283        col: u16,
284    ) -> RenderStyle {
285        let Some(selection) = selection else {
286            return style;
287        };
288        SelectionVisual::for_cell(Some(selection.range), row, col).apply(style, selection.background)
289    }
290}
291
292#[derive(Clone, Copy, Debug, Eq, PartialEq)]
293pub struct TerminalOrigin {
294    pub col: u16,
295    pub row: u16,
296}
297
298#[derive(Clone, Copy, Debug)]
299pub struct SelectionHighlight<'a> {
300    pub background: RenderColor,
301    pub range: &'a SelectionRange,
302}
303
304#[derive(Clone, Copy, Debug)]
305pub struct TerminalRender<'a> {
306    pub changes: &'a RenderFrameChanges,
307    pub frame_buffer: &'a FrameBuffer,
308    pub origin: TerminalOrigin,
309    pub selection: Option<SelectionHighlight<'a>>,
310}
311
312pub fn queue_full_redraw_start(stdout: &mut impl Write) -> rootcause::Result<()> {
313    // A complete redraw writes every cell, including trailing spaces. Clearing first creates a visible blank frame
314    // whenever an encoded render is superseded, even inside terminals that do not honor synchronized output.
315    queue_cmd(stdout, Hide)
316}
317
318#[derive(Clone, Debug, Eq, PartialEq)]
319pub enum ApplyOutcome {
320    Applied(RenderFrameChanges),
321    NeedsResync,
322}
323
324#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct RenderFrameChanges {
326    cursor: RenderCursor,
327    scope: RenderFrameScope,
328    rows: Vec<RenderRowSpan>,
329}
330
331impl RenderFrameChanges {
332    #[must_use]
333    pub const fn scope(&self) -> RenderFrameScope {
334        self.scope
335    }
336}
337
338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
339pub enum RenderFrameScope {
340    Full,
341    Partial,
342}
343
344fn apply_span_to_rows(rows: &mut [Arc<[RenderCell]>], span: &RenderRowSpan) -> rootcause::Result<()> {
345    validate_span_against_rows(rows, span)?;
346    let Some(row) = rows.get_mut(usize::from(span.row())) else {
347        return Err(report!("muxr render row outside frame").attach(format!("row={}", span.row())));
348    };
349    let row = Arc::make_mut(row);
350    let col = usize::from(span.col());
351
352    for (target, cell) in row.iter_mut().skip(col).zip(span.cells().iter()) {
353        *target = cell.clone();
354    }
355    Ok(())
356}
357
358fn validate_span_against_rows(rows: &[Arc<[RenderCell]>], span: &RenderRowSpan) -> rootcause::Result<()> {
359    let Some(row) = rows.get(usize::from(span.row())) else {
360        return Err(report!("muxr render row outside frame").attach(format!("row={}", span.row())));
361    };
362    let col = usize::from(span.col());
363    let end = col
364        .checked_add(span.cells().len())
365        .ok_or_else(|| report!("muxr render span column overflowed"))?;
366    if end > row.len() {
367        return Err(report!("muxr render span outside frame")
368            .attach(format!("row={}", span.row()))
369            .attach(format!("col={}", span.col()))
370            .attach(format!("cells={}", span.cells().len()))
371            .attach(format!("cols={}", row.len())));
372    }
373
374    Ok(())
375}
376
377#[derive(Clone, Copy, Debug, Eq, PartialEq)]
378enum SelectionVisual {
379    Selected,
380    Unselected,
381}
382
383impl SelectionVisual {
384    fn for_cell(selection: Option<&SelectionRange>, row: u16, col: u16) -> Self {
385        if selection.is_some_and(|selection| selection.contains(row, col)) {
386            Self::Selected
387        } else {
388            Self::Unselected
389        }
390    }
391
392    const fn apply(self, mut style: RenderStyle, selection_bg: RenderColor) -> RenderStyle {
393        match self {
394            Self::Selected => {
395                style.attrs = style.attrs.set_inverse(false);
396                style.bg = selection_bg;
397                style
398            }
399            Self::Unselected => style,
400        }
401    }
402}
403
404#[derive(Clone, Copy, Debug, Eq, PartialEq)]
405struct RenderRunStyle<'a> {
406    hyperlink_uri: Option<&'a str>,
407    style: RenderStyle,
408}
409
410fn queue_hyperlink_start(stdout: &mut impl Write, uri: &str) -> rootcause::Result<()> {
411    queue_bytes(stdout, OSC8_OPEN_PREFIX)?;
412    queue_bytes(stdout, uri.as_bytes())?;
413    queue_bytes(stdout, OSC8_TERMINATOR)
414}
415
416fn queue_hyperlink_end(stdout: &mut impl Write) -> rootcause::Result<()> {
417    queue_bytes(stdout, OSC8_CLOSE)
418}
419
420fn apply_style_transition(
421    stdout: &mut impl Write,
422    active_style: &mut RenderStyle,
423    next_style: RenderStyle,
424) -> rootcause::Result<()> {
425    if *active_style == next_style {
426        return Ok(());
427    }
428
429    let attrs_changed = active_style.attrs != next_style.attrs;
430    if attrs_changed {
431        reset_style(stdout)?;
432        *active_style = RenderStyle::default();
433    }
434    if active_style.fg != next_style.fg {
435        queue_cmd(stdout, SetForegroundColor(crossterm_color(next_style.fg)))?;
436    }
437    if active_style.bg != next_style.bg {
438        queue_cmd(stdout, SetBackgroundColor(crossterm_color(next_style.bg)))?;
439    }
440    if attrs_changed {
441        apply_enabled_attrs(stdout, next_style.attrs)?;
442    }
443    *active_style = next_style;
444    Ok(())
445}
446
447fn reset_style(stdout: &mut impl Write) -> rootcause::Result<()> {
448    queue_cmd(stdout, ResetColor)?;
449    queue_cmd(stdout, SetAttribute(Attribute::Reset))
450}
451
452fn apply_enabled_attrs(stdout: &mut impl Write, attrs: RenderTextStyle) -> rootcause::Result<()> {
453    if attrs.bold() {
454        queue_cmd(stdout, SetAttribute(Attribute::Bold))?;
455    }
456    if attrs.dim() {
457        queue_cmd(stdout, SetAttribute(Attribute::Dim))?;
458    }
459    if attrs.italic() {
460        queue_cmd(stdout, SetAttribute(Attribute::Italic))?;
461    }
462    if attrs.underline() {
463        queue_cmd(stdout, SetAttribute(Attribute::Underlined))?;
464    }
465    if attrs.inverse() {
466        queue_cmd(stdout, SetAttribute(Attribute::Reverse))?;
467    }
468    Ok(())
469}
470
471fn render_cursor(
472    stdout: &mut impl Write,
473    cursor: &RenderCursor,
474    row_offset: u16,
475    col_offset: u16,
476) -> rootcause::Result<()> {
477    if cursor.visibility == muxr_core::RenderCursorVisibility::Visible {
478        queue_cursor_shape(stdout, cursor.shape)?;
479        let row = cursor
480            .row
481            .checked_add(row_offset)
482            .ok_or_else(|| report!("muxr render cursor row offset overflowed"))?;
483        let col = cursor
484            .col
485            .checked_add(col_offset)
486            .ok_or_else(|| report!("muxr render cursor column offset overflowed"))?;
487        queue_cmd(stdout, MoveTo(col, row))?;
488        queue_cmd(stdout, Show)
489    } else {
490        queue_cmd(stdout, Hide)
491    }
492}
493
494fn queue_cursor_shape(stdout: &mut impl Write, shape: RenderCursorShape) -> rootcause::Result<()> {
495    queue_bytes(stdout, self::cursor_shape_sequence(shape))
496}
497
498const fn cursor_shape_sequence(shape: RenderCursorShape) -> &'static [u8] {
499    match shape {
500        RenderCursorShape::Default => b"\x1b[0 q",
501        RenderCursorShape::BlinkingBlock => b"\x1b[1 q",
502        RenderCursorShape::SteadyBlock => b"\x1b[2 q",
503        RenderCursorShape::BlinkingUnderline => b"\x1b[3 q",
504        RenderCursorShape::SteadyUnderline => b"\x1b[4 q",
505        RenderCursorShape::BlinkingBar => b"\x1b[5 q",
506        RenderCursorShape::SteadyBar => b"\x1b[6 q",
507    }
508}
509
510pub const fn crossterm_color(color: RenderColor) -> Color {
511    match color {
512        RenderColor::Default => Color::Reset,
513        RenderColor::Indexed(index) => Color::AnsiValue(index),
514        RenderColor::Rgb { r, g, b } => Color::Rgb { r, g, b },
515    }
516}
517
518fn queue_cmd<W, C>(stdout: &mut W, cmd: C) -> rootcause::Result<()>
519where
520    W: Write,
521    C: Command,
522{
523    Ok(stdout
524        .queue(cmd)
525        .map(|_| ())
526        .context("failed to write muxr render frame")?)
527}
528
529fn queue_bytes(stdout: &mut impl Write, bytes: &[u8]) -> rootcause::Result<()> {
530    stdout
531        .write_all(bytes)
532        .context("failed to write muxr render escape sequence")?;
533    Ok(())
534}
535
536#[cfg(test)]
537mod tests {
538    use muxr_config::MuxrConfig;
539    use muxr_core::ClientMousePosition;
540    use muxr_core::PaneId;
541    use muxr_core::PaneMouseMode;
542    use muxr_core::PaneRegionSnapshot;
543    use muxr_core::PaneRegionsSnapshot;
544    use muxr_core::RenderTextStyle;
545    use rstest::rstest;
546    use test_that::prelude::*;
547
548    use super::*;
549    use crate::copy_selection::SelectionInput;
550    use crate::copy_selection::SelectionState;
551
552    #[test]
553    fn test_frame_buffer_apply_when_baseline_arrives_stores_frame() -> rootcause::Result<()> {
554        let mut frame_buffer = FrameBuffer::default();
555
556        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
557            return Err(report!("expected applied baseline"));
558        };
559
560        assert_that!(changes.scope, eq(RenderFrameScope::Full));
561        assert_that!(changes.rows.len(), eq(2));
562        assert_that!(frame_buffer.seq, eq(Some(1)));
563        Ok(())
564    }
565
566    #[rstest]
567    #[case::missing_baseline(FrameBuffer::default())]
568    #[case::wrong_base(applied_frame_buffer()?)]
569    fn test_frame_buffer_apply_when_diff_base_is_missing_requests_resync(
570        #[case] mut frame_buffer: FrameBuffer,
571    ) -> rootcause::Result<()> {
572        let outcome = frame_buffer.apply(RenderUpdate::Diff(RenderDiff::new(
573            9,
574            10,
575            terminal_size()?,
576            RenderCursor {
577                row: 0,
578                col: 0,
579                shape: RenderCursorShape::Default,
580                visibility: muxr_core::RenderCursorVisibility::Visible,
581            },
582            vec![RenderRowSpan::new(0, 0, vec![render_cell("x")])?],
583        )?))?;
584
585        assert_that!(outcome, eq(ApplyOutcome::NeedsResync));
586        Ok(())
587    }
588
589    #[test]
590    fn test_frame_buffer_apply_when_diff_arrives_updates_dirty_cells() -> rootcause::Result<()> {
591        let mut frame_buffer = applied_frame_buffer()?;
592
593        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Diff(render_diff()?))? else {
594            return Err(report!("expected applied diff"));
595        };
596
597        assert_that!(changes.scope, eq(RenderFrameScope::Partial));
598        assert_that!(changes.rows.len(), eq(1));
599        assert_that!(frame_buffer.seq, eq(Some(2)));
600        Ok(())
601    }
602
603    #[test]
604    fn test_frame_buffer_clone_when_diff_changes_one_row_shares_unchanged_rows() -> rootcause::Result<()> {
605        let frame_buffer = applied_frame_buffer()?;
606        let mut successor = frame_buffer.clone();
607
608        let ApplyOutcome::Applied(_) = successor.apply(RenderUpdate::Diff(render_diff()?))? else {
609            return Err(report!("expected applied diff"));
610        };
611
612        assert_that!(Arc::ptr_eq(&frame_buffer.rows[0], &successor.rows[0]), eq(true));
613        assert_that!(Arc::ptr_eq(&frame_buffer.rows[1], &successor.rows[1]), eq(false));
614        Ok(())
615    }
616
617    #[test]
618    fn test_frame_buffer_full_redraw_after_diff_contains_complete_successor_frame() -> rootcause::Result<()> {
619        let mut frame_buffer = applied_frame_buffer()?;
620        let ApplyOutcome::Applied(_) = frame_buffer.apply(RenderUpdate::Diff(render_diff()?))? else {
621            return Err(report!("expected applied diff"));
622        };
623
624        let changes = frame_buffer
625            .full_redraw_changes()
626            .ok_or_else(|| report!("expected full redraw changes"))?;
627
628        assert_that!(changes.scope, eq(RenderFrameScope::Full));
629        assert_that!(changes.rows.len(), eq(2));
630        assert_that!(changes.rows[1].cells()[1].text(), eq("x"));
631        Ok(())
632    }
633
634    #[test]
635    fn test_frame_buffer_row_redraw_changes_when_rows_are_supplied_returns_only_requested_rows() -> rootcause::Result<()>
636    {
637        let frame_buffer = applied_frame_buffer()?;
638
639        let changes = frame_buffer
640            .row_redraw_changes(&[1])?
641            .ok_or_else(|| report!("expected row redraw changes"))?;
642
643        assert_that!(changes.scope, eq(RenderFrameScope::Partial));
644        assert_that!(changes.rows.len(), eq(1));
645        assert_that!(changes.rows[0].row(), eq(1));
646        Ok(())
647    }
648
649    #[test]
650    fn test_terminal_update_encoder_when_changes_arrive_writes_commands_without_flushing() -> rootcause::Result<()> {
651        let mut frame_buffer = FrameBuffer::default();
652        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
653            return Err(report!("expected applied baseline"));
654        };
655        let mut output = CountingWriter::default();
656
657        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
658
659        let rendered = output.rendered_string()?;
660        assert_that!(rendered, contains_substring("a"));
661        assert_that!(rendered, contains_substring("d"));
662        assert_that!(output.flushes, eq(0));
663        Ok(())
664    }
665
666    #[test]
667    fn test_terminal_update_encoder_when_text_run_capacity_is_outlier_discards_it() -> rootcause::Result<()> {
668        let mut frame_buffer = FrameBuffer::default();
669        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
670            return Err(report!("expected applied baseline"));
671        };
672        let mut encoder = TerminalUpdateEncoder::default();
673        encoder.text_run.reserve(MAX_RETAINED_TEXT_RUN_BYTES + 1);
674        let mut output = Vec::new();
675
676        encoder.encode(&mut output, terminal_render(&frame_buffer, &changes))?;
677
678        assert_that!(encoder.text_run.capacity(), le(MAX_RETAINED_TEXT_RUN_BYTES));
679        Ok(())
680    }
681
682    #[test]
683    fn test_terminal_update_encoder_when_previous_attempt_failed_does_not_reuse_stale_text() -> rootcause::Result<()> {
684        let mut frame_buffer = FrameBuffer::default();
685        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
686            return Err(report!("expected applied baseline"));
687        };
688        let mut encoder = TerminalUpdateEncoder::default();
689        let mut failed_output = FailOnTextWriter::default();
690
691        assert_that!(
692            encoder
693                .encode(&mut failed_output, terminal_render(&frame_buffer, &changes))
694                .is_err(),
695            eq(true)
696        );
697        assert_that!(failed_output.failed_on_text, eq(true));
698        assert_that!(encoder.text_run, eq(String::new()));
699        assert_that!(encoder.text_run.capacity(), le(MAX_RETAINED_TEXT_RUN_BYTES));
700
701        let mut retry_output = Vec::new();
702        encoder.encode(&mut retry_output, terminal_render(&frame_buffer, &changes))?;
703        let mut fresh_output = Vec::new();
704        TerminalUpdateEncoder::default().encode(&mut fresh_output, terminal_render(&frame_buffer, &changes))?;
705
706        assert_that!(retry_output, eq(fresh_output));
707        Ok(())
708    }
709
710    #[test]
711    fn test_terminal_update_encoder_when_diff_arrives_hides_cursor_before_dirty_row_moves() -> rootcause::Result<()> {
712        let mut frame_buffer = applied_frame_buffer()?;
713        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Diff(render_diff()?))? else {
714            return Err(report!("expected applied diff"));
715        };
716        let mut output = Vec::new();
717
718        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
719
720        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
721        let hide_index = rendered
722            .find("\x1b[?25l")
723            .ok_or_else(|| report!("expected cursor hide"))?;
724        let dirty_row_move_index = rendered
725            .find("\x1b[2;2H")
726            .ok_or_else(|| report!("expected dirty row cursor move"))?;
727        let final_cursor_move_index = rendered
728            .rfind("\x1b[2;2H")
729            .ok_or_else(|| report!("expected final cursor move"))?;
730        assert_that!(hide_index, lt(dirty_row_move_index));
731        assert_that!(dirty_row_move_index, lt(final_cursor_move_index));
732        assert_that!(rendered, ends_with("\x1b[?25h"));
733        Ok(())
734    }
735
736    #[test]
737    fn test_terminal_update_encoder_when_origin_is_offset_moves_rows_columns_and_cursor() -> rootcause::Result<()> {
738        let mut frame_buffer = FrameBuffer::default();
739        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
740            return Err(report!("expected applied baseline"));
741        };
742        let mut output = Vec::new();
743
744        encode_terminal_render(
745            &mut output,
746            TerminalRender {
747                changes: &changes,
748                frame_buffer: &frame_buffer,
749                origin: TerminalOrigin { col: 2, row: 1 },
750                selection: None,
751            },
752        )?;
753
754        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
755        assert_that!(rendered, contains_substring("\x1b[2;3H"));
756        assert_that!(rendered, contains_substring("\x1b[3;3H"));
757        assert_that!(occurrence_count(&rendered, "\x1b[2;3H"), eq(2));
758        Ok(())
759    }
760
761    #[test]
762    fn test_terminal_update_encoder_when_cursor_shape_is_bar_emits_shape() -> rootcause::Result<()> {
763        let mut frame_buffer = FrameBuffer::default();
764        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(
765            render_baseline_with_cursor_shape(RenderCursorShape::SteadyBar)?,
766        ))?
767        else {
768            return Err(report!("expected applied baseline"));
769        };
770        let mut output = Vec::new();
771
772        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
773
774        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
775        assert_that!(rendered, contains_substring("\x1b[6 q"));
776        Ok(())
777    }
778
779    #[test]
780    fn test_terminal_update_encoder_when_adjacent_cells_share_style_emits_one_color_transition() -> rootcause::Result<()>
781    {
782        let style = render_style(RenderColor::Indexed(1), RenderColor::Default, RenderTextStyle::empty());
783        let mut frame_buffer = FrameBuffer::default();
784        let ApplyOutcome::Applied(changes) =
785            frame_buffer.apply(RenderUpdate::Baseline(styled_render_baseline(style)?))?
786        else {
787            return Err(report!("expected applied baseline"));
788        };
789        let mut output = Vec::new();
790
791        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
792
793        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
794        let foreground_escape = expected_escape(ExpectedEscape::Foreground(RenderColor::Indexed(1)))?;
795        assert_that!(occurrence_count(&rendered, &foreground_escape), eq(1));
796        assert_that!(rendered, contains_substring("abc"));
797        Ok(())
798    }
799
800    #[test]
801    fn test_terminal_update_encoder_when_linked_cells_arrive_emits_osc8_around_run() -> rootcause::Result<()> {
802        let uri = "https://example.com";
803        let mut frame_buffer = FrameBuffer::default();
804        let ApplyOutcome::Applied(changes) =
805            frame_buffer.apply(RenderUpdate::Baseline(linked_render_baseline(uri)?))?
806        else {
807            return Err(report!("expected applied baseline"));
808        };
809        let mut output = Vec::new();
810
811        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
812
813        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
814        let open = osc8_open(uri);
815        let close = osc8_close()?;
816        assert_that!(rendered, contains_substring(format!("{open}ab{close}c")));
817        assert_that!(occurrence_count(&rendered, &open), eq(1));
818        assert_that!(occurrence_count(&rendered, &close), eq(1));
819        let close_index = rendered.find(&close).ok_or_else(|| report!("expected OSC 8 close"))?;
820        let reset_index = rendered
821            .rfind("\x1b[0m")
822            .ok_or_else(|| report!("expected terminal style reset"))?;
823        assert_that!(close_index, lt(reset_index));
824        Ok(())
825    }
826
827    #[test]
828    fn test_terminal_update_encoder_when_linked_diff_starts_mid_row_emits_osc8_start() -> rootcause::Result<()> {
829        let uri = "https://example.com/diff";
830        let mut frame_buffer = FrameBuffer::default();
831        let ApplyOutcome::Applied(_) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
832            return Err(report!("expected applied baseline"));
833        };
834        let diff = RenderDiff::new(
835            1,
836            2,
837            terminal_size()?,
838            RenderCursor {
839                row: 1,
840                col: 1,
841                shape: RenderCursorShape::Default,
842                visibility: muxr_core::RenderCursorVisibility::Visible,
843            },
844            vec![RenderRowSpan::new(1, 1, vec![linked_render_cell("x", uri)?])?],
845        )?;
846        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Diff(diff))? else {
847            return Err(report!("expected applied diff"));
848        };
849        let mut output = Vec::new();
850
851        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
852
853        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
854        assert_that!(
855            rendered,
856            contains_substring(format!("{}x{}", osc8_open(uri), osc8_close()?))
857        );
858        Ok(())
859    }
860
861    #[test]
862    fn test_terminal_update_encoder_when_linked_cell_is_selected_preserves_osc8() -> rootcause::Result<()> {
863        let uri = "https://example.com/selected";
864        let (selection, _) = self::selection_range_and_style()?;
865        let mut frame_buffer = FrameBuffer::default();
866        let ApplyOutcome::Applied(changes) =
867            frame_buffer.apply(RenderUpdate::Baseline(linked_render_baseline(uri)?))?
868        else {
869            return Err(report!("expected applied baseline"));
870        };
871        let mut output = Vec::new();
872
873        let highlight = SelectionHighlight {
874            background: MuxrConfig::default().selection.bg,
875            range: &selection,
876        };
877        encode_terminal_render(
878            &mut output,
879            TerminalRender {
880                changes: &changes,
881                frame_buffer: &frame_buffer,
882                origin: TerminalOrigin { col: 0, row: 0 },
883                selection: Some(highlight),
884            },
885        )?;
886
887        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
888        assert_that!(rendered, contains_substring(osc8_open(uri)));
889        Ok(())
890    }
891
892    #[test]
893    fn test_selection_visual_when_cell_is_selected_marks_only_selected_cells() -> rootcause::Result<()> {
894        let (selection, unselected_style) = self::selection_range_and_style()?;
895        let selection_bg = MuxrConfig::default().selection.bg;
896
897        assert_that!(
898            SelectionVisual::for_cell(Some(&selection), 0, 0),
899            eq(SelectionVisual::Selected)
900        );
901        assert_that!(
902            SelectionVisual::for_cell(Some(&selection), 0, 2),
903            eq(SelectionVisual::Unselected)
904        );
905        assert_that!(SelectionVisual::for_cell(None, 0, 0), eq(SelectionVisual::Unselected));
906        let highlight = SelectionHighlight {
907            background: selection_bg,
908            range: &selection,
909        };
910        let selected_style = TerminalUpdateEncoder::selected_style(unselected_style, Some(highlight), 0, 0);
911
912        // Selection colors are tunable; this only gates the invariant that selected cells stay visibly distinct.
913        assert_that!(selected_style.bg, not(eq(unselected_style.bg)));
914        assert_that!(selected_style.attrs.inverse(), eq(false));
915        assert_that!(
916            TerminalUpdateEncoder::selected_style(unselected_style, Some(highlight), 0, 2),
917            eq(unselected_style)
918        );
919        Ok(())
920    }
921
922    #[rstest]
923    #[case::foreground(
924        render_style(RenderColor::Indexed(1), RenderColor::Default, RenderTextStyle::empty()),
925        ExpectedEscape::Foreground(RenderColor::Indexed(1))
926    )]
927    #[case::background(
928        render_style(RenderColor::Default, RenderColor::Indexed(2), RenderTextStyle::empty()),
929        ExpectedEscape::Background(RenderColor::Indexed(2))
930    )]
931    #[case::bold(
932        render_style(
933            RenderColor::Default,
934            RenderColor::Default,
935            RenderTextStyle::empty().set_bold(true),
936        ),
937        ExpectedEscape::Attribute(Attribute::Bold)
938    )]
939    fn test_terminal_update_encoder_when_style_changes_emits_expected_transition(
940        #[case] style: RenderStyle,
941        #[case] expected: ExpectedEscape,
942    ) -> rootcause::Result<()> {
943        let mut frame_buffer = FrameBuffer::default();
944        let ApplyOutcome::Applied(changes) =
945            frame_buffer.apply(RenderUpdate::Baseline(styled_render_baseline(style)?))?
946        else {
947            return Err(report!("expected applied baseline"));
948        };
949        let mut output = Vec::new();
950
951        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
952
953        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
954        let expected_escape = expected_escape(expected)?;
955        assert_that!(rendered, contains_substring(expected_escape));
956        Ok(())
957    }
958
959    #[test]
960    fn test_queue_full_redraw_start_hides_cursor_without_clearing_or_flushing() -> rootcause::Result<()> {
961        let mut output = CountingWriter::default();
962
963        queue_full_redraw_start(&mut output)?;
964
965        let rendered = output.rendered_string()?;
966        assert_that!(rendered, contains_substring("\x1b[?25l"));
967        assert_that!(rendered.contains("\x1b[2J"), eq(false));
968        assert_that!(output.flushes, eq(0));
969        Ok(())
970    }
971
972    #[derive(Default)]
973    struct CountingWriter {
974        bytes: Vec<u8>,
975        flushes: usize,
976    }
977
978    impl CountingWriter {
979        fn rendered_string(&self) -> rootcause::Result<String> {
980            Ok(String::from_utf8(self.bytes.clone()).context("muxr render test output was not utf8")?)
981        }
982    }
983
984    impl std::io::Write for CountingWriter {
985        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
986            self.bytes.extend_from_slice(buf);
987            Ok(buf.len())
988        }
989
990        fn flush(&mut self) -> std::io::Result<()> {
991            self.flushes = self.flushes.saturating_add(1);
992            Ok(())
993        }
994    }
995
996    #[derive(Default)]
997    struct FailOnTextWriter {
998        failed_on_text: bool,
999    }
1000
1001    impl Write for FailOnTextWriter {
1002        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1003            if buf.contains(&b'a') {
1004                self.failed_on_text = true;
1005                return Err(std::io::Error::new(
1006                    std::io::ErrorKind::BrokenPipe,
1007                    "injected muxr text-run write failure",
1008                ));
1009            }
1010            Ok(buf.len())
1011        }
1012
1013        fn flush(&mut self) -> std::io::Result<()> {
1014            Ok(())
1015        }
1016    }
1017
1018    fn encode_terminal_render(output: &mut impl Write, render: TerminalRender<'_>) -> rootcause::Result<()> {
1019        TerminalUpdateEncoder::default().encode(output, render)
1020    }
1021
1022    const fn terminal_render<'a>(frame_buffer: &'a FrameBuffer, changes: &'a RenderFrameChanges) -> TerminalRender<'a> {
1023        TerminalRender {
1024            changes,
1025            frame_buffer,
1026            origin: TerminalOrigin { col: 0, row: 0 },
1027            selection: None,
1028        }
1029    }
1030
1031    fn applied_frame_buffer() -> rootcause::Result<FrameBuffer> {
1032        let mut frame_buffer = FrameBuffer::default();
1033        let ApplyOutcome::Applied(_) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
1034            return Err(rootcause::report!("expected applied muxr render baseline"));
1035        };
1036        Ok(frame_buffer)
1037    }
1038
1039    fn render_baseline() -> rootcause::Result<muxr_core::RenderBaseline> {
1040        self::render_baseline_with_cursor_shape(RenderCursorShape::Default)
1041    }
1042
1043    fn render_baseline_with_cursor_shape(shape: RenderCursorShape) -> rootcause::Result<muxr_core::RenderBaseline> {
1044        muxr_core::RenderBaseline::new(
1045            1,
1046            terminal_size()?,
1047            RenderCursor {
1048                row: 0,
1049                col: 0,
1050                shape,
1051                visibility: muxr_core::RenderCursorVisibility::Visible,
1052            },
1053            vec![
1054                RenderRowSpan::new(0, 0, vec![render_cell("a"), render_cell("b")])?,
1055                RenderRowSpan::new(1, 0, vec![render_cell("c"), render_cell("d")])?,
1056            ],
1057        )
1058    }
1059
1060    fn render_diff() -> rootcause::Result<RenderDiff> {
1061        RenderDiff::new(
1062            1,
1063            2,
1064            terminal_size()?,
1065            RenderCursor {
1066                row: 1,
1067                col: 1,
1068                shape: RenderCursorShape::Default,
1069                visibility: muxr_core::RenderCursorVisibility::Visible,
1070            },
1071            vec![RenderRowSpan::new(1, 1, vec![render_cell("x")])?],
1072        )
1073    }
1074
1075    fn styled_render_baseline(style: RenderStyle) -> rootcause::Result<muxr_core::RenderBaseline> {
1076        muxr_core::RenderBaseline::new(
1077            1,
1078            TerminalSize::new(3, 1)?,
1079            RenderCursor {
1080                row: 0,
1081                col: 0,
1082                shape: RenderCursorShape::Default,
1083                visibility: muxr_core::RenderCursorVisibility::Visible,
1084            },
1085            vec![RenderRowSpan::new(
1086                0,
1087                0,
1088                vec![
1089                    RenderCell::narrow("a", style),
1090                    RenderCell::narrow("b", style),
1091                    RenderCell::narrow("c", style),
1092                ],
1093            )?],
1094        )
1095    }
1096
1097    fn linked_render_baseline(uri: &str) -> rootcause::Result<muxr_core::RenderBaseline> {
1098        muxr_core::RenderBaseline::new(
1099            1,
1100            TerminalSize::new(3, 1)?,
1101            RenderCursor {
1102                row: 0,
1103                col: 0,
1104                shape: RenderCursorShape::Default,
1105                visibility: muxr_core::RenderCursorVisibility::Visible,
1106            },
1107            vec![RenderRowSpan::new(
1108                0,
1109                0,
1110                vec![
1111                    linked_render_cell("a", uri)?,
1112                    linked_render_cell("b", uri)?,
1113                    render_cell("c"),
1114                ],
1115            )?],
1116        )
1117    }
1118
1119    fn render_style(fg: RenderColor, bg: RenderColor, attrs: RenderTextStyle) -> RenderStyle {
1120        RenderStyle { attrs, bg, fg }
1121    }
1122
1123    fn linked_render_cell(text: &str, uri: &str) -> rootcause::Result<RenderCell> {
1124        render_cell(text).with_hyperlink_uri(uri)
1125    }
1126
1127    fn render_cell(text: &str) -> RenderCell {
1128        RenderCell::narrow(text, RenderStyle::default())
1129    }
1130
1131    fn osc8_open(uri: &str) -> String {
1132        format!("\x1b]8;;{uri}\x1b\\")
1133    }
1134
1135    fn osc8_close() -> rootcause::Result<String> {
1136        Ok(String::from_utf8(OSC8_CLOSE.to_vec()).context("muxr OSC 8 close was not utf8")?)
1137    }
1138
1139    fn terminal_size() -> rootcause::Result<TerminalSize> {
1140        TerminalSize::new(2, 2)
1141    }
1142
1143    fn selection_range_and_style() -> rootcause::Result<(SelectionRange, RenderStyle)> {
1144        let mut frame_buffer = FrameBuffer::default();
1145        frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))?;
1146        let regions = PaneRegionsSnapshot::new(vec![PaneRegionSnapshot::new(
1147            PaneId::new(1)?,
1148            0,
1149            0,
1150            2,
1151            1,
1152            PaneMouseMode::None,
1153            0,
1154        )?])?;
1155        let mut selection = SelectionState::default();
1156        selection.apply(
1157            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1158            &regions,
1159            &frame_buffer,
1160        )?;
1161        selection.apply(
1162            SelectionInput::Update(ClientMousePosition { row: 0, col: 1 }),
1163            &regions,
1164            &frame_buffer,
1165        )?;
1166        let range = selection
1167            .range()
1168            .cloned()
1169            .ok_or_else(|| report!("expected muxr selection range"))?;
1170
1171        Ok((range, RenderStyle::default()))
1172    }
1173
1174    fn occurrence_count(haystack: &str, needle: &str) -> usize {
1175        haystack.matches(needle).count()
1176    }
1177
1178    #[derive(Clone, Copy)]
1179    enum ExpectedEscape {
1180        Attribute(Attribute),
1181        Background(RenderColor),
1182        Foreground(RenderColor),
1183    }
1184
1185    fn expected_escape(expected: ExpectedEscape) -> rootcause::Result<String> {
1186        let mut output = Vec::new();
1187        match expected {
1188            ExpectedEscape::Attribute(attribute) => queue_cmd(&mut output, SetAttribute(attribute))?,
1189            ExpectedEscape::Background(color) => {
1190                queue_cmd(&mut output, SetBackgroundColor(crossterm_color(color)))?;
1191            }
1192            ExpectedEscape::Foreground(color) => {
1193                queue_cmd(&mut output, SetForegroundColor(crossterm_color(color)))?;
1194            }
1195        }
1196
1197        Ok(String::from_utf8(output).context("muxr render test output was not utf8")?)
1198    }
1199}