1use alacritty_terminal::{
2 config::{Config, Program, PtyConfig},
3 event::{Event as AlacTermEvent, EventListener, Notify},
4 event_loop::{EventLoop, Msg, Notifier},
5 grid::Scroll,
6 sync::FairMutex,
7 term::{color::Rgb as AlacRgb, SizeInfo},
8 tty, Term,
9};
10
11use futures::{
12 channel::mpsc::{unbounded, UnboundedSender},
13 StreamExt,
14};
15use gpui::{
16 actions, color::Color, elements::*, impl_internal_actions, platform::CursorStyle,
17 ClipboardItem, Entity, MutableAppContext, View, ViewContext,
18};
19use project::{Project, ProjectPath};
20use settings::Settings;
21use smallvec::SmallVec;
22use std::{path::PathBuf, sync::Arc};
23use workspace::{Item, Workspace};
24
25use crate::terminal_element::{get_color_at_index, TerminalEl};
26
27//ASCII Control characters on a keyboard
28const ETX_CHAR: char = 3_u8 as char; //'End of text', the control code for 'ctrl-c'
29const TAB_CHAR: char = 9_u8 as char;
30const CARRIAGE_RETURN_CHAR: char = 13_u8 as char;
31const ESC_CHAR: char = 27_u8 as char;
32const DEL_CHAR: char = 127_u8 as char;
33const LEFT_SEQ: &str = "\x1b[D";
34const RIGHT_SEQ: &str = "\x1b[C";
35const UP_SEQ: &str = "\x1b[A";
36const DOWN_SEQ: &str = "\x1b[B";
37const DEFAULT_TITLE: &str = "Terminal";
38
39pub mod terminal_element;
40
41///Action for carrying the input to the PTY
42#[derive(Clone, Default, Debug, PartialEq, Eq)]
43pub struct Input(pub String);
44
45///Event to transmit the scroll from the element to the view
46#[derive(Clone, Debug, PartialEq)]
47pub struct ScrollTerminal(pub i32);
48
49actions!(
50 terminal,
51 [Sigint, Escape, Del, Return, Left, Right, Up, Down, Tab, Clear, Paste, Deploy, Quit]
52);
53impl_internal_actions!(terminal, [Input, ScrollTerminal]);
54
55///Initialize and register all of our action handlers
56pub fn init(cx: &mut MutableAppContext) {
57 cx.add_action(Terminal::deploy);
58 cx.add_action(Terminal::write_to_pty);
59 cx.add_action(Terminal::send_sigint);
60 cx.add_action(Terminal::escape);
61 cx.add_action(Terminal::quit);
62 cx.add_action(Terminal::del);
63 cx.add_action(Terminal::carriage_return); //TODO figure out how to do this properly. Should we be checking the terminal mode?
64 cx.add_action(Terminal::left);
65 cx.add_action(Terminal::right);
66 cx.add_action(Terminal::up);
67 cx.add_action(Terminal::down);
68 cx.add_action(Terminal::tab);
69 cx.add_action(Terminal::paste);
70 cx.add_action(Terminal::scroll_terminal);
71}
72
73///A translation struct for Alacritty to communicate with us from their event loop
74#[derive(Clone)]
75pub struct ZedListener(UnboundedSender<AlacTermEvent>);
76
77impl EventListener for ZedListener {
78 fn send_event(&self, event: AlacTermEvent) {
79 self.0.unbounded_send(event).ok();
80 }
81}
82
83///A terminal view, maintains the PTY's file handles and communicates with the terminal
84pub struct Terminal {
85 pty_tx: Notifier,
86 term: Arc<FairMutex<Term<ZedListener>>>,
87 title: String,
88 has_new_content: bool,
89 has_bell: bool, //Currently using iTerm bell, show bell emoji in tab until input is received
90 cur_size: SizeInfo,
91}
92
93///Upward flowing events, for changing the title and such
94pub enum Event {
95 TitleChanged,
96 CloseTerminal,
97 Activate,
98}
99
100impl Entity for Terminal {
101 type Event = Event;
102}
103
104impl Terminal {
105 ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
106 fn new(cx: &mut ViewContext<Self>, working_directory: Option<PathBuf>) -> Self {
107 //Spawn a task so the Alacritty EventLoop can communicate with us in a view context
108 let (events_tx, mut events_rx) = unbounded();
109 cx.spawn_weak(|this, mut cx| async move {
110 while let Some(event) = events_rx.next().await {
111 match this.upgrade(&cx) {
112 Some(handle) => {
113 handle.update(&mut cx, |this, cx| {
114 this.process_terminal_event(event, cx);
115 cx.notify();
116 });
117 }
118 None => break,
119 }
120 }
121 })
122 .detach();
123
124 let pty_config = PtyConfig {
125 shell: Some(Program::Just("zsh".to_string())),
126 working_directory,
127 hold: false,
128 };
129
130 let config = Config {
131 pty_config: pty_config.clone(),
132 ..Default::default()
133 };
134
135 //The details here don't matter, the terminal will be resized on the first layout
136 //Set to something small for easier debugging
137 let size_info = SizeInfo::new(200., 100.0, 5., 5., 0., 0., false);
138
139 //Set up the terminal...
140 let term = Term::new(&config, size_info, ZedListener(events_tx.clone()));
141 let term = Arc::new(FairMutex::new(term));
142
143 //Setup the pty...
144 let pty = tty::new(&pty_config, &size_info, None).expect("Could not create tty");
145
146 //And connect them together
147 let event_loop = EventLoop::new(
148 term.clone(),
149 ZedListener(events_tx.clone()),
150 pty,
151 pty_config.hold,
152 false,
153 );
154
155 //Kick things off
156 let pty_tx = Notifier(event_loop.channel());
157 let _io_thread = event_loop.spawn();
158 Terminal {
159 title: DEFAULT_TITLE.to_string(),
160 term,
161 pty_tx,
162 has_new_content: false,
163 has_bell: false,
164 cur_size: size_info,
165 }
166 }
167
168 ///Takes events from Alacritty and translates them to behavior on this view
169 fn process_terminal_event(
170 &mut self,
171 event: alacritty_terminal::event::Event,
172 cx: &mut ViewContext<Self>,
173 ) {
174 match event {
175 AlacTermEvent::Wakeup => {
176 if !cx.is_self_focused() {
177 self.has_new_content = true; //Change tab content
178 cx.emit(Event::TitleChanged);
179 } else {
180 cx.notify()
181 }
182 }
183 AlacTermEvent::PtyWrite(out) => self.write_to_pty(&Input(out), cx),
184 AlacTermEvent::MouseCursorDirty => {
185 //Calculate new cursor style.
186 //TODO
187 //Check on correctly handling mouse events for terminals
188 cx.platform().set_cursor_style(CursorStyle::Arrow); //???
189 }
190 AlacTermEvent::Title(title) => {
191 self.title = title;
192 cx.emit(Event::TitleChanged);
193 }
194 AlacTermEvent::ResetTitle => {
195 self.title = DEFAULT_TITLE.to_string();
196 cx.emit(Event::TitleChanged);
197 }
198 AlacTermEvent::ClipboardStore(_, data) => {
199 cx.write_to_clipboard(ClipboardItem::new(data))
200 }
201 AlacTermEvent::ClipboardLoad(_, format) => self.write_to_pty(
202 &Input(format(
203 &cx.read_from_clipboard()
204 .map(|ci| ci.text().to_string())
205 .unwrap_or("".to_string()),
206 )),
207 cx,
208 ),
209 AlacTermEvent::ColorRequest(index, format) => {
210 let color = self.term.lock().colors()[index].unwrap_or_else(|| {
211 let term_style = &cx.global::<Settings>().theme.terminal;
212 match index {
213 0..=255 => to_alac_rgb(get_color_at_index(&(index as u8), term_style)),
214 //These additional values are required to match the Alacritty Colors object's behavior
215 256 => to_alac_rgb(term_style.foreground),
216 257 => to_alac_rgb(term_style.background),
217 258 => to_alac_rgb(term_style.cursor),
218 259 => to_alac_rgb(term_style.dim_black),
219 260 => to_alac_rgb(term_style.dim_red),
220 261 => to_alac_rgb(term_style.dim_green),
221 262 => to_alac_rgb(term_style.dim_yellow),
222 263 => to_alac_rgb(term_style.dim_blue),
223 264 => to_alac_rgb(term_style.dim_magenta),
224 265 => to_alac_rgb(term_style.dim_cyan),
225 266 => to_alac_rgb(term_style.dim_white),
226 267 => to_alac_rgb(term_style.bright_foreground),
227 268 => to_alac_rgb(term_style.black), //Dim Background, non-standard
228 _ => AlacRgb { r: 0, g: 0, b: 0 },
229 }
230 });
231 self.write_to_pty(&Input(format(color)), cx)
232 }
233 AlacTermEvent::CursorBlinkingChange => {
234 //TODO: Set a timer to blink the cursor on and off
235 }
236 AlacTermEvent::Bell => {
237 self.has_bell = true;
238 cx.emit(Event::TitleChanged);
239 }
240 AlacTermEvent::Exit => self.quit(&Quit, cx),
241 }
242 }
243
244 ///Resize the terminal and the PTY. This locks the terminal.
245 fn set_size(&mut self, new_size: SizeInfo) {
246 if new_size != self.cur_size {
247 self.pty_tx.0.send(Msg::Resize(new_size)).ok();
248 self.term.lock().resize(new_size);
249 self.cur_size = new_size;
250 }
251 }
252
253 ///Scroll the terminal. This locks the terminal
254 fn scroll_terminal(&mut self, scroll: &ScrollTerminal, _: &mut ViewContext<Self>) {
255 self.term.lock().scroll_display(Scroll::Delta(scroll.0));
256 }
257
258 ///Create a new Terminal in the current working directory or the user's home directory
259 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
260 let project = workspace.project().read(cx);
261 let abs_path = project
262 .active_entry()
263 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
264 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
265 .map(|wt| wt.abs_path().to_path_buf());
266
267 workspace.add_item(Box::new(cx.add_view(|cx| Terminal::new(cx, abs_path))), cx);
268 }
269
270 ///Send the shutdown message to Alacritty
271 fn shutdown_pty(&mut self) {
272 self.pty_tx.0.send(Msg::Shutdown).ok();
273 }
274
275 ///Tell Zed to close us
276 fn quit(&mut self, _: &Quit, cx: &mut ViewContext<Self>) {
277 cx.emit(Event::CloseTerminal);
278 }
279
280 ///Attempt to paste the clipboard into the terminal
281 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
282 if let Some(item) = cx.read_from_clipboard() {
283 self.write_to_pty(&Input(item.text().to_owned()), cx);
284 }
285 }
286
287 ///Write the Input payload to the tty. This locks the terminal so we can scroll it.
288 fn write_to_pty(&mut self, input: &Input, cx: &mut ViewContext<Self>) {
289 //iTerm bell behavior, bell stays until terminal is interacted with
290 self.has_bell = false;
291 self.term.lock().scroll_display(Scroll::Bottom);
292 cx.emit(Event::TitleChanged);
293 self.pty_tx.notify(input.0.clone().into_bytes());
294 }
295
296 ///Send the `up` key
297 fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
298 self.write_to_pty(&Input(UP_SEQ.to_string()), cx);
299 }
300
301 ///Send the `down` key
302 fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
303 self.write_to_pty(&Input(DOWN_SEQ.to_string()), cx);
304 }
305
306 ///Send the `tab` key
307 fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
308 self.write_to_pty(&Input(TAB_CHAR.to_string()), cx);
309 }
310
311 ///Send `SIGINT` (`ctrl-c`)
312 fn send_sigint(&mut self, _: &Sigint, cx: &mut ViewContext<Self>) {
313 self.write_to_pty(&Input(ETX_CHAR.to_string()), cx);
314 }
315
316 ///Send the `escape` key
317 fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
318 self.write_to_pty(&Input(ESC_CHAR.to_string()), cx);
319 }
320
321 ///Send the `delete` key. TODO: Difference between this and backspace?
322 fn del(&mut self, _: &Del, cx: &mut ViewContext<Self>) {
323 self.write_to_pty(&Input(DEL_CHAR.to_string()), cx);
324 }
325
326 ///Send a carriage return. TODO: May need to check the terminal mode.
327 fn carriage_return(&mut self, _: &Return, cx: &mut ViewContext<Self>) {
328 self.write_to_pty(&Input(CARRIAGE_RETURN_CHAR.to_string()), cx);
329 }
330
331 //Send the `left` key
332 fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
333 self.write_to_pty(&Input(LEFT_SEQ.to_string()), cx);
334 }
335
336 //Send the `right` key
337 fn right(&mut self, _: &Right, cx: &mut ViewContext<Self>) {
338 self.write_to_pty(&Input(RIGHT_SEQ.to_string()), cx);
339 }
340}
341
342impl Drop for Terminal {
343 fn drop(&mut self) {
344 self.shutdown_pty();
345 }
346}
347
348impl View for Terminal {
349 fn ui_name() -> &'static str {
350 "Terminal"
351 }
352
353 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
354 TerminalEl::new(cx.handle()).contained().boxed()
355 }
356
357 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
358 cx.emit(Event::Activate);
359 self.has_new_content = false;
360 }
361}
362
363impl Item for Terminal {
364 fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
365 let settings = cx.global::<Settings>();
366 let search_theme = &settings.theme.search; //TODO properly integrate themes
367
368 let mut flex = Flex::row();
369
370 if self.has_bell {
371 flex.add_child(
372 Svg::new("icons/zap.svg") //TODO: Swap out for a better icon, or at least resize this
373 .with_color(tab_theme.label.text.color)
374 .constrained()
375 .with_width(search_theme.tab_icon_width)
376 .aligned()
377 .boxed(),
378 );
379 };
380
381 flex.with_child(
382 Label::new(self.title.clone(), tab_theme.label.clone())
383 .aligned()
384 .contained()
385 .with_margin_left(if self.has_bell {
386 search_theme.tab_icon_spacing
387 } else {
388 0.
389 })
390 .boxed(),
391 )
392 .boxed()
393 }
394
395 fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
396 None
397 }
398
399 fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
400 SmallVec::new()
401 }
402
403 fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
404 false
405 }
406
407 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
408
409 fn can_save(&self, _cx: &gpui::AppContext) -> bool {
410 false
411 }
412
413 fn save(
414 &mut self,
415 _project: gpui::ModelHandle<Project>,
416 _cx: &mut ViewContext<Self>,
417 ) -> gpui::Task<gpui::anyhow::Result<()>> {
418 unreachable!("save should not have been called");
419 }
420
421 fn save_as(
422 &mut self,
423 _project: gpui::ModelHandle<Project>,
424 _abs_path: std::path::PathBuf,
425 _cx: &mut ViewContext<Self>,
426 ) -> gpui::Task<gpui::anyhow::Result<()>> {
427 unreachable!("save_as should not have been called");
428 }
429
430 fn reload(
431 &mut self,
432 _project: gpui::ModelHandle<Project>,
433 _cx: &mut ViewContext<Self>,
434 ) -> gpui::Task<gpui::anyhow::Result<()>> {
435 gpui::Task::ready(Ok(()))
436 }
437
438 fn is_dirty(&self, _: &gpui::AppContext) -> bool {
439 self.has_new_content
440 }
441
442 fn should_update_tab_on_event(event: &Self::Event) -> bool {
443 matches!(event, &Event::TitleChanged)
444 }
445
446 fn should_close_item_on_event(event: &Self::Event) -> bool {
447 matches!(event, &Event::CloseTerminal)
448 }
449
450 fn should_activate_item_on_event(event: &Self::Event) -> bool {
451 matches!(event, &Event::Activate)
452 }
453}
454
455//Convenience method for less lines
456fn to_alac_rgb(color: Color) -> AlacRgb {
457 AlacRgb {
458 r: color.r,
459 g: color.g,
460 b: color.g,
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::terminal_element::{build_chunks, BuiltChunks};
468 use gpui::TestAppContext;
469
470 ///Basic integration test, can we get the terminal to show up, execute a command,
471 //and produce noticable output?
472 #[gpui::test]
473 async fn test_terminal(cx: &mut TestAppContext) {
474 let terminal = cx.add_view(Default::default(), |cx| Terminal::new(cx, None));
475
476 terminal.update(cx, |terminal, cx| {
477 terminal.write_to_pty(&Input(("expr 3 + 4".to_string()).to_string()), cx);
478 terminal.carriage_return(&Return, cx);
479 });
480
481 terminal
482 .condition(cx, |terminal, _cx| {
483 let term = terminal.term.clone();
484 let BuiltChunks { chunks, .. } = build_chunks(
485 term.lock().renderable_content().display_iter,
486 &Default::default(),
487 Default::default(),
488 );
489 let content = chunks.iter().map(|e| e.0.trim()).collect::<String>();
490 content.contains("7")
491 })
492 .await;
493 }
494}