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, 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;
 34
 35/// The `TerminalOutput` struct handles the parsing and rendering of text input,
 36/// simulating a basic terminal environment within REPL output.
 37///
 38/// `TerminalOutput` is designed to handle various types of text-based output, including:
 39///
 40/// * stdout (standard output)
 41/// * stderr (standard error)
 42/// * text/plain content
 43/// * error tracebacks
 44///
 45/// It uses the Alacritty terminal emulator backend to process and render text,
 46/// supporting ANSI escape sequences for text formatting and colors.
 47///
 48pub struct TerminalOutput {
 49    full_buffer: Option<Entity<Buffer>>,
 50    /// ANSI escape sequence processor for parsing input text.
 51    parser: Processor,
 52    /// Alacritty terminal instance that manages the terminal state and content.
 53    handler: alacritty_terminal::Term<VoidListener>,
 54}
 55
 56const DEFAULT_NUM_LINES: usize = 32;
 57const DEFAULT_NUM_COLUMNS: usize = 128;
 58
 59/// Returns the default text style for the terminal output.
 60pub fn text_style(window: &mut Window, cx: &mut App) -> TextStyle {
 61    let settings = ThemeSettings::get_global(cx).clone();
 62
 63    let font_size = settings.buffer_font_size(cx).into();
 64    let font_family = settings.buffer_font.family;
 65    let font_features = settings.buffer_font.features;
 66    let font_weight = settings.buffer_font.weight;
 67    let font_fallbacks = settings.buffer_font.fallbacks;
 68
 69    let theme = cx.theme();
 70
 71    TextStyle {
 72        font_family,
 73        font_features,
 74        font_weight,
 75        font_fallbacks,
 76        font_size,
 77        font_style: FontStyle::Normal,
 78        line_height: window.line_height().into(),
 79        background_color: Some(theme.colors().terminal_ansi_background),
 80        white_space: WhiteSpace::Normal,
 81        // These are going to be overridden per-cell
 82        color: theme.colors().terminal_foreground,
 83        ..Default::default()
 84    }
 85}
 86
 87/// Returns the default terminal size for the terminal output.
 88pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBounds {
 89    let text_style = text_style(window, cx);
 90    let text_system = window.text_system();
 91
 92    let line_height = window.line_height();
 93
 94    let font_pixels = text_style.font_size.to_pixels(window.rem_size());
 95    let font_id = text_system.resolve_font(&text_style.font());
 96
 97    let cell_width = text_system
 98        .advance(font_id, font_pixels, 'w')
 99        .unwrap()
100        .width;
101
102    let num_lines = DEFAULT_NUM_LINES;
103    let columns = DEFAULT_NUM_COLUMNS;
104
105    // Reversed math from terminal::TerminalSize to get pixel width according to terminal width
106    let width = columns as f32 * cell_width;
107    let height = num_lines as f32 * window.line_height();
108
109    terminal::TerminalBounds {
110        cell_width,
111        line_height,
112        bounds: Bounds {
113            origin: gpui::Point::default(),
114            size: size(width, height),
115        },
116    }
117}
118
119impl TerminalOutput {
120    /// Creates a new `TerminalOutput` instance.
121    ///
122    /// This method initializes a new terminal emulator with default configuration
123    /// and sets up the necessary components for handling terminal events and rendering.
124    ///
125    pub fn new(window: &mut Window, cx: &mut App) -> Self {
126        let term = alacritty_terminal::Term::new(
127            Config::default(),
128            &terminal_size(window, cx),
129            VoidListener,
130        );
131
132        Self {
133            parser: Processor::new(),
134            handler: term,
135            full_buffer: None,
136        }
137    }
138
139    /// Creates a new `TerminalOutput` instance with initial content.
140    ///
141    /// Initializes a new terminal output and populates it with the provided text.
142    ///
143    /// # Arguments
144    ///
145    /// * `text` - A string slice containing the initial text for the terminal output.
146    /// * `cx` - A mutable reference to the `WindowContext` for initialization.
147    ///
148    /// # Returns
149    ///
150    /// A new instance of `TerminalOutput` containing the provided text.
151    pub fn from(text: &str, window: &mut Window, cx: &mut App) -> Self {
152        let mut output = Self::new(window, cx);
153        output.append_text(text, cx);
154        output
155    }
156
157    /// Appends text to the terminal output.
158    ///
159    /// Processes each byte of the input text, handling newline characters specially
160    /// to ensure proper cursor movement. Uses the ANSI parser to process the input
161    /// and update the terminal state.
162    ///
163    /// As an example, if the user runs the following Python code in this REPL:
164    ///
165    /// ```python
166    /// import time
167    /// print("Hello,", end="")
168    /// time.sleep(1)
169    /// print(" world!")
170    /// ```
171    ///
172    /// Then append_text will be called twice, with the following arguments:
173    ///
174    /// ```rust
175    /// terminal_output.append_text("Hello,")
176    /// terminal_output.append_text(" world!")
177    /// ```
178    /// Resulting in a single output of "Hello, world!".
179    ///
180    /// # Arguments
181    ///
182    /// * `text` - A string slice containing the text to be appended.
183    pub fn append_text(&mut self, text: &str, cx: &mut App) {
184        for byte in text.as_bytes() {
185            if *byte == b'\n' {
186                // Dirty (?) hack to move the cursor down
187                self.parser.advance(&mut self.handler, &[b'\r']);
188                self.parser.advance(&mut self.handler, &[b'\n']);
189            } else {
190                self.parser.advance(&mut self.handler, &[*byte]);
191            }
192        }
193
194        // This will keep the buffer up to date, though with some terminal codes it won't be perfect
195        if let Some(buffer) = self.full_buffer.as_ref() {
196            buffer.update(cx, |buffer, cx| {
197                buffer.edit([(buffer.len()..buffer.len(), text)], None, cx);
198            });
199        }
200    }
201
202    fn full_text(&self) -> String {
203        let mut full_text = String::new();
204
205        // Get the total number of lines, including history
206        let total_lines = self.handler.grid().total_lines();
207        let visible_lines = self.handler.screen_lines();
208        let history_lines = total_lines - visible_lines;
209
210        // Capture history lines in correct order (oldest to newest)
211        for line in (0..history_lines).rev() {
212            let line_index = Line(-(line as i32) - 1);
213            let start = Point::new(line_index, Column(0));
214            let end = Point::new(line_index, Column(self.handler.columns() - 1));
215            let line_content = self.handler.bounds_to_string(start, end);
216
217            if !line_content.trim().is_empty() {
218                full_text.push_str(&line_content);
219                full_text.push('\n');
220            }
221        }
222
223        // Capture visible lines
224        for line in 0..visible_lines {
225            let line_index = Line(line as i32);
226            let start = Point::new(line_index, Column(0));
227            let end = Point::new(line_index, Column(self.handler.columns() - 1));
228            let line_content = self.handler.bounds_to_string(start, end);
229
230            if !line_content.trim().is_empty() {
231                full_text.push_str(&line_content);
232                full_text.push('\n');
233            }
234        }
235
236        // Trim any trailing newlines
237        full_text.trim_end().to_string()
238    }
239}
240
241impl Render for TerminalOutput {
242    /// Renders the terminal output as a GPUI element.
243    ///
244    /// Converts the current terminal state into a renderable GPUI element. It handles
245    /// the layout of the terminal grid, calculates the dimensions of the output, and
246    /// creates a canvas element that paints the terminal cells and background rectangles.
247    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
248        let text_style = text_style(window, cx);
249        let text_system = window.text_system();
250
251        let grid = self
252            .handler
253            .renderable_content()
254            .display_iter
255            .map(|ic| terminal::IndexedCell {
256                point: ic.point,
257                cell: ic.cell.clone(),
258            });
259        let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
260        let (rects, batched_text_runs) =
261            TerminalElement::layout_grid(grid, 0, &text_style, None, minimum_contrast, cx);
262
263        // lines are 0-indexed, so we must add 1 to get the number of lines
264        let text_line_height = text_style.line_height_in_pixels(window.rem_size());
265        let num_lines = batched_text_runs
266            .iter()
267            .map(|b| b.start_point.line)
268            .max()
269            .unwrap_or(0)
270            + 1;
271        let height = num_lines as f32 * text_line_height;
272
273        let font_pixels = text_style.font_size.to_pixels(window.rem_size());
274        let font_id = text_system.resolve_font(&text_style.font());
275
276        let cell_width = text_system
277            .advance(font_id, font_pixels, 'w')
278            .map(|advance| advance.width)
279            .unwrap_or(Pixels(0.0));
280
281        canvas(
282            // prepaint
283            move |_bounds, _, _| {},
284            // paint
285            move |bounds, _, window, cx| {
286                for rect in rects {
287                    rect.paint(
288                        bounds.origin,
289                        &terminal::TerminalBounds {
290                            cell_width,
291                            line_height: text_line_height,
292                            bounds,
293                        },
294                        window,
295                    );
296                }
297
298                for batch in batched_text_runs {
299                    batch.paint(
300                        bounds.origin,
301                        &terminal::TerminalBounds {
302                            cell_width,
303                            line_height: text_line_height,
304                            bounds,
305                        },
306                        window,
307                        cx,
308                    );
309                }
310            },
311        )
312        // We must set the height explicitly for the editor block to size itself correctly
313        .h(height)
314    }
315}
316
317impl OutputContent for TerminalOutput {
318    fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
319        Some(ClipboardItem::new_string(self.full_text()))
320    }
321
322    fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
323        true
324    }
325
326    fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
327        true
328    }
329
330    fn buffer_content(&mut self, _: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
331        if self.full_buffer.as_ref().is_some() {
332            return self.full_buffer.clone();
333        }
334
335        let buffer = cx.new(|cx| {
336            let mut buffer =
337                Buffer::local(self.full_text(), cx).with_language(language::PLAIN_TEXT.clone(), cx);
338            buffer.set_capability(language::Capability::ReadOnly, cx);
339            buffer
340        });
341
342        self.full_buffer = Some(buffer.clone());
343        Some(buffer)
344    }
345}