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}