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