terminal.rs

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