1use std::cell::Cell;
2use std::ops::Range;
3use std::rc::Rc;
4
5use gpui::{
6 AnyElement, Bounds, Context, FocusHandle, Focusable, Font, FontFallbacks, HighlightStyle,
7 KeyDownEvent, Keystroke, Render, StyledText, canvas, rgb,
8};
9
10use crate::prelude::*;
11
12const TERMINAL_FONT_FAMILY: &str = "IBM Plex Mono";
16const TERMINAL_FONT_SIZE: Pixels = px(12.5);
17const TERMINAL_LINE_HEIGHT: f32 = 1.5;
18
19fn terminal_font() -> Font {
33 Font {
34 family: TERMINAL_FONT_FAMILY.into(),
35 fallbacks: Some(FontFallbacks::from_fonts(vec![
36 "Symbols Nerd Font Mono".into(),
37 "Symbols Nerd Font".into(),
38 "JetBrainsMono Nerd Font Mono".into(),
39 "JetBrainsMono Nerd Font".into(),
40 "Hack Nerd Font Mono".into(),
41 "Hack Nerd Font".into(),
42 "MesloLGS NF".into(),
43 "FiraCode Nerd Font Mono".into(),
44 "FiraCode Nerd Font".into(),
45 ])),
46 ..Default::default()
47 }
48}
49const CELL_WIDTH: u16 = 8;
57const CELL_HEIGHT: u16 = 18;
58const DEFAULT_ROWS: u16 = 24;
59const DEFAULT_COLUMNS: u16 = 80;
60
61fn encode_keystroke(keystroke: &Keystroke) -> Option<Vec<u8>> {
72 if keystroke.modifiers.platform {
73 return None;
74 }
75 if keystroke.modifiers.control
76 && let Some(ch) = keystroke.key.chars().next()
77 && ch.is_ascii_alphabetic()
78 {
79 let code = ch.to_ascii_uppercase() as u8 - b'A' + 1;
80 return Some(vec![code]);
81 }
82 match keystroke.key.as_str() {
83 "enter" => Some(b"\r".to_vec()),
84 "backspace" => Some(b"\x7f".to_vec()),
85 "tab" => Some(b"\t".to_vec()),
86 "escape" => Some(b"\x1b".to_vec()),
87 "up" => Some(b"\x1b[A".to_vec()),
88 "down" => Some(b"\x1b[B".to_vec()),
89 "right" => Some(b"\x1b[C".to_vec()),
90 "left" => Some(b"\x1b[D".to_vec()),
91 "home" => Some(b"\x1b[H".to_vec()),
92 "end" => Some(b"\x1b[F".to_vec()),
93 "pageup" => Some(b"\x1b[5~".to_vec()),
94 "pagedown" => Some(b"\x1b[6~".to_vec()),
95 "delete" => Some(b"\x1b[3~".to_vec()),
96 "insert" => Some(b"\x1b[2~".to_vec()),
97 "f1" => Some(b"\x1bOP".to_vec()),
98 "f2" => Some(b"\x1bOQ".to_vec()),
99 "f3" => Some(b"\x1bOR".to_vec()),
100 "f4" => Some(b"\x1bOS".to_vec()),
101 "f5" => Some(b"\x1b[15~".to_vec()),
102 "f6" => Some(b"\x1b[17~".to_vec()),
103 "f7" => Some(b"\x1b[18~".to_vec()),
104 "f8" => Some(b"\x1b[19~".to_vec()),
105 "f9" => Some(b"\x1b[20~".to_vec()),
106 "f10" => Some(b"\x1b[21~".to_vec()),
107 "f11" => Some(b"\x1b[23~".to_vec()),
108 "f12" => Some(b"\x1b[24~".to_vec()),
109 "space" => Some(b" ".to_vec()),
110 _ => keystroke
111 .key_char
112 .as_ref()
113 .map(|text| text.as_bytes().to_vec()),
114 }
115}
116
117fn default_terminal_size() -> terminal::TerminalSize {
118 terminal::TerminalSize {
119 rows: DEFAULT_ROWS,
120 columns: DEFAULT_COLUMNS,
121 cell_width: CELL_WIDTH,
122 cell_height: CELL_HEIGHT,
123 }
124}
125
126fn size_for_bounds(bounds: Bounds<Pixels>) -> terminal::TerminalSize {
131 let columns = (f32::from(bounds.size.width) / CELL_WIDTH as f32).floor() as u16;
132 let rows = (f32::from(bounds.size.height) / CELL_HEIGHT as f32).floor() as u16;
133 terminal::TerminalSize {
134 rows: rows.max(1),
135 columns: columns.max(1),
136 cell_width: CELL_WIDTH,
137 cell_height: CELL_HEIGHT,
138 }
139}
140
141fn rgb_to_hsla(color: terminal::Rgb) -> gpui::Hsla {
142 gpui::rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32).into()
143}
144
145fn styled_screen_text(
152 rows: Vec<Vec<terminal::TerminalCell>>,
153) -> (String, Vec<(Range<usize>, HighlightStyle)>) {
154 let mut text = String::new();
155 let mut highlights = Vec::new();
156
157 for (row_ix, row) in rows.into_iter().enumerate() {
158 if row_ix > 0 {
159 text.push('\n');
160 }
161
162 let mut span_start = text.len();
163 let mut current_style: Option<(Option<terminal::Rgb>, bool, bool, bool)> = None;
164
165 for cell in row {
166 let style_key = (cell.fg, cell.bold, cell.italic, cell.underline);
167 if current_style != Some(style_key) {
168 if let Some((fg, bold, italic, underline)) = current_style
169 && text.len() > span_start
170 {
171 highlights.push((
172 span_start..text.len(),
173 cell_highlight_style(fg, bold, italic, underline),
174 ));
175 }
176 span_start = text.len();
177 current_style = Some(style_key);
178 }
179 text.push(cell.text);
180 }
181
182 if let Some((fg, bold, italic, underline)) = current_style
183 && text.len() > span_start
184 {
185 highlights.push((
186 span_start..text.len(),
187 cell_highlight_style(fg, bold, italic, underline),
188 ));
189 }
190 }
191
192 (text, highlights)
193}
194
195fn cell_highlight_style(
196 fg: Option<terminal::Rgb>,
197 bold: bool,
198 italic: bool,
199 underline: bool,
200) -> HighlightStyle {
201 HighlightStyle {
202 color: fg.map(rgb_to_hsla),
203 font_weight: bold.then_some(gpui::FontWeight::BOLD),
204 font_style: italic.then_some(gpui::FontStyle::Italic),
205 underline: underline.then_some(gpui::UnderlineStyle::default()),
206 ..Default::default()
207 }
208}
209
210pub struct TerminalView {
226 terminal: Option<terminal::Terminal>,
227 spawn_error: Option<String>,
228 focus_handle: FocusHandle,
229 container_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
234}
235
236impl TerminalView {
237 pub fn new(cx: &mut Context<Self>) -> Self {
238 let focus_handle = cx.focus_handle();
239 match terminal::Terminal::spawn(default_terminal_size()) {
240 Ok((terminal, events)) => {
241 cx.spawn(async move |this, cx| {
242 while events.recv().await.is_ok() {
243 if this.update(cx, |_, cx| cx.notify()).is_err() {
244 break;
245 }
246 }
247 })
248 .detach();
249 Self {
250 terminal: Some(terminal),
251 spawn_error: None,
252 focus_handle,
253 container_bounds: Rc::new(Cell::new(None)),
254 }
255 }
256 Err(error) => Self {
257 terminal: None,
258 spawn_error: Some(error.to_string()),
259 focus_handle,
260 container_bounds: Rc::new(Cell::new(None)),
261 },
262 }
263 }
264
265 pub fn focus(&self, window: &mut Window, cx: &mut App) {
270 window.focus(&self.focus_handle, cx);
271 }
272
273 fn handle_key_down(
274 &mut self,
275 event: &KeyDownEvent,
276 _window: &mut Window,
277 cx: &mut Context<Self>,
278 ) {
279 let Some(terminal) = &self.terminal else {
280 return;
281 };
282 if let Some(bytes) = encode_keystroke(&event.keystroke) {
283 terminal.write_input(bytes);
284 cx.notify();
285 }
286 }
287
288 fn sync_size_to_container(&self) {
294 let Some(terminal) = &self.terminal else {
295 return;
296 };
297 let Some(bounds) = self.container_bounds.get() else {
298 return;
299 };
300 let wanted = size_for_bounds(bounds);
301 let current = terminal.current_size();
302 if wanted.rows != current.rows || wanted.columns != current.columns {
303 terminal.resize(wanted);
304 }
305 }
306}
307
308impl Focusable for TerminalView {
309 fn focus_handle(&self, _cx: &App) -> FocusHandle {
310 self.focus_handle.clone()
311 }
312}
313
314impl Render for TerminalView {
315 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
316 self.sync_size_to_container();
317
318 let body: AnyElement = if let Some(error) = &self.spawn_error {
319 div()
320 .text_color(rgb(0xE06C75))
321 .child(format!("Failed to start terminal: {error}"))
322 .into_any_element()
323 } else {
324 let (text, highlights) = self
325 .terminal
326 .as_ref()
327 .map(|terminal| styled_screen_text(terminal.screen_cells()))
328 .unwrap_or_default();
329 StyledText::new(text)
330 .with_highlights(highlights)
331 .into_any_element()
332 };
333
334 let measure = self.container_bounds.clone();
335
336 div()
337 .id(("terminal-view", cx.entity_id()))
338 .track_focus(&self.focus_handle)
339 .on_key_down(cx.listener(Self::handle_key_down))
340 .relative()
341 .w_full()
342 .h_full()
343 .overflow_y_scroll()
344 .p_3()
345 .bg(rgb(0x0D1117))
346 .text_color(rgb(0xC9D1D9))
347 .font(terminal_font())
348 .text_size(TERMINAL_FONT_SIZE)
349 .line_height(relative(TERMINAL_LINE_HEIGHT))
350 .child(
351 canvas(
352 move |bounds, _, _| measure.set(Some(bounds)),
353 |_, _, _, _| {},
354 )
355 .absolute()
356 .size_full(),
357 )
358 .child(body)
359 }
360}
361
362pub fn terminal_view_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
368 div()
369 .h(px(280.))
370 .rounded_lg()
371 .overflow_hidden()
372 .child(cx.new(|cx| TerminalView::new(cx)))
373 .into_any_element()
374}