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::{grid::Dimensions as _, term::Config, vte::ansi::Processor};
 19use gpui::{canvas, size, AnyElement, ClipboardItem, FontStyle, TextStyle, WhiteSpace};
 20use settings::Settings as _;
 21use std::mem;
 22use terminal::ZedListener;
 23use terminal_view::terminal_element::TerminalElement;
 24use theme::ThemeSettings;
 25use ui::{prelude::*, IntoElement};
 26
 27use crate::outputs::SupportsClipboard;
 28
 29/// The `TerminalOutput` struct handles the parsing and rendering of text input,
 30/// simulating a basic terminal environment within REPL output.
 31///
 32/// `TerminalOutput` is designed to handle various types of text-based output, including:
 33///
 34/// * stdout (standard output)
 35/// * stderr (standard error)
 36/// * text/plain content
 37/// * error tracebacks
 38///
 39/// It uses the Alacritty terminal emulator backend to process and render text,
 40/// supporting ANSI escape sequences for text formatting and colors.
 41///
 42pub struct TerminalOutput {
 43    /// ANSI escape sequence processor for parsing input text.
 44    parser: Processor,
 45    /// Alacritty terminal instance that manages the terminal state and content.
 46    handler: alacritty_terminal::Term<ZedListener>,
 47}
 48
 49const DEFAULT_NUM_LINES: usize = 32;
 50const DEFAULT_NUM_COLUMNS: usize = 128;
 51
 52/// Returns the default text style for the terminal output.
 53pub fn text_style(cx: &mut WindowContext) -> TextStyle {
 54    let settings = ThemeSettings::get_global(cx).clone();
 55
 56    let font_family = settings.buffer_font.family;
 57    let font_features = settings.buffer_font.features;
 58    let font_weight = settings.buffer_font.weight;
 59    let font_fallbacks = settings.buffer_font.fallbacks;
 60
 61    let theme = cx.theme();
 62
 63    let text_style = TextStyle {
 64        font_family,
 65        font_features,
 66        font_weight,
 67        font_fallbacks,
 68        font_size: theme::get_buffer_font_size(cx).into(),
 69        font_style: FontStyle::Normal,
 70        // todo
 71        line_height: cx.line_height().into(),
 72        background_color: Some(theme.colors().terminal_background),
 73        white_space: WhiteSpace::Normal,
 74        truncate: None,
 75        // These are going to be overridden per-cell
 76        underline: None,
 77        strikethrough: None,
 78        color: theme.colors().terminal_foreground,
 79    };
 80
 81    text_style
 82}
 83
 84/// Returns the default terminal size for the terminal output.
 85pub fn terminal_size(cx: &mut WindowContext) -> terminal::TerminalSize {
 86    let text_style = text_style(cx);
 87    let text_system = cx.text_system();
 88
 89    let line_height = cx.line_height();
 90
 91    let font_pixels = text_style.font_size.to_pixels(cx.rem_size());
 92    let font_id = text_system.resolve_font(&text_style.font());
 93
 94    let cell_width = text_system
 95        .advance(font_id, font_pixels, 'w')
 96        .unwrap()
 97        .width;
 98
 99    let num_lines = DEFAULT_NUM_LINES;
100    let columns = DEFAULT_NUM_COLUMNS;
101
102    // Reversed math from terminal::TerminalSize to get pixel width according to terminal width
103    let width = columns as f32 * cell_width;
104    let height = num_lines as f32 * cx.line_height();
105
106    terminal::TerminalSize {
107        cell_width,
108        line_height,
109        size: size(width, height),
110    }
111}
112
113impl TerminalOutput {
114    /// Creates a new `TerminalOutput` instance.
115    ///
116    /// This method initializes a new terminal emulator with default configuration
117    /// and sets up the necessary components for handling terminal events and rendering.
118    ///
119    pub fn new(cx: &mut WindowContext) -> Self {
120        let (events_tx, events_rx) = futures::channel::mpsc::unbounded();
121        let term = alacritty_terminal::Term::new(
122            Config::default(),
123            &terminal_size(cx),
124            terminal::ZedListener(events_tx.clone()),
125        );
126
127        mem::forget(events_rx);
128        Self {
129            parser: Processor::new(),
130            handler: term,
131        }
132    }
133
134    /// Creates a new `TerminalOutput` instance with initial content.
135    ///
136    /// Initializes a new terminal output and populates it with the provided text.
137    ///
138    /// # Arguments
139    ///
140    /// * `text` - A string slice containing the initial text for the terminal output.
141    /// * `cx` - A mutable reference to the `WindowContext` for initialization.
142    ///
143    /// # Returns
144    ///
145    /// A new instance of `TerminalOutput` containing the provided text.
146    pub fn from(text: &str, cx: &mut WindowContext) -> Self {
147        let mut output = Self::new(cx);
148        output.append_text(text);
149        output
150    }
151
152    /// Appends text to the terminal output.
153    ///
154    /// Processes each byte of the input text, handling newline characters specially
155    /// to ensure proper cursor movement. Uses the ANSI parser to process the input
156    /// and update the terminal state.
157    ///
158    /// As an example, if the user runs the following Python code in this REPL:
159    ///
160    /// ```python
161    /// import time
162    /// print("Hello,", end="")
163    /// time.sleep(1)
164    /// print(" world!")
165    /// ```
166    ///
167    /// Then append_text will be called twice, with the following arguments:
168    ///
169    /// ```rust
170    /// terminal_output.append_text("Hello,")
171    /// terminal_output.append_text(" world!")
172    /// ```
173    /// Resulting in a single output of "Hello, world!".
174    ///
175    /// # Arguments
176    ///
177    /// * `text` - A string slice containing the text to be appended.
178    pub fn append_text(&mut self, text: &str) {
179        for byte in text.as_bytes() {
180            if *byte == b'\n' {
181                // Dirty (?) hack to move the cursor down
182                self.parser.advance(&mut self.handler, b'\r');
183                self.parser.advance(&mut self.handler, b'\n');
184            } else {
185                self.parser.advance(&mut self.handler, *byte);
186            }
187
188            // self.parser.advance(&mut self.handler, *byte);
189        }
190    }
191
192    /// Renders the terminal output as a GPUI element.
193    ///
194    /// Converts the current terminal state into a renderable GPUI element. It handles
195    /// the layout of the terminal grid, calculates the dimensions of the output, and
196    /// creates a canvas element that paints the terminal cells and background rectangles.
197    pub fn render(&self, cx: &mut WindowContext) -> AnyElement {
198        let text_style = text_style(cx);
199        let text_system = cx.text_system();
200
201        let grid = self
202            .handler
203            .renderable_content()
204            .display_iter
205            .map(|ic| terminal::IndexedCell {
206                point: ic.point,
207                cell: ic.cell.clone(),
208            });
209        let (cells, rects) = TerminalElement::layout_grid(grid, &text_style, text_system, None, cx);
210
211        // lines are 0-indexed, so we must add 1 to get the number of lines
212        let text_line_height = text_style.line_height_in_pixels(cx.rem_size());
213        let num_lines = cells.iter().map(|c| c.point.line).max().unwrap_or(0) + 1;
214        let height = num_lines as f32 * text_line_height;
215
216        let font_pixels = text_style.font_size.to_pixels(cx.rem_size());
217        let font_id = text_system.resolve_font(&text_style.font());
218
219        let cell_width = text_system
220            .advance(font_id, font_pixels, 'w')
221            .map(|advance| advance.width)
222            .unwrap_or(Pixels(0.0));
223
224        canvas(
225            // prepaint
226            move |_bounds, _| {},
227            // paint
228            move |bounds, _, cx| {
229                for rect in rects {
230                    rect.paint(
231                        bounds.origin,
232                        &terminal::TerminalSize {
233                            cell_width,
234                            line_height: text_line_height,
235                            size: bounds.size,
236                        },
237                        cx,
238                    );
239                }
240
241                for cell in cells {
242                    cell.paint(
243                        bounds.origin,
244                        &terminal::TerminalSize {
245                            cell_width,
246                            line_height: text_line_height,
247                            size: bounds.size,
248                        },
249                        bounds,
250                        cx,
251                    );
252                }
253            },
254        )
255        // We must set the height explicitly for the editor block to size itself correctly
256        .h(height)
257        .into_any_element()
258    }
259}
260
261impl SupportsClipboard for TerminalOutput {
262    fn clipboard_content(&self, _cx: &WindowContext) -> Option<ClipboardItem> {
263        let start = alacritty_terminal::index::Point::new(
264            alacritty_terminal::index::Line(0),
265            alacritty_terminal::index::Column(0),
266        );
267        let end = alacritty_terminal::index::Point::new(
268            alacritty_terminal::index::Line(self.handler.screen_lines() as i32 - 1),
269            alacritty_terminal::index::Column(self.handler.columns() - 1),
270        );
271        let text = self.handler.bounds_to_string(start, end);
272        Some(ClipboardItem::new_string(text.trim().into()))
273    }
274
275    fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
276        true
277    }
278}