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 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 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 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 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 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 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 ®ions,
1159 &frame_buffer,
1160 )?;
1161 selection.apply(
1162 SelectionInput::Update(ClientMousePosition { row: 0, col: 1 }),
1163 ®ions,
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}