Merge pull request #1520 from zed-industries/terminal-blink

Mikayla Maki created

Terminal cursor blinking

Change summary

assets/settings/default.json          |  13 ++
crates/editor/src/element.rs          |  46 ++++++++++
crates/settings/src/settings.rs       |  16 +++
crates/terminal/src/connected_el.rs   | 126 +++++++++++++++++++---------
crates/terminal/src/connected_view.rs |  78 +++++++++++++++++
crates/terminal/src/terminal.rs       |  23 ++++-
crates/terminal/src/terminal_view.rs  |   9 +
7 files changed, 258 insertions(+), 53 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -102,6 +102,19 @@
         //
         //
         "working_directory": "current_project_directory",
+        //Set the cursor blinking behavior in the terminal.
+        //May take 4 values:
+        // 1. Never blink the cursor, ignoring the terminal mode
+        //        "blinking": "never",
+        // 2. Default the cursor blink to off, but allow the terminal to 
+        //    turn blinking on
+        //        "blinking": "off",
+        // 3. Default the cursor blink to on, but allow the terminal to 
+        //    turn blinking off
+        //        "blinking": "on",
+        // 4. Always blink the cursor, ignoring the terminal mode
+        //        "blinking": "always",
+        "blinking": "on",
         //Any key-value pairs added to this list will be added to the terminal's
         //enviroment. Use `:` to seperate multiple values.
         "env": {

crates/editor/src/element.rs 🔗

@@ -1753,6 +1753,7 @@ pub enum CursorShape {
     Bar,
     Block,
     Underscore,
+    Hollow,
 }
 
 impl Default for CursorShape {
@@ -1808,8 +1809,19 @@ impl Cursor {
                 self.origin + origin + Vector2F::new(0.0, self.line_height - 2.0),
                 vec2f(self.block_width, 2.0),
             ),
+            CursorShape::Hollow => RectF::new(
+                self.origin + origin + Vector2F::new(0.0, self.line_height - 1.0),
+                vec2f(self.block_width, 1.0),
+            ),
         };
 
+        //Draw text under the hollow block if need be
+        if matches!(self.shape, CursorShape::Hollow) {
+            if let Some(block_text) = &self.block_text {
+                block_text.paint(self.origin + origin, bounds, self.line_height, cx);
+            }
+        }
+
         cx.scene.push_quad(Quad {
             bounds,
             background: Some(self.color),
@@ -1817,8 +1829,38 @@ impl Cursor {
             corner_radius: 0.,
         });
 
-        if let Some(block_text) = &self.block_text {
-            block_text.paint(self.origin + origin, bounds, self.line_height, cx);
+        if matches!(self.shape, CursorShape::Hollow) {
+            //Top
+            cx.scene.push_quad(Quad {
+                bounds: RectF::new(
+                    self.origin + origin + Vector2F::new(0.0, -1.0),
+                    vec2f(self.block_width + 1., 1.0),
+                ),
+                background: Some(self.color),
+                border: Border::new(0., Color::black()),
+                corner_radius: 0.,
+            });
+            //Left
+            cx.scene.push_quad(Quad {
+                bounds: RectF::new(self.origin + origin, vec2f(1.0, self.line_height)),
+                background: Some(self.color),
+                border: Border::new(0., Color::black()),
+                corner_radius: 0.,
+            });
+            //Right
+            cx.scene.push_quad(Quad {
+                bounds: RectF::new(
+                    self.origin + origin + vec2f(self.block_width, 0.),
+                    vec2f(1.0, self.line_height),
+                ),
+                background: Some(self.color),
+                border: Border::new(0., Color::black()),
+                corner_radius: 0.,
+            });
+        } else {
+            if let Some(block_text) = &self.block_text {
+                block_text.paint(self.origin + origin, bounds, self.line_height, cx);
+            }
         }
     }
 }

crates/settings/src/settings.rs 🔗

@@ -83,6 +83,22 @@ pub struct TerminalSettings {
     pub font_size: Option<f32>,
     pub font_family: Option<String>,
     pub env: Option<HashMap<String, String>>,
+    pub blinking: Option<TerminalBlink>,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum TerminalBlink {
+    Never,
+    On,
+    Off,
+    Always,
+}
+
+impl Default for TerminalBlink {
+    fn default() -> Self {
+        TerminalBlink::On
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]

crates/terminal/src/connected_el.rs 🔗

@@ -21,7 +21,7 @@ use gpui::{
 };
 use itertools::Itertools;
 use ordered_float::OrderedFloat;
-use settings::Settings;
+use settings::{Settings, TerminalBlink};
 use theme::TerminalStyle;
 use util::ResultExt;
 
@@ -201,6 +201,7 @@ pub struct TerminalEl {
     view: WeakViewHandle<ConnectedView>,
     modal: bool,
     focused: bool,
+    blink_state: bool,
 }
 
 impl TerminalEl {
@@ -209,12 +210,14 @@ impl TerminalEl {
         terminal: WeakModelHandle<Terminal>,
         modal: bool,
         focused: bool,
+        blink_state: bool,
     ) -> TerminalEl {
         TerminalEl {
             view,
             terminal,
             modal,
             focused,
+            blink_state,
         }
     }
 
@@ -568,6 +571,33 @@ impl TerminalEl {
 
         (point, side)
     }
+
+    pub fn should_show_cursor(
+        settings: Option<TerminalBlink>,
+        blinking_on: bool,
+        focused: bool,
+        blink_show: bool,
+    ) -> bool {
+        if !focused {
+            true
+        } else {
+            match settings {
+                Some(setting) => match setting {
+                TerminalBlink::Never => true,
+                TerminalBlink::On | TerminalBlink::Off if blinking_on => blink_show,
+                TerminalBlink::On | TerminalBlink::Off /*if !blinking_on */ => true,
+                TerminalBlink::Always => focused && blink_show,
+            },
+                None => {
+                    if blinking_on {
+                        blink_show
+                    } else {
+                        false
+                    }
+                }
+            }
+        }
+    }
 }
 
 impl Element for TerminalEl {
@@ -580,6 +610,7 @@ impl Element for TerminalEl {
         cx: &mut gpui::LayoutContext,
     ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
         let settings = cx.global::<Settings>();
+        let blink_settings = settings.terminal_overrides.blinking.clone();
         let font_cache = cx.font_cache();
 
         //Setup layout information
@@ -598,13 +629,13 @@ impl Element for TerminalEl {
             terminal_theme.colors.background
         };
 
-        let (cells, selection, cursor, display_offset, cursor_text) = self
+        let (cells, selection, cursor, display_offset, cursor_text, blink_mode) = self
             .terminal
             .upgrade(cx)
             .unwrap()
             .update(cx.app, |terminal, mcx| {
                 terminal.set_size(dimensions);
-                terminal.render_lock(mcx, |content, cursor_text| {
+                terminal.render_lock(mcx, |content, cursor_text, blink_mode| {
                     let mut cells = vec![];
                     cells.extend(
                         content
@@ -628,6 +659,7 @@ impl Element for TerminalEl {
                         content.cursor,
                         content.display_offset,
                         cursor_text,
+                        blink_mode,
                     )
                 })
             });
@@ -644,48 +676,57 @@ impl Element for TerminalEl {
 
         //Layout cursor
         let cursor = {
-            let cursor_point = DisplayCursor::from(cursor.point, display_offset);
-            let cursor_text = {
-                let str_trxt = cursor_text.to_string();
-
-                let color = if self.focused {
-                    terminal_theme.colors.background
-                } else {
-                    terminal_theme.colors.foreground
-                };
-
-                cx.text_layout_cache.layout_str(
-                    &str_trxt,
-                    text_style.font_size,
-                    &[(
-                        str_trxt.len(),
-                        RunStyle {
-                            font_id: text_style.font_id,
-                            color,
-                            underline: Default::default(),
-                        },
-                    )],
-                )
-            };
+            if !TerminalEl::should_show_cursor(
+                blink_settings,
+                blink_mode,
+                self.focused,
+                self.blink_state,
+            ) {
+                None
+            } else {
+                let cursor_point = DisplayCursor::from(cursor.point, display_offset);
+                let cursor_text = {
+                    let str_trxt = cursor_text.to_string();
 
-            TerminalEl::shape_cursor(cursor_point, dimensions, &cursor_text).map(
-                move |(cursor_position, block_width)| {
-                    let (shape, color) = if self.focused {
-                        (CursorShape::Block, terminal_theme.colors.cursor)
+                    let color = if self.focused {
+                        terminal_theme.colors.background
                     } else {
-                        (CursorShape::Underscore, terminal_theme.colors.foreground)
+                        terminal_theme.colors.foreground
                     };
 
-                    Cursor::new(
-                        cursor_position,
-                        block_width,
-                        dimensions.line_height,
-                        color,
-                        shape,
-                        Some(cursor_text),
+                    cx.text_layout_cache.layout_str(
+                        &str_trxt,
+                        text_style.font_size,
+                        &[(
+                            str_trxt.len(),
+                            RunStyle {
+                                font_id: text_style.font_id,
+                                color,
+                                underline: Default::default(),
+                            },
+                        )],
                     )
-                },
-            )
+                };
+
+                TerminalEl::shape_cursor(cursor_point, dimensions, &cursor_text).map(
+                    move |(cursor_position, block_width)| {
+                        let (shape, color) = if self.focused {
+                            (CursorShape::Block, terminal_theme.colors.cursor)
+                        } else {
+                            (CursorShape::Hollow, terminal_theme.colors.foreground)
+                        };
+
+                        Cursor::new(
+                            cursor_position,
+                            block_width,
+                            dimensions.line_height,
+                            color,
+                            shape,
+                            Some(cursor_text),
+                        )
+                    },
+                )
+            }
         };
 
         //Done!
@@ -818,7 +859,10 @@ impl Element for TerminalEl {
 
                 //TODO Talk to keith about how to catch events emitted from an element.
                 if let Some(view) = self.view.upgrade(cx.app) {
-                    view.update(cx.app, |view, cx| view.clear_bel(cx))
+                    view.update(cx.app, |view, cx| {
+                        view.clear_bel(cx);
+                        view.pause_cursor_blinking(cx);
+                    })
                 }
 
                 self.terminal

crates/terminal/src/connected_view.rs 🔗

@@ -1,3 +1,5 @@
+use std::time::Duration;
+
 use alacritty_terminal::term::TermMode;
 use context_menu::{ContextMenu, ContextMenuItem};
 use gpui::{
@@ -9,10 +11,13 @@ use gpui::{
     AnyViewHandle, AppContext, Element, ElementBox, ModelHandle, MutableAppContext, View,
     ViewContext, ViewHandle,
 };
+use smol::Timer;
 use workspace::pane;
 
 use crate::{connected_el::TerminalEl, Event, Terminal};
 
+const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
+
 ///Event to transmit the scroll from the element to the view
 #[derive(Clone, Debug, PartialEq)]
 pub struct ScrollTerminal(pub i32);
@@ -51,6 +56,9 @@ pub struct ConnectedView {
     // Only for styling purposes. Doesn't effect behavior
     modal: bool,
     context_menu: ViewHandle<ContextMenu>,
+    show_cursor: bool,
+    blinking_paused: bool,
+    blink_epoch: usize,
 }
 
 impl ConnectedView {
@@ -83,6 +91,9 @@ impl ConnectedView {
             has_bell: false,
             modal,
             context_menu: cx.add_view(ContextMenu::new),
+            show_cursor: true,
+            blinking_paused: false,
+            blink_epoch: 0,
         }
     }
 
@@ -120,6 +131,59 @@ impl ConnectedView {
         cx.notify();
     }
 
+    //Following code copied from editor cursor
+    pub fn blink_show(&self) -> bool {
+        self.blinking_paused || self.show_cursor
+    }
+
+    fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
+        if epoch == self.blink_epoch && !self.blinking_paused {
+            self.show_cursor = !self.show_cursor;
+            cx.notify();
+
+            let epoch = self.next_blink_epoch();
+            cx.spawn(|this, mut cx| {
+                let this = this.downgrade();
+                async move {
+                    Timer::after(CURSOR_BLINK_INTERVAL).await;
+                    if let Some(this) = this.upgrade(&cx) {
+                        this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
+                    }
+                }
+            })
+            .detach();
+        }
+    }
+
+    pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
+        self.show_cursor = true;
+        cx.notify();
+
+        let epoch = self.next_blink_epoch();
+        cx.spawn(|this, mut cx| {
+            let this = this.downgrade();
+            async move {
+                Timer::after(CURSOR_BLINK_INTERVAL).await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
+                }
+            }
+        })
+        .detach();
+    }
+
+    fn next_blink_epoch(&mut self) -> usize {
+        self.blink_epoch += 1;
+        self.blink_epoch
+    }
+
+    fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
+        if epoch == self.blink_epoch {
+            self.blinking_paused = false;
+            self.blink_cursors(epoch, cx);
+        }
+    }
+
     ///Attempt to paste the clipboard into the terminal
     fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
         self.terminal.update(cx, |term, _| term.copy())
@@ -189,9 +253,15 @@ impl View for ConnectedView {
 
         Stack::new()
             .with_child(
-                TerminalEl::new(cx.handle(), terminal_handle, self.modal, focused)
-                    .contained()
-                    .boxed(),
+                TerminalEl::new(
+                    cx.handle(),
+                    terminal_handle,
+                    self.modal,
+                    focused,
+                    self.blink_show(),
+                )
+                .contained()
+                .boxed(),
             )
             .with_child(ChildView::new(&self.context_menu).boxed())
             .boxed()
@@ -200,6 +270,7 @@ impl View for ConnectedView {
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
         self.has_new_content = false;
         self.terminal.read(cx).focus_in();
+        self.blink_cursors(self.blink_epoch, cx);
         cx.notify();
     }
 
@@ -208,6 +279,7 @@ impl View for ConnectedView {
         cx.notify();
     }
 
+    //IME stuff
     fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
         if self
             .terminal

crates/terminal/src/terminal.rs 🔗

@@ -25,7 +25,7 @@ use futures::{
 };
 
 use modal::deploy_modal;
-use settings::{Settings, Shell};
+use settings::{Settings, Shell, TerminalBlink};
 use std::{collections::HashMap, fmt::Display, path::PathBuf, sync::Arc, time::Duration};
 use thiserror::Error;
 
@@ -254,6 +254,7 @@ impl TerminalBuilder {
         shell: Option<Shell>,
         env: Option<HashMap<String, String>>,
         initial_size: TerminalSize,
+        blink_settings: Option<TerminalBlink>,
     ) -> Result<TerminalBuilder> {
         let pty_config = {
             let alac_shell = shell.clone().and_then(|shell| match shell {
@@ -287,9 +288,21 @@ impl TerminalBuilder {
         setup_env(&config);
 
         //Spawn a task so the Alacritty EventLoop can communicate with us in a view context
+        //TODO: Remove with a bounded sender which can be dispatched on &self
         let (events_tx, events_rx) = unbounded();
         //Set up the terminal...
-        let term = Term::new(&config, &initial_size, ZedListener(events_tx.clone()));
+        let mut term = Term::new(&config, &initial_size, ZedListener(events_tx.clone()));
+
+        //Start off blinking if we need to
+        match blink_settings {
+            Some(setting) => match setting {
+                TerminalBlink::On | TerminalBlink::Always => {
+                    term.set_mode(alacritty_terminal::ansi::Mode::BlinkingCursor)
+                }
+                _ => {}
+            },
+            None => term.set_mode(alacritty_terminal::ansi::Mode::BlinkingCursor),
+        }
         let term = Arc::new(FairMutex::new(term));
 
         //Setup the pty...
@@ -321,7 +334,7 @@ impl TerminalBuilder {
         //And connect them together
         let event_loop = EventLoop::new(
             term.clone(),
-            ZedListener(events_tx),
+            ZedListener(events_tx.clone()),
             pty,
             pty_config.hold,
             false,
@@ -582,7 +595,7 @@ impl Terminal {
 
     pub fn render_lock<F, T>(&mut self, cx: &mut ModelContext<Self>, f: F) -> T
     where
-        F: FnOnce(RenderableContent, char) -> T,
+        F: FnOnce(RenderableContent, char, bool) -> T,
     {
         let m = self.term.clone(); //Arc clone
         let mut term = m.lock();
@@ -598,7 +611,7 @@ impl Terminal {
 
         let cursor_text = term.grid()[content.cursor.point].c;
 
-        f(content, cursor_text)
+        f(content, cursor_text, term.cursor_style().blinking)
     }
 
     ///Scroll the terminal

crates/terminal/src/terminal_view.rs 🔗

@@ -94,8 +94,13 @@ impl TerminalView {
         let shell = settings.terminal_overrides.shell.clone();
         let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
 
-        let content = match TerminalBuilder::new(working_directory.clone(), shell, envs, size_info)
-        {
+        let content = match TerminalBuilder::new(
+            working_directory.clone(),
+            shell,
+            envs,
+            size_info,
+            settings.terminal_overrides.blinking.clone(),
+        ) {
             Ok(terminal) => {
                 let terminal = cx.add_model(|cx| terminal.subscribe(cx));
                 let view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));