plain.rs

  1//! # Plain Text Output
  2//!
  3//! This module provides functionality for rendering plain text output in a terminal-like format.
  4//! It uses the Alacritty terminal emulator backend to process and display text, supporting
  5//! ANSI escape sequences for formatting, colors, and other terminal features.
  6//!
  7//! The main component of this module is the `TerminalOutput` struct, which handles the parsing
  8//! and rendering of text input, simulating a basic terminal environment within REPL output.
  9//!
 10//! This module is used for displaying:
 11//!
 12//! - Standard output (stdout)
 13//! - Standard error (stderr)
 14//! - Plain text content
 15//! - Error tracebacks
 16//!
 17
 18use alacritty_terminal::{
 19    event::VoidListener,
 20    grid::Dimensions as _,
 21    index::{Column, Line, Point},
 22    term::Config,
 23    vte::ansi::Processor,
 24};
 25use gpui::{Bounds, ClipboardItem, Entity, FontStyle, Pixels, TextStyle, WhiteSpace, canvas, size};
 26use language::Buffer;
 27use settings::Settings as _;
 28use terminal::terminal_settings::TerminalSettings;
 29use terminal_view::terminal_element::TerminalElement;
 30use theme::ThemeSettings;
 31use ui::{IntoElement, prelude::*};
 32
 33use crate::outputs::OutputContent;
 34use crate::repl_settings::ReplSettings;
 35
 36/// The `TerminalOutput` struct handles the parsing and rendering of text input,
 37/// simulating a basic terminal environment within REPL output.
 38///
 39/// `TerminalOutput` is designed to handle various types of text-based output, including:
 40///
 41/// * stdout (standard output)
 42/// * stderr (standard error)
 43/// * text/plain content
 44/// * error tracebacks
 45///
 46/// It uses the Alacritty terminal emulator backend to process and render text,
 47/// supporting ANSI escape sequences for text formatting and colors.
 48///
 49pub struct TerminalOutput {
 50    full_buffer: Option<Entity<Buffer>>,
 51    /// ANSI escape sequence processor for parsing input text.
 52    parser: Processor,
 53    /// Alacritty terminal instance that manages the terminal state and content.
 54    handler: alacritty_terminal::Term<VoidListener>,
 55}
 56
 57/// Returns the default text style for the terminal output.
 58pub fn text_style(window: &mut Window, cx: &App) -> TextStyle {
 59    let settings = ThemeSettings::get_global(cx).clone();
 60
 61    let font_size = settings.buffer_font_size(cx).into();
 62    let font_family = settings.buffer_font.family;
 63    let font_features = settings.buffer_font.features;
 64    let font_weight = settings.buffer_font.weight;
 65    let font_fallbacks = settings.buffer_font.fallbacks;
 66
 67    let theme = cx.theme();
 68
 69    TextStyle {
 70        font_family,
 71        font_features,
 72        font_weight,
 73        font_fallbacks,
 74        font_size,
 75        font_style: FontStyle::Normal,
 76        line_height: window.line_height().into(),
 77        background_color: Some(theme.colors().terminal_ansi_background),
 78        white_space: WhiteSpace::Normal,
 79        // These are going to be overridden per-cell
 80        color: theme.colors().terminal_foreground,
 81        ..Default::default()
 82    }
 83}
 84
 85/// Returns the default terminal size for the terminal output.
 86pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBounds {
 87    let text_style = text_style(window, cx);
 88    let text_system = window.text_system();
 89
 90    let line_height = window.line_height();
 91
 92    let font_pixels = text_style.font_size.to_pixels(window.rem_size());
 93    let font_id = text_system.resolve_font(&text_style.font());
 94
 95    let cell_width = text_system
 96        .advance(font_id, font_pixels, 'w')
 97        .map(|advance| advance.width)
 98        .unwrap_or(Pixels::ZERO);
 99
100    let num_lines = ReplSettings::get_global(cx).max_lines;
101    let columns = ReplSettings::get_global(cx).max_columns;
102
103    // Reversed math from terminal::TerminalSize to get pixel width according to terminal width
104    let width = columns as f32 * cell_width;
105    let height = num_lines as f32 * window.line_height();
106
107    terminal::TerminalBounds {
108        cell_width,
109        line_height,
110        bounds: Bounds {
111            origin: gpui::Point::default(),
112            size: size(width, height),
113        },
114    }
115}
116
117pub fn max_width_for_columns(
118    columns: usize,
119    window: &mut Window,
120    cx: &App,
121) -> Option<gpui::Pixels> {
122    if columns == 0 {
123        return None;
124    }
125
126    let text_style = text_style(window, cx);
127    let text_system = window.text_system();
128    let font_pixels = text_style.font_size.to_pixels(window.rem_size());
129    let font_id = text_system.resolve_font(&text_style.font());
130    let cell_width = text_system
131        .advance(font_id, font_pixels, 'w')
132        .map(|advance| advance.width)
133        .unwrap_or(Pixels::ZERO);
134
135    Some(cell_width * columns as f32)
136}
137
138impl TerminalOutput {
139    /// Creates a new `TerminalOutput` instance.
140    ///
141    /// This method initializes a new terminal emulator with default configuration
142    /// and sets up the necessary components for handling terminal events and rendering.
143    ///
144    pub fn new(window: &mut Window, cx: &mut App) -> Self {
145        let term = alacritty_terminal::Term::new(
146            Config::default(),
147            &terminal_size(window, cx),
148            VoidListener,
149        );
150
151        Self {
152            parser: Processor::new(),
153            handler: term,
154            full_buffer: None,
155        }
156    }
157
158    /// Creates a new `TerminalOutput` instance with initial content.
159    ///
160    /// Initializes a new terminal output and populates it with the provided text.
161    ///
162    /// # Arguments
163    ///
164    /// * `text` - A string slice containing the initial text for the terminal output.
165    /// * `cx` - A mutable reference to the `WindowContext` for initialization.
166    ///
167    /// # Returns
168    ///
169    /// A new instance of `TerminalOutput` containing the provided text.
170    pub fn from(text: &str, window: &mut Window, cx: &mut App) -> Self {
171        let mut output = Self::new(window, cx);
172        output.append_text(text, cx);
173        output
174    }
175
176    /// Appends text to the terminal output.
177    ///
178    /// Processes each byte of the input text, handling newline characters specially
179    /// to ensure proper cursor movement. Uses the ANSI parser to process the input
180    /// and update the terminal state.
181    ///
182    /// As an example, if the user runs the following Python code in this REPL:
183    ///
184    /// ```python
185    /// import time
186    /// print("Hello,", end="")
187    /// time.sleep(1)
188    /// print(" world!")
189    /// ```
190    ///
191    /// Then append_text will be called twice, with the following arguments:
192    ///
193    /// ```ignore
194    /// terminal_output.append_text("Hello,");
195    /// terminal_output.append_text(" world!");
196    /// ```
197    /// Resulting in a single output of "Hello, world!".
198    ///
199    /// # Arguments
200    ///
201    /// * `text` - A string slice containing the text to be appended.
202    pub fn append_text(&mut self, text: &str, cx: &mut App) {
203        for byte in text.as_bytes() {
204            if *byte == b'\n' {
205                // Dirty (?) hack to move the cursor down
206                self.parser.advance(&mut self.handler, &[b'\r']);
207                self.parser.advance(&mut self.handler, &[b'\n']);
208            } else {
209                self.parser.advance(&mut self.handler, &[*byte]);
210            }
211        }
212
213        // This will keep the buffer up to date, though with some terminal codes it won't be perfect
214        if let Some(buffer) = self.full_buffer.as_ref() {
215            buffer.update(cx, |buffer, cx| {
216                buffer.edit([(buffer.len()..buffer.len(), text)], None, cx);
217            });
218        }
219    }
220
221    pub fn full_text(&self) -> String {
222        fn sanitize(mut line: String) -> Option<String> {
223            line.retain(|ch| ch != '\u{0}' && ch != '\r');
224            if line.trim().is_empty() {
225                return None;
226            }
227            let trimmed = line.trim_end_matches([' ', '\t']);
228            Some(trimmed.to_owned())
229        }
230
231        let mut lines = Vec::new();
232
233        // Get the total number of lines, including history
234        let total_lines = self.handler.grid().total_lines();
235        let visible_lines = self.handler.screen_lines();
236        let history_lines = total_lines - visible_lines;
237
238        // Capture history lines in correct order (oldest to newest)
239        for line in (0..history_lines).rev() {
240            let line_index = Line(-(line as i32) - 1);
241            let start = Point::new(line_index, Column(0));
242            let end = Point::new(line_index, Column(self.handler.columns() - 1));
243            if let Some(cleaned) = sanitize(self.handler.bounds_to_string(start, end)) {
244                lines.push(cleaned);
245            }
246        }
247
248        // Capture visible lines
249        for line in 0..visible_lines {
250            let line_index = Line(line as i32);
251            let start = Point::new(line_index, Column(0));
252            let end = Point::new(line_index, Column(self.handler.columns() - 1));
253            if let Some(cleaned) = sanitize(self.handler.bounds_to_string(start, end)) {
254                lines.push(cleaned);
255            }
256        }
257
258        if lines.is_empty() {
259            String::new()
260        } else {
261            let mut full_text = lines.join("\n");
262            full_text.push('\n');
263            full_text
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use gpui::{TestAppContext, VisualTestContext};
272    use settings::SettingsStore;
273
274    fn init_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
275        cx.update(|cx| {
276            let settings_store = SettingsStore::test(cx);
277            cx.set_global(settings_store);
278            theme::init(theme::LoadThemes::JustBase, cx);
279        });
280        cx.add_empty_window()
281    }
282
283    #[gpui::test]
284    fn test_max_width_for_columns_zero(cx: &mut TestAppContext) {
285        let cx = init_test(cx);
286        let result = cx.update(|window, cx| max_width_for_columns(0, window, cx));
287        assert!(result.is_none());
288    }
289
290    #[gpui::test]
291    fn test_max_width_for_columns_matches_cell_width(cx: &mut TestAppContext) {
292        let cx = init_test(cx);
293        let columns = 5;
294        let (result, expected) = cx.update(|window, cx| {
295            let text_style = text_style(window, cx);
296            let text_system = window.text_system();
297            let font_pixels = text_style.font_size.to_pixels(window.rem_size());
298            let font_id = text_system.resolve_font(&text_style.font());
299            let cell_width = text_system
300                .advance(font_id, font_pixels, 'w')
301                .map(|advance| advance.width)
302                .unwrap_or(gpui::Pixels::ZERO);
303            let result = max_width_for_columns(columns, window, cx);
304            (result, cell_width * columns as f32)
305        });
306
307        let Some(result) = result else {
308            panic!("expected max width for columns {columns}");
309        };
310        let result_f32: f32 = result.into();
311        let expected_f32: f32 = expected.into();
312        assert!((result_f32 - expected_f32).abs() < 0.01);
313    }
314}
315
316impl Render for TerminalOutput {
317    /// Renders the terminal output as a GPUI element.
318    ///
319    /// Converts the current terminal state into a renderable GPUI element. It handles
320    /// the layout of the terminal grid, calculates the dimensions of the output, and
321    /// creates a canvas element that paints the terminal cells and background rectangles.
322    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
323        let text_style = text_style(window, cx);
324        let text_system = window.text_system();
325
326        let grid = self
327            .handler
328            .renderable_content()
329            .display_iter
330            .map(|ic| terminal::IndexedCell {
331                point: ic.point,
332                cell: ic.cell.clone(),
333            });
334        let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
335        let (rects, batched_text_runs) =
336            TerminalElement::layout_grid(grid, 0, &text_style, None, minimum_contrast, cx);
337
338        // lines are 0-indexed, so we must add 1 to get the number of lines
339        let text_line_height = text_style.line_height_in_pixels(window.rem_size());
340        let num_lines = batched_text_runs
341            .iter()
342            .map(|b| b.start_point.line)
343            .max()
344            .unwrap_or(0)
345            + 1;
346        let height = num_lines as f32 * text_line_height;
347
348        let font_pixels = text_style.font_size.to_pixels(window.rem_size());
349        let font_id = text_system.resolve_font(&text_style.font());
350
351        let cell_width = text_system
352            .advance(font_id, font_pixels, 'w')
353            .map(|advance| advance.width)
354            .unwrap_or(Pixels::ZERO);
355
356        canvas(
357            // prepaint
358            move |_bounds, _, _| {},
359            // paint
360            move |bounds, _, window, cx| {
361                for rect in rects {
362                    rect.paint(
363                        bounds.origin,
364                        &terminal::TerminalBounds {
365                            cell_width,
366                            line_height: text_line_height,
367                            bounds,
368                        },
369                        window,
370                    );
371                }
372
373                for batch in batched_text_runs {
374                    batch.paint(
375                        bounds.origin,
376                        &terminal::TerminalBounds {
377                            cell_width,
378                            line_height: text_line_height,
379                            bounds,
380                        },
381                        window,
382                        cx,
383                    );
384                }
385            },
386        )
387        // We must set the height explicitly for the editor block to size itself correctly
388        .h(height)
389    }
390}
391
392impl OutputContent for TerminalOutput {
393    fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
394        Some(ClipboardItem::new_string(self.full_text()))
395    }
396
397    fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
398        true
399    }
400
401    fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
402        true
403    }
404
405    fn buffer_content(&mut self, _: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
406        if self.full_buffer.as_ref().is_some() {
407            return self.full_buffer.clone();
408        }
409
410        let buffer = cx.new(|cx| {
411            let mut buffer =
412                Buffer::local(self.full_text(), cx).with_language(language::PLAIN_TEXT.clone(), cx);
413            buffer.set_capability(language::Capability::ReadOnly, cx);
414            buffer
415        });
416
417        self.full_buffer = Some(buffer.clone());
418        Some(buffer)
419    }
420}