terminal_view.rs

  1use std::{ops::RangeInclusive, time::Duration};
  2
  3use alacritty_terminal::{index::Point, term::TermMode};
  4use context_menu::{ContextMenu, ContextMenuItem};
  5use gpui::{
  6    actions,
  7    elements::{AnchorCorner, ChildView, ParentElement, Stack},
  8    geometry::vector::Vector2F,
  9    impl_actions, impl_internal_actions,
 10    keymap::Keystroke,
 11    AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
 12    View, ViewContext, ViewHandle,
 13};
 14use serde::Deserialize;
 15use settings::{Settings, TerminalBlink};
 16use smol::Timer;
 17use util::ResultExt;
 18use workspace::pane;
 19
 20use crate::{terminal_element::TerminalElement, Event, Terminal};
 21
 22const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
 23
 24///Event to transmit the scroll from the element to the view
 25#[derive(Clone, Debug, PartialEq)]
 26pub struct ScrollTerminal(pub i32);
 27
 28#[derive(Clone, PartialEq)]
 29pub struct DeployContextMenu {
 30    pub position: Vector2F,
 31}
 32
 33#[derive(Clone, Default, Deserialize, PartialEq)]
 34pub struct SendText(String);
 35
 36#[derive(Clone, Default, Deserialize, PartialEq)]
 37pub struct SendKeystroke(String);
 38
 39actions!(
 40    terminal,
 41    [
 42        Up,
 43        Down,
 44        CtrlC,
 45        Escape,
 46        Enter,
 47        Clear,
 48        Copy,
 49        Paste,
 50        ShowCharacterPalette,
 51        SearchTest
 52    ]
 53);
 54
 55impl_actions!(terminal, [SendText, SendKeystroke]);
 56
 57impl_internal_actions!(project_panel, [DeployContextMenu]);
 58
 59pub fn init(cx: &mut MutableAppContext) {
 60    //Useful terminal views
 61    cx.add_action(TerminalView::send_text);
 62    cx.add_action(TerminalView::send_keystroke);
 63    cx.add_action(TerminalView::deploy_context_menu);
 64    cx.add_action(TerminalView::copy);
 65    cx.add_action(TerminalView::paste);
 66    cx.add_action(TerminalView::clear);
 67    cx.add_action(TerminalView::show_character_palette);
 68}
 69
 70///A terminal view, maintains the PTY's file handles and communicates with the terminal
 71pub struct TerminalView {
 72    terminal: ModelHandle<Terminal>,
 73    has_new_content: bool,
 74    //Currently using iTerm bell, show bell emoji in tab until input is received
 75    has_bell: bool,
 76    // Only for styling purposes. Doesn't effect behavior
 77    modal: bool,
 78    context_menu: ViewHandle<ContextMenu>,
 79    blink_state: bool,
 80    blinking_on: bool,
 81    blinking_paused: bool,
 82    blink_epoch: usize,
 83}
 84
 85impl Entity for TerminalView {
 86    type Event = Event;
 87}
 88
 89impl TerminalView {
 90    pub fn from_terminal(
 91        terminal: ModelHandle<Terminal>,
 92        modal: bool,
 93        cx: &mut ViewContext<Self>,
 94    ) -> Self {
 95        cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
 96        cx.subscribe(&terminal, |this, _, event, cx| match event {
 97            Event::Wakeup => {
 98                if !cx.is_self_focused() {
 99                    this.has_new_content = true;
100                    cx.notify();
101                }
102                cx.emit(Event::Wakeup);
103            }
104            Event::Bell => {
105                this.has_bell = true;
106                cx.emit(Event::Wakeup);
107            }
108            Event::BlinkChanged => this.blinking_on = !this.blinking_on,
109            _ => cx.emit(*event),
110        })
111        .detach();
112
113        Self {
114            terminal,
115            has_new_content: true,
116            has_bell: false,
117            modal,
118            context_menu: cx.add_view(ContextMenu::new),
119            blink_state: true,
120            blinking_on: false,
121            blinking_paused: false,
122            blink_epoch: 0,
123        }
124    }
125
126    pub fn handle(&self) -> ModelHandle<Terminal> {
127        self.terminal.clone()
128    }
129
130    pub fn has_new_content(&self) -> bool {
131        self.has_new_content
132    }
133
134    pub fn has_bell(&self) -> bool {
135        self.has_bell
136    }
137
138    pub fn clear_bel(&mut self, cx: &mut ViewContext<TerminalView>) {
139        self.has_bell = false;
140        cx.emit(Event::Wakeup);
141    }
142
143    pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
144        let menu_entries = vec![
145            ContextMenuItem::item("Clear Buffer", Clear),
146            ContextMenuItem::item("Close Terminal", pane::CloseActiveItem),
147        ];
148
149        self.context_menu.update(cx, |menu, cx| {
150            menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx)
151        });
152
153        cx.notify();
154    }
155
156    fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
157        if !self
158            .terminal
159            .read(cx)
160            .last_content
161            .mode
162            .contains(TermMode::ALT_SCREEN)
163        {
164            cx.show_character_palette();
165        } else {
166            self.terminal.update(cx, |term, cx| {
167                term.try_keystroke(
168                    &Keystroke::parse("ctrl-cmd-space").unwrap(),
169                    cx.global::<Settings>()
170                        .terminal_overrides
171                        .option_as_meta
172                        .unwrap_or(false),
173                )
174            });
175        }
176    }
177
178    fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
179        self.terminal.update(cx, |term, _| term.clear());
180        cx.notify();
181    }
182
183    pub fn should_show_cursor(
184        &self,
185        focused: bool,
186        cx: &mut gpui::RenderContext<'_, Self>,
187    ) -> bool {
188        //Don't blink the cursor when not focused, blinking is disabled, or paused
189        if !focused
190            || !self.blinking_on
191            || self.blinking_paused
192            || self
193                .terminal
194                .read(cx)
195                .last_content
196                .mode
197                .contains(TermMode::ALT_SCREEN)
198        {
199            return true;
200        }
201
202        let setting = {
203            let settings = cx.global::<Settings>();
204            settings
205                .terminal_overrides
206                .blinking
207                .clone()
208                .unwrap_or(TerminalBlink::TerminalControlled)
209        };
210
211        match setting {
212            //If the user requested to never blink, don't blink it.
213            TerminalBlink::Off => true,
214            //If the terminal is controlling it, check terminal mode
215            TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state,
216        }
217    }
218
219    fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
220        if epoch == self.blink_epoch && !self.blinking_paused {
221            self.blink_state = !self.blink_state;
222            cx.notify();
223
224            let epoch = self.next_blink_epoch();
225            cx.spawn(|this, mut cx| {
226                let this = this.downgrade();
227                async move {
228                    Timer::after(CURSOR_BLINK_INTERVAL).await;
229                    if let Some(this) = this.upgrade(&cx) {
230                        this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
231                    }
232                }
233            })
234            .detach();
235        }
236    }
237
238    pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
239        self.blink_state = true;
240        cx.notify();
241
242        let epoch = self.next_blink_epoch();
243        cx.spawn(|this, mut cx| {
244            let this = this.downgrade();
245            async move {
246                Timer::after(CURSOR_BLINK_INTERVAL).await;
247                if let Some(this) = this.upgrade(&cx) {
248                    this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
249                }
250            }
251        })
252        .detach();
253    }
254
255    pub fn find_matches(
256        &mut self,
257        query: project::search::SearchQuery,
258        cx: &mut ViewContext<Self>,
259    ) -> Task<Vec<RangeInclusive<Point>>> {
260        self.terminal
261            .update(cx, |term, cx| term.find_matches(query, cx))
262    }
263
264    pub fn terminal(&self) -> &ModelHandle<Terminal> {
265        &self.terminal
266    }
267
268    fn next_blink_epoch(&mut self) -> usize {
269        self.blink_epoch += 1;
270        self.blink_epoch
271    }
272
273    fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
274        if epoch == self.blink_epoch {
275            self.blinking_paused = false;
276            self.blink_cursors(epoch, cx);
277        }
278    }
279
280    ///Attempt to paste the clipboard into the terminal
281    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
282        self.terminal.update(cx, |term, _| term.copy())
283    }
284
285    ///Attempt to paste the clipboard into the terminal
286    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
287        if let Some(item) = cx.read_from_clipboard() {
288            self.terminal
289                .update(cx, |terminal, _cx| terminal.paste(item.text()));
290        }
291    }
292
293    fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
294        self.clear_bel(cx);
295        self.terminal.update(cx, |term, _| {
296            term.input(text.0.to_string());
297        });
298    }
299
300    fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
301        if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
302            self.clear_bel(cx);
303            self.terminal.update(cx, |term, cx| {
304                term.try_keystroke(
305                    &keystroke,
306                    cx.global::<Settings>()
307                        .terminal_overrides
308                        .option_as_meta
309                        .unwrap_or(false),
310                );
311            });
312        }
313    }
314}
315
316impl View for TerminalView {
317    fn ui_name() -> &'static str {
318        "Terminal"
319    }
320
321    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
322        let terminal_handle = self.terminal.clone().downgrade();
323
324        let self_id = cx.view_id();
325        let focused = cx
326            .focused_view_id(cx.window_id())
327            .filter(|view_id| *view_id == self_id)
328            .is_some();
329
330        Stack::new()
331            .with_child(
332                TerminalElement::new(
333                    cx.handle(),
334                    terminal_handle,
335                    self.modal,
336                    focused,
337                    self.should_show_cursor(focused, cx),
338                )
339                .contained()
340                .boxed(),
341            )
342            .with_child(ChildView::new(&self.context_menu).boxed())
343            .boxed()
344    }
345
346    fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
347        self.has_new_content = false;
348        self.terminal.read(cx).focus_in();
349        self.blink_cursors(self.blink_epoch, cx);
350        cx.notify();
351    }
352
353    fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
354        self.terminal.update(cx, |terminal, _| {
355            terminal.focus_out();
356        });
357        cx.notify();
358    }
359
360    //IME stuff
361    fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
362        if self
363            .terminal
364            .read(cx)
365            .last_content
366            .mode
367            .contains(TermMode::ALT_SCREEN)
368        {
369            None
370        } else {
371            Some(0..0)
372        }
373    }
374
375    fn replace_text_in_range(
376        &mut self,
377        _: Option<std::ops::Range<usize>>,
378        text: &str,
379        cx: &mut ViewContext<Self>,
380    ) {
381        self.terminal.update(cx, |terminal, _| {
382            terminal.input(text.into());
383        });
384    }
385
386    fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context {
387        let mut context = Self::default_keymap_context();
388        if self.modal {
389            context.set.insert("ModalTerminal".into());
390        }
391        let mode = self.terminal.read(cx).last_content.mode;
392        context.map.insert(
393            "screen".to_string(),
394            (if mode.contains(TermMode::ALT_SCREEN) {
395                "alt"
396            } else {
397                "normal"
398            })
399            .to_string(),
400        );
401
402        if mode.contains(TermMode::APP_CURSOR) {
403            context.set.insert("DECCKM".to_string());
404        }
405        if mode.contains(TermMode::APP_KEYPAD) {
406            context.set.insert("DECPAM".to_string());
407        }
408        //Note the ! here
409        if !mode.contains(TermMode::APP_KEYPAD) {
410            context.set.insert("DECPNM".to_string());
411        }
412        if mode.contains(TermMode::SHOW_CURSOR) {
413            context.set.insert("DECTCEM".to_string());
414        }
415        if mode.contains(TermMode::LINE_WRAP) {
416            context.set.insert("DECAWM".to_string());
417        }
418        if mode.contains(TermMode::ORIGIN) {
419            context.set.insert("DECOM".to_string());
420        }
421        if mode.contains(TermMode::INSERT) {
422            context.set.insert("IRM".to_string());
423        }
424        //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
425        if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
426            context.set.insert("LNM".to_string());
427        }
428        if mode.contains(TermMode::FOCUS_IN_OUT) {
429            context.set.insert("report_focus".to_string());
430        }
431        if mode.contains(TermMode::ALTERNATE_SCROLL) {
432            context.set.insert("alternate_scroll".to_string());
433        }
434        if mode.contains(TermMode::BRACKETED_PASTE) {
435            context.set.insert("bracketed_paste".to_string());
436        }
437        if mode.intersects(TermMode::MOUSE_MODE) {
438            context.set.insert("any_mouse_reporting".to_string());
439        }
440        {
441            let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
442                "click"
443            } else if mode.contains(TermMode::MOUSE_DRAG) {
444                "drag"
445            } else if mode.contains(TermMode::MOUSE_MOTION) {
446                "motion"
447            } else {
448                "off"
449            };
450            context
451                .map
452                .insert("mouse_reporting".to_string(), mouse_reporting.to_string());
453        }
454        {
455            let format = if mode.contains(TermMode::SGR_MOUSE) {
456                "sgr"
457            } else if mode.contains(TermMode::UTF8_MOUSE) {
458                "utf8"
459            } else {
460                "normal"
461            };
462            context
463                .map
464                .insert("mouse_format".to_string(), format.to_string());
465        }
466        context
467    }
468}