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