shadow_terminal/output/
native.rs

1//! Sending terminal output to end users. This "native" output is more useful when used directly
2//! with Rust (as opposed to FFI users or STDOUT users).
3//!
4//! We try to be efficient in how we send output. If the change from the underlying PTY is small,
5//! then we just send diffs. Otherwise we send the entire screen or scrollback buffer.
6
7use std::io::Write as _;
8
9use snafu::{OptionExt as _, ResultExt as _};
10use termwiz::surface::Change as TermwizChange;
11use termwiz::surface::Position as TermwizPosition;
12
13/// Send raw output directly to the user's terminal without going via the renderer. Useful for
14/// sending ANSI codes to the terminal emulator. For example, `^[?1h` to set "application mode"
15/// arrow key codes.
16///
17/// # Errors
18/// When writing to STDOUT fails.
19#[inline]
20pub fn raw_string_direct_to_terminal(
21    string: &str,
22) -> Result<(), crate::errors::ShadowTerminalError> {
23    std::io::stdout()
24        .write(string.as_bytes())
25        .with_whatever_context(|err| {
26            format!("Writing direct raw output to user's terminal: {err:?}")
27        })?;
28    std::io::stdout().flush().with_whatever_context(|err| {
29        format!("Writing direct raw output to user's terminal: {err:?}")
30    })
31}
32
33/// The mode of the terminal screen, therefore either the primary screen, where the scrollback is
34/// collected, or the alternate screen, where apps like `vim`, `htop`, etc, get rendered.
35#[derive(
36    Clone, Debug, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema, Eq, PartialEq,
37)]
38#[non_exhaustive]
39pub enum ScreenMode {
40    /// The typical REPL mode of the terminal. Also can be thought of as a view onto the bottom of
41    /// the scrollback.
42    #[default]
43    Primary,
44    /// The so-called "alternate" screen where apps like `vim`, `htop`, etc, get displayed.
45    Alternate,
46}
47
48/// Hopefully the most common form of output, therefore a small diff of changes.
49#[derive(Clone)]
50#[non_exhaustive]
51pub enum SurfaceDiff {
52    /// Output generated by the terminal whilst in REPL mode, aka, the "primary screen".
53    Scrollback(ScrollbackDiff),
54    /// The current view of the terminal, regardless of whether it's the primary or alternate
55    /// screen.
56    Screen(ScreenDiff),
57}
58
59/// The scrollback is a history, albeit limited, of all the output whilst in REPL mode, aka the
60/// "primary screen".
61///
62/// Even though it's called the "scrollback", it's still the main interactive view, we just mostly
63/// are seeing the bottom of the scrollback where the current prompt is.
64#[derive(Clone, Debug, Default)]
65#[non_exhaustive]
66pub struct ScrollbackDiff {
67    /// A list of `termwiz` changes that, once applied, should bring any surfaces following this
68    /// view, up to date.
69    pub changes: Vec<TermwizChange>,
70    /// The size of the underlying PTY at the time this diff was made.
71    pub size: (usize, usize),
72    /// The current position of the user's view on the scrollback. Is 0 when not scrolling.
73    pub position: usize,
74    /// The size of the current scrollback. Can increase up to the configured maximum.
75    pub height: usize,
76}
77
78/// The constant view into the terminal, regardless of whether it's in primary or alternate screen.
79///
80/// However, sending diffs of it is only possible when in "alternate mode". This is because the
81/// diffs generated by the scrollback only apply to the ever-growing scrollback buffer. The screen
82/// on the other hand is always limited to a certain height, in which case diffs can't just be
83/// additive, they must also replace what is under them.
84#[derive(Clone, Debug, Default)]
85#[non_exhaustive]
86pub struct ScreenDiff {
87    /// A list of `termwiz` changes that, once applied, should bring any surfaces following this
88    /// view, up to date.
89    pub changes: Vec<TermwizChange>,
90    /// Whether the terminal screen is primary or alternate.
91    pub mode: ScreenMode,
92    /// The size of the underlying PTY at the time this diff was made.
93    pub size: (usize, usize),
94    /// All the details about the user's cursor.
95    pub cursor: wezterm_term::CursorPosition,
96}
97
98impl std::fmt::Debug for SurfaceDiff {
99    #[expect(clippy::min_ident_chars, reason = "It's in the standard library")]
100    #[inline]
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        let info = match self {
103            Self::Scrollback(diff) => ("Scrollback", diff.changes.len(), diff.size),
104            Self::Screen(diff) => ("Screen", diff.changes.len(), diff.size),
105        };
106        write!(
107            f,
108            "{} diff of {} change(s) {}x{}",
109            info.0, info.1, info.2 .0, info.2 .1,
110        )
111    }
112}
113
114/// A complete, cell-for-cell duplicate of the current Wezterm shadow terminal.
115///
116/// When diffing is deemed ineffeicient, say when resizing, or when scrolling in `vim`, it's
117/// hopefully more efficient to just send the entire view of the terminal. After all, diffs have to
118/// be applied one by one, so there must come a point where it's cheaper to just send all the cell
119/// data verbatim.
120#[derive(Clone)]
121#[non_exhaustive]
122pub enum CompleteSurface {
123    /// A complete scrollback.
124    Scrollback(CompleteScrollback),
125    /// The current view of the terminal, regardless of whether it's the primary or alternate
126    /// screen.
127    Screen(CompleteScreen),
128}
129
130impl std::fmt::Debug for CompleteSurface {
131    #[expect(clippy::min_ident_chars, reason = "It's in the standard library")]
132    #[inline]
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        let info = match self {
135            Self::Scrollback(scrollback) => ("scrollback", &scrollback.surface),
136            Self::Screen(screen) => ("screen", &screen.surface),
137        };
138        write!(
139            f,
140            "Complete {} surface: {}x{}",
141            info.0,
142            info.1.dimensions().0,
143            info.1.dimensions().1
144        )
145    }
146}
147
148/// Every cell in the current scrollback, and the current location if the scrollback is actively
149/// being scrolled.
150#[derive(Default, Clone)]
151#[non_exhaustive]
152pub struct CompleteScrollback {
153    /// The `termwiz` surface data.
154    pub surface: termwiz::surface::Surface,
155    /// The position of the current scroll, so it would be 0, if the user is not scrolling.
156    pub position: usize,
157}
158
159/// Every cell in the current sreen, and the screen's mode.
160#[derive(Default, Clone)]
161#[non_exhaustive]
162pub struct CompleteScreen {
163    /// The `termwiz` surface data.
164    pub surface: termwiz::surface::Surface,
165    /// Whether the terminal is in primary or alternate mode.
166    pub mode: ScreenMode,
167}
168
169impl CompleteScreen {
170    /// Instantiate an empty `CompleteScreen`.
171    #[inline]
172    #[must_use]
173    pub fn new(width: usize, height: usize) -> Self {
174        Self {
175            surface: termwiz::surface::Surface::new(width, height),
176            mode: ScreenMode::default(),
177        }
178    }
179}
180
181#[derive(Clone, Debug)]
182#[non_exhaustive]
183/// All the possible kinds of output, whether they're primary, alternate, diffs or entire snapshots.
184pub enum Output {
185    /// A diff should be the most common output format, it's more efficient.
186    Diff(SurfaceDiff),
187    /// In certain cases, it's likely more efficient to just send all the cell data for the
188    /// terminal. Or perhaps it's useful in moments of recovery or reset.
189    Complete(CompleteSurface),
190}
191
192/// The kinds of surfaces that can be output.
193#[derive(Debug)]
194#[non_exhaustive]
195pub enum SurfaceKind {
196    /// The terminal scrollback, or "primary screen".
197    Scrollback,
198    /// The current view of the terminal, regardless of whether it's the primary or alternate
199    /// screen.
200    Screen,
201}
202
203impl Default for SurfaceDiff {
204    #[inline]
205    fn default() -> Self {
206        Self::Scrollback(ScrollbackDiff::default())
207    }
208}
209
210impl crate::shadow_terminal::ShadowTerminal {
211    /// Build output for broadcasting to end users.
212    pub(crate) fn build_current_output(
213        &mut self,
214        kind: &SurfaceKind,
215    ) -> Result<Output, crate::errors::ShadowTerminalError> {
216        tracing::trace!("Converting Wezterm terminal state to a `termwiz::surface::Surface`");
217
218        let tty_size = self.terminal.get_size();
219        let total_lines = self.terminal.screen().scrollback_rows();
220        let changed_line_ids = self.terminal.screen().get_changed_stable_rows(
221            0..total_lines.try_into().with_whatever_context(|err| {
222                format!("Couldn't convert `total_lines` to `isize`: {err:?}")
223            })?,
224            self.last_sent.pty_sequence,
225        );
226
227        // TODO: Explore these heuristics. Maybe make them user configurable?
228        let is_diff_efficient = match kind {
229            SurfaceKind::Scrollback => changed_line_ids.len() < total_lines.div_euclid(2),
230            SurfaceKind::Screen => changed_line_ids.len() < tty_size.rows,
231        };
232
233        let is_building_screen = matches!(kind, SurfaceKind::Screen);
234        let is_resized = self.last_sent.pty_size != (tty_size.cols, tty_size.rows);
235        let is_diff_possible = !is_resized && !is_building_screen;
236
237        let output = if is_diff_efficient && is_diff_possible {
238            self.build_diff(kind, changed_line_ids, tty_size, total_lines)?
239        } else {
240            self.build_complete_surface(kind, tty_size, total_lines)?
241        };
242
243        Ok(output)
244    }
245
246    /// Query the active terminal for its screen mode.
247    fn get_screen_mode(&self) -> ScreenMode {
248        if self.terminal.is_alt_screen_active() {
249            ScreenMode::Alternate
250        } else {
251            ScreenMode::Primary
252        }
253    }
254
255    /// Build a diff of the changes from the PTY
256    fn build_diff(
257        &mut self,
258        kind: &SurfaceKind,
259        changed_line_ids: Vec<wezterm_term::StableRowIndex>,
260        tty_size: wezterm_term::TerminalSize,
261        total_lines: usize,
262    ) -> Result<Output, crate::errors::ShadowTerminalError> {
263        tracing::trace!("Building diff from Wezterm for {kind:?} from lines: {changed_line_ids:?}");
264
265        let changes = self.generate_changes(kind, Some(changed_line_ids))?;
266        let diff = match kind {
267            SurfaceKind::Scrollback => SurfaceDiff::Scrollback(ScrollbackDiff {
268                changes,
269                size: (tty_size.cols, tty_size.rows),
270                position: self.scroll_position,
271                height: total_lines,
272            }),
273            SurfaceKind::Screen => SurfaceDiff::Screen(ScreenDiff {
274                mode: self.get_screen_mode(),
275                changes,
276                size: (tty_size.cols, tty_size.rows),
277                cursor: self.terminal.cursor_pos(),
278            }),
279        };
280        Ok(Output::Diff(diff))
281    }
282
283    /// Build an entire surface of all the cell data from the PTY.
284    fn build_complete_surface(
285        &mut self,
286        kind: &SurfaceKind,
287        tty_size: wezterm_term::TerminalSize,
288        total_lines: usize,
289    ) -> Result<Output, crate::errors::ShadowTerminalError> {
290        tracing::trace!(
291            "Building surface or diff from Wezterm for {kind:?} from lines: 0 to {total_lines:?}"
292        );
293
294        let changes = self.generate_changes(kind, None)?;
295        let complete_surface = match kind {
296            SurfaceKind::Scrollback => {
297                let changes_count = changes.len();
298                let mut surface = termwiz::surface::Surface::new(tty_size.cols, total_lines);
299                surface.add_changes(changes);
300                tracing::trace!(
301                    "Sending complete Scrollback ({} changes): Sample:\n{:.100}\n...",
302                    changes_count,
303                    surface.screen_chars_to_string()
304                );
305                CompleteSurface::Scrollback(CompleteScrollback {
306                    surface,
307                    position: self.scroll_position,
308                })
309            }
310            SurfaceKind::Screen => {
311                let changes_count = changes.len();
312                let mut surface = termwiz::surface::Surface::new(tty_size.cols, tty_size.rows);
313                surface.add_changes(changes);
314                tracing::trace!(
315                    "Sending complete Screen ({}x{}, {} changes): Sample:\n{:.1000}\n...",
316                    tty_size.cols,
317                    tty_size.rows,
318                    changes_count,
319                    surface.screen_chars_to_string()
320                );
321                CompleteSurface::Screen(CompleteScreen {
322                    surface,
323                    mode: self.get_screen_mode(),
324                })
325            }
326        };
327
328        Ok(Output::Complete(complete_surface))
329    }
330
331    /// Generate a change set. It is used both for generating diffs and it is, perhaps
332    /// surprisingly, the method required to construct an entire surface from scratch.
333    fn generate_changes(
334        &mut self,
335        kind: &SurfaceKind,
336        maybe_dirty_lines: Option<Vec<isize>>,
337    ) -> Result<Vec<TermwizChange>, crate::errors::ShadowTerminalError> {
338        let mut changes = Vec::new();
339        let (line_ids, output_start) = self.calculate_line_ids(kind, maybe_dirty_lines)?;
340        let screen = self.terminal.screen_mut();
341
342        for line_id in line_ids {
343            let line = screen.line_mut(line_id);
344            let y = line_id - output_start;
345            changes.push(TermwizChange::CursorPosition {
346                x: TermwizPosition::Absolute(0),
347                y: TermwizPosition::Absolute(y),
348            });
349
350            let mut wide_character_offset = 0;
351            for cell in line.cells_mut() {
352                // Wide characters, like say, "🤓", use up 2 cells in the terminal. The following
353                // cell is always left blank. The Wezterm terminal already does this, and also adding
354                // a wide character to a Termwiz surface will create these blank cells. Therefore
355                // without intervention we'll actually create blank cells from both Wezterm and
356                // Termwiz, doubling the number of needed blank cells. So we just ignore the blank
357                // cells coming from Wezterm and let Termwiz handle automating all the blank cells.
358                if wide_character_offset > 0 {
359                    wide_character_offset -= 1;
360                    continue;
361                }
362
363                let mut attributes = vec![
364                    TermwizChange::AllAttributes(cell.attrs().clone()),
365                    cell.str().into(),
366                ];
367                wide_character_offset = cell.width() - 1;
368
369                changes.append(&mut attributes);
370            }
371        }
372
373        self.cursor_state(&mut changes)?;
374
375        Ok(changes)
376    }
377
378    /// Add the current cursor state.
379    fn cursor_state(
380        &self,
381        changes: &mut Vec<TermwizChange>,
382    ) -> Result<(), crate::errors::ShadowTerminalError> {
383        let cursor = self.terminal.cursor_pos();
384
385        let x = cursor.x;
386        let y = cursor.y.try_into().with_whatever_context(|err| {
387            format!("Couldn't convert cursor position to usize: {err:?}")
388        })?;
389        changes.push(TermwizChange::CursorPosition {
390            x: TermwizPosition::Absolute(x),
391            y: TermwizPosition::Absolute(y),
392        });
393
394        changes.push(TermwizChange::CursorShape(cursor.shape));
395        changes.push(TermwizChange::CursorVisibility(cursor.visibility));
396
397        Ok(())
398    }
399
400    /// Calculate the IDs of the lines that need to be output. Could just be the changed lines, or
401    /// all the lines of the screen/scrollback.
402    fn calculate_line_ids(
403        &mut self,
404        kind: &SurfaceKind,
405        maybe_dirty_lines: Option<Vec<isize>>,
406    ) -> Result<(Vec<usize>, usize), crate::errors::ShadowTerminalError> {
407        let tty_size = self.terminal.get_size();
408        let screen = self.terminal.screen_mut();
409        let mut line_ids: Vec<usize> = Vec::new();
410        let (output_start, output_end) = match kind {
411            SurfaceKind::Scrollback => (0, screen.scrollback_rows()),
412            SurfaceKind::Screen => {
413                let end = screen.scrollback_rows() - self.scroll_position;
414                let start = end - tty_size.rows;
415                (start, end)
416            }
417        };
418
419        match maybe_dirty_lines {
420            Some(dirty_lines) => {
421                for stable_dirty_line in dirty_lines {
422                    let physical_line_id = screen
423                        .stable_row_to_phys(stable_dirty_line)
424                        .with_whatever_context(|| {
425                            "Couldn't get physical row ID from stable row ID"
426                        })?;
427                    line_ids.push(physical_line_id);
428                }
429            }
430            None => {
431                for line_id in output_start..output_end {
432                    line_ids.push(line_id);
433                }
434            }
435        }
436
437        Ok((line_ids, output_start))
438    }
439}
440
441#[cfg(test)]
442mod test {
443    #[cfg(not(target_os = "windows"))]
444    #[tokio::test(flavor = "multi_thread")]
445    async fn wide_characters() {
446        let mut stepper = Box::pin(crate::tests::helpers::run(Some(100), None)).await;
447        let columns = stepper.shadow_terminal.terminal.get_size().cols;
448        let full_row = "😀".repeat(columns.div_euclid(2));
449
450        let command = format!("echo {full_row}");
451        stepper.send_command(command.as_str()).unwrap();
452
453        // The test code that dumps the terminal contents also includes the required blank cell(s)
454        // that always follow wide characters. So we need to match them too.
455        let raw_with_spaces = full_row
456            .chars()
457            .map(|character| character.to_string())
458            .collect::<Vec<String>>()
459            .join(" ");
460
461        stepper
462            .wait_for_string(&raw_with_spaces, None)
463            .await
464            .unwrap();
465    }
466}