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