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_legacy_wide_cells_are_followed_by_text_preserves_logical_order()
763    -> rootcause::Result<()> {
764        let baseline = muxr_core::RenderBaseline::new(
765            1,
766            TerminalSize::new(6, 1)?,
767            RenderCursor {
768                row: 0,
769                col: 0,
770                shape: RenderCursorShape::Default,
771                visibility: muxr_core::RenderCursorVisibility::Hidden,
772            },
773            vec![RenderRowSpan::new(
774                0,
775                0,
776                vec![
777                    RenderCell::wide("👩‍", RenderStyle::default()),
778                    RenderCell::wide_continuation(RenderStyle::default()),
779                    RenderCell::wide("🌾", RenderStyle::default()),
780                    RenderCell::wide_continuation(RenderStyle::default()),
781                    render_cell("X"),
782                    render_cell("|"),
783                ],
784            )?],
785        )?;
786        let mut frame_buffer = FrameBuffer::default();
787        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(baseline))? else {
788            return Err(report!("expected applied baseline"));
789        };
790        let mut output = Vec::new();
791
792        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
793
794        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
795        assert_that!(rendered, contains_substring("👩‍🌾"));
796        assert_that!(rendered, contains_substring("X|"));
797        assert_that!(rendered, not(contains_substring("\x1b[1;3H")));
798        Ok(())
799    }
800
801    #[test]
802    fn test_terminal_update_encoder_when_cursor_shape_is_bar_emits_shape() -> rootcause::Result<()> {
803        let mut frame_buffer = FrameBuffer::default();
804        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Baseline(
805            render_baseline_with_cursor_shape(RenderCursorShape::SteadyBar)?,
806        ))?
807        else {
808            return Err(report!("expected applied baseline"));
809        };
810        let mut output = Vec::new();
811
812        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
813
814        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
815        assert_that!(rendered, contains_substring("\x1b[6 q"));
816        Ok(())
817    }
818
819    #[test]
820    fn test_terminal_update_encoder_when_adjacent_cells_share_style_emits_one_color_transition() -> rootcause::Result<()>
821    {
822        let style = render_style(RenderColor::Indexed(1), RenderColor::Default, RenderTextStyle::empty());
823        let mut frame_buffer = FrameBuffer::default();
824        let ApplyOutcome::Applied(changes) =
825            frame_buffer.apply(RenderUpdate::Baseline(styled_render_baseline(style)?))?
826        else {
827            return Err(report!("expected applied baseline"));
828        };
829        let mut output = Vec::new();
830
831        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
832
833        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
834        let foreground_escape = expected_escape(ExpectedEscape::Foreground(RenderColor::Indexed(1)))?;
835        assert_that!(occurrence_count(&rendered, &foreground_escape), eq(1));
836        assert_that!(rendered, contains_substring("abc"));
837        Ok(())
838    }
839
840    #[test]
841    fn test_terminal_update_encoder_when_linked_cells_arrive_emits_osc8_around_run() -> rootcause::Result<()> {
842        let uri = "https://example.com";
843        let mut frame_buffer = FrameBuffer::default();
844        let ApplyOutcome::Applied(changes) =
845            frame_buffer.apply(RenderUpdate::Baseline(linked_render_baseline(uri)?))?
846        else {
847            return Err(report!("expected applied baseline"));
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        let open = osc8_open(uri);
855        let close = osc8_close()?;
856        assert_that!(rendered, contains_substring(format!("{open}ab{close}c")));
857        assert_that!(occurrence_count(&rendered, &open), eq(1));
858        assert_that!(occurrence_count(&rendered, &close), eq(1));
859        let close_index = rendered.find(&close).ok_or_else(|| report!("expected OSC 8 close"))?;
860        let reset_index = rendered
861            .rfind("\x1b[0m")
862            .ok_or_else(|| report!("expected terminal style reset"))?;
863        assert_that!(close_index, lt(reset_index));
864        Ok(())
865    }
866
867    #[test]
868    fn test_terminal_update_encoder_when_linked_diff_starts_mid_row_emits_osc8_start() -> rootcause::Result<()> {
869        let uri = "https://example.com/diff";
870        let mut frame_buffer = FrameBuffer::default();
871        let ApplyOutcome::Applied(_) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
872            return Err(report!("expected applied baseline"));
873        };
874        let diff = RenderDiff::new(
875            1,
876            2,
877            terminal_size()?,
878            RenderCursor {
879                row: 1,
880                col: 1,
881                shape: RenderCursorShape::Default,
882                visibility: muxr_core::RenderCursorVisibility::Visible,
883            },
884            vec![RenderRowSpan::new(1, 1, vec![linked_render_cell("x", uri)?])?],
885        )?;
886        let ApplyOutcome::Applied(changes) = frame_buffer.apply(RenderUpdate::Diff(diff))? else {
887            return Err(report!("expected applied diff"));
888        };
889        let mut output = Vec::new();
890
891        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
892
893        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
894        assert_that!(
895            rendered,
896            contains_substring(format!("{}x{}", osc8_open(uri), osc8_close()?))
897        );
898        Ok(())
899    }
900
901    #[test]
902    fn test_terminal_update_encoder_when_linked_cell_is_selected_preserves_osc8() -> rootcause::Result<()> {
903        let uri = "https://example.com/selected";
904        let (selection, _) = self::selection_range_and_style()?;
905        let mut frame_buffer = FrameBuffer::default();
906        let ApplyOutcome::Applied(changes) =
907            frame_buffer.apply(RenderUpdate::Baseline(linked_render_baseline(uri)?))?
908        else {
909            return Err(report!("expected applied baseline"));
910        };
911        let mut output = Vec::new();
912
913        let highlight = SelectionHighlight {
914            background: MuxrConfig::default().selection.bg,
915            range: &selection,
916        };
917        encode_terminal_render(
918            &mut output,
919            TerminalRender {
920                changes: &changes,
921                frame_buffer: &frame_buffer,
922                origin: TerminalOrigin { col: 0, row: 0 },
923                selection: Some(highlight),
924            },
925        )?;
926
927        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
928        assert_that!(rendered, contains_substring(osc8_open(uri)));
929        Ok(())
930    }
931
932    #[test]
933    fn test_selection_visual_when_cell_is_selected_marks_only_selected_cells() -> rootcause::Result<()> {
934        let (selection, unselected_style) = self::selection_range_and_style()?;
935        let selection_bg = MuxrConfig::default().selection.bg;
936
937        assert_that!(
938            SelectionVisual::for_cell(Some(&selection), 0, 0),
939            eq(SelectionVisual::Selected)
940        );
941        assert_that!(
942            SelectionVisual::for_cell(Some(&selection), 0, 2),
943            eq(SelectionVisual::Unselected)
944        );
945        assert_that!(SelectionVisual::for_cell(None, 0, 0), eq(SelectionVisual::Unselected));
946        let highlight = SelectionHighlight {
947            background: selection_bg,
948            range: &selection,
949        };
950        let selected_style = TerminalUpdateEncoder::selected_style(unselected_style, Some(highlight), 0, 0);
951
952        // Selection colors are tunable; this only gates the invariant that selected cells stay visibly distinct.
953        assert_that!(selected_style.bg, not(eq(unselected_style.bg)));
954        assert_that!(selected_style.attrs.inverse(), eq(false));
955        assert_that!(
956            TerminalUpdateEncoder::selected_style(unselected_style, Some(highlight), 0, 2),
957            eq(unselected_style)
958        );
959        Ok(())
960    }
961
962    #[rstest]
963    #[case::foreground(
964        render_style(RenderColor::Indexed(1), RenderColor::Default, RenderTextStyle::empty()),
965        ExpectedEscape::Foreground(RenderColor::Indexed(1))
966    )]
967    #[case::background(
968        render_style(RenderColor::Default, RenderColor::Indexed(2), RenderTextStyle::empty()),
969        ExpectedEscape::Background(RenderColor::Indexed(2))
970    )]
971    #[case::bold(
972        render_style(
973            RenderColor::Default,
974            RenderColor::Default,
975            RenderTextStyle::empty().set_bold(true),
976        ),
977        ExpectedEscape::Attribute(Attribute::Bold)
978    )]
979    fn test_terminal_update_encoder_when_style_changes_emits_expected_transition(
980        #[case] style: RenderStyle,
981        #[case] expected: ExpectedEscape,
982    ) -> rootcause::Result<()> {
983        let mut frame_buffer = FrameBuffer::default();
984        let ApplyOutcome::Applied(changes) =
985            frame_buffer.apply(RenderUpdate::Baseline(styled_render_baseline(style)?))?
986        else {
987            return Err(report!("expected applied baseline"));
988        };
989        let mut output = Vec::new();
990
991        encode_terminal_render(&mut output, terminal_render(&frame_buffer, &changes))?;
992
993        let rendered = String::from_utf8(output).context("muxr render test output was not utf8")?;
994        let expected_escape = expected_escape(expected)?;
995        assert_that!(rendered, contains_substring(expected_escape));
996        Ok(())
997    }
998
999    #[test]
1000    fn test_queue_full_redraw_start_hides_cursor_without_clearing_or_flushing() -> rootcause::Result<()> {
1001        let mut output = CountingWriter::default();
1002
1003        queue_full_redraw_start(&mut output)?;
1004
1005        let rendered = output.rendered_string()?;
1006        assert_that!(rendered, contains_substring("\x1b[?25l"));
1007        assert_that!(rendered.contains("\x1b[2J"), eq(false));
1008        assert_that!(output.flushes, eq(0));
1009        Ok(())
1010    }
1011
1012    #[derive(Default)]
1013    struct CountingWriter {
1014        bytes: Vec<u8>,
1015        flushes: usize,
1016    }
1017
1018    impl CountingWriter {
1019        fn rendered_string(&self) -> rootcause::Result<String> {
1020            Ok(String::from_utf8(self.bytes.clone()).context("muxr render test output was not utf8")?)
1021        }
1022    }
1023
1024    impl std::io::Write for CountingWriter {
1025        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1026            self.bytes.extend_from_slice(buf);
1027            Ok(buf.len())
1028        }
1029
1030        fn flush(&mut self) -> std::io::Result<()> {
1031            self.flushes = self.flushes.saturating_add(1);
1032            Ok(())
1033        }
1034    }
1035
1036    #[derive(Default)]
1037    struct FailOnTextWriter {
1038        failed_on_text: bool,
1039    }
1040
1041    impl Write for FailOnTextWriter {
1042        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1043            if buf.contains(&b'a') {
1044                self.failed_on_text = true;
1045                return Err(std::io::Error::new(
1046                    std::io::ErrorKind::BrokenPipe,
1047                    "injected muxr text-run write failure",
1048                ));
1049            }
1050            Ok(buf.len())
1051        }
1052
1053        fn flush(&mut self) -> std::io::Result<()> {
1054            Ok(())
1055        }
1056    }
1057
1058    fn encode_terminal_render(output: &mut impl Write, render: TerminalRender<'_>) -> rootcause::Result<()> {
1059        TerminalUpdateEncoder::default().encode(output, render)
1060    }
1061
1062    const fn terminal_render<'a>(frame_buffer: &'a FrameBuffer, changes: &'a RenderFrameChanges) -> TerminalRender<'a> {
1063        TerminalRender {
1064            changes,
1065            frame_buffer,
1066            origin: TerminalOrigin { col: 0, row: 0 },
1067            selection: None,
1068        }
1069    }
1070
1071    fn applied_frame_buffer() -> rootcause::Result<FrameBuffer> {
1072        let mut frame_buffer = FrameBuffer::default();
1073        let ApplyOutcome::Applied(_) = frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))? else {
1074            return Err(rootcause::report!("expected applied muxr render baseline"));
1075        };
1076        Ok(frame_buffer)
1077    }
1078
1079    fn render_baseline() -> rootcause::Result<muxr_core::RenderBaseline> {
1080        self::render_baseline_with_cursor_shape(RenderCursorShape::Default)
1081    }
1082
1083    fn render_baseline_with_cursor_shape(shape: RenderCursorShape) -> rootcause::Result<muxr_core::RenderBaseline> {
1084        muxr_core::RenderBaseline::new(
1085            1,
1086            terminal_size()?,
1087            RenderCursor {
1088                row: 0,
1089                col: 0,
1090                shape,
1091                visibility: muxr_core::RenderCursorVisibility::Visible,
1092            },
1093            vec![
1094                RenderRowSpan::new(0, 0, vec![render_cell("a"), render_cell("b")])?,
1095                RenderRowSpan::new(1, 0, vec![render_cell("c"), render_cell("d")])?,
1096            ],
1097        )
1098    }
1099
1100    fn render_diff() -> rootcause::Result<RenderDiff> {
1101        RenderDiff::new(
1102            1,
1103            2,
1104            terminal_size()?,
1105            RenderCursor {
1106                row: 1,
1107                col: 1,
1108                shape: RenderCursorShape::Default,
1109                visibility: muxr_core::RenderCursorVisibility::Visible,
1110            },
1111            vec![RenderRowSpan::new(1, 1, vec![render_cell("x")])?],
1112        )
1113    }
1114
1115    fn styled_render_baseline(style: RenderStyle) -> rootcause::Result<muxr_core::RenderBaseline> {
1116        muxr_core::RenderBaseline::new(
1117            1,
1118            TerminalSize::new(3, 1)?,
1119            RenderCursor {
1120                row: 0,
1121                col: 0,
1122                shape: RenderCursorShape::Default,
1123                visibility: muxr_core::RenderCursorVisibility::Visible,
1124            },
1125            vec![RenderRowSpan::new(
1126                0,
1127                0,
1128                vec![
1129                    RenderCell::narrow("a", style),
1130                    RenderCell::narrow("b", style),
1131                    RenderCell::narrow("c", style),
1132                ],
1133            )?],
1134        )
1135    }
1136
1137    fn linked_render_baseline(uri: &str) -> rootcause::Result<muxr_core::RenderBaseline> {
1138        muxr_core::RenderBaseline::new(
1139            1,
1140            TerminalSize::new(3, 1)?,
1141            RenderCursor {
1142                row: 0,
1143                col: 0,
1144                shape: RenderCursorShape::Default,
1145                visibility: muxr_core::RenderCursorVisibility::Visible,
1146            },
1147            vec![RenderRowSpan::new(
1148                0,
1149                0,
1150                vec![
1151                    linked_render_cell("a", uri)?,
1152                    linked_render_cell("b", uri)?,
1153                    render_cell("c"),
1154                ],
1155            )?],
1156        )
1157    }
1158
1159    fn render_style(fg: RenderColor, bg: RenderColor, attrs: RenderTextStyle) -> RenderStyle {
1160        RenderStyle { attrs, bg, fg }
1161    }
1162
1163    fn linked_render_cell(text: &str, uri: &str) -> rootcause::Result<RenderCell> {
1164        render_cell(text).with_hyperlink_uri(uri)
1165    }
1166
1167    fn render_cell(text: &str) -> RenderCell {
1168        RenderCell::narrow(text, RenderStyle::default())
1169    }
1170
1171    fn osc8_open(uri: &str) -> String {
1172        format!("\x1b]8;;{uri}\x1b\\")
1173    }
1174
1175    fn osc8_close() -> rootcause::Result<String> {
1176        Ok(String::from_utf8(OSC8_CLOSE.to_vec()).context("muxr OSC 8 close was not utf8")?)
1177    }
1178
1179    fn terminal_size() -> rootcause::Result<TerminalSize> {
1180        TerminalSize::new(2, 2)
1181    }
1182
1183    fn selection_range_and_style() -> rootcause::Result<(SelectionRange, RenderStyle)> {
1184        let mut frame_buffer = FrameBuffer::default();
1185        frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))?;
1186        let regions = PaneRegionsSnapshot::new(vec![PaneRegionSnapshot::new(
1187            PaneId::new(1)?,
1188            0,
1189            0,
1190            2,
1191            1,
1192            PaneMouseMode::None,
1193            0,
1194        )?])?;
1195        let mut selection = SelectionState::default();
1196        selection.apply(
1197            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1198            &regions,
1199            &frame_buffer,
1200        )?;
1201        selection.apply(
1202            SelectionInput::Update(ClientMousePosition { row: 0, col: 1 }),
1203            &regions,
1204            &frame_buffer,
1205        )?;
1206        let range = selection
1207            .range()
1208            .cloned()
1209            .ok_or_else(|| report!("expected muxr selection range"))?;
1210
1211        Ok((range, RenderStyle::default()))
1212    }
1213
1214    fn occurrence_count(haystack: &str, needle: &str) -> usize {
1215        haystack.matches(needle).count()
1216    }
1217
1218    #[derive(Clone, Copy)]
1219    enum ExpectedEscape {
1220        Attribute(Attribute),
1221        Background(RenderColor),
1222        Foreground(RenderColor),
1223    }
1224
1225    fn expected_escape(expected: ExpectedEscape) -> rootcause::Result<String> {
1226        let mut output = Vec::new();
1227        match expected {
1228            ExpectedEscape::Attribute(attribute) => queue_cmd(&mut output, SetAttribute(attribute))?,
1229            ExpectedEscape::Background(color) => {
1230                queue_cmd(&mut output, SetBackgroundColor(crossterm_color(color)))?;
1231            }
1232            ExpectedEscape::Foreground(color) => {
1233                queue_cmd(&mut output, SetForegroundColor(crossterm_color(color)))?;
1234            }
1235        }
1236
1237        Ok(String::from_utf8(output).context("muxr render test output was not utf8")?)
1238    }
1239}