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