connected_view.rs

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