terminal: Make CursorShape configurable (#18530)

Thorsten Ball created

This builds on top of @Yevgen's #15840 and combines it with the settings
names introduced in #17572.

Closes #4731.

Release Notes:

- Added a setting for the terminal's default cursor shape. The setting
is `{"terminal": {"cursor_shape": "block"}}``. Possible values: `block`,
`bar`, `hollow`, `underline`.

Demo:


https://github.com/user-attachments/assets/96ed28c2-c222-436b-80cb-7cd63eeb47dd

Change summary

assets/settings/default.json              | 12 ++++++
crates/project/src/terminals.rs           |  1 
crates/terminal/src/terminal.rs           | 13 ++++++-
crates/terminal/src/terminal_settings.rs  | 43 +++++++++++++++++++++++++
crates/terminal_view/src/terminal_view.rs | 23 ++++++++++++-
5 files changed, 88 insertions(+), 4 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -671,6 +671,18 @@
     //  3. Always blink the cursor, ignoring the terminal mode
     //         "blinking": "on",
     "blinking": "terminal_controlled",
+    // Default cursor shape for the terminal.
+    //  1. A block that surrounds the following character
+    //     "block"
+    //  2. A vertical bar
+    //     "bar"
+    //  3. An underline that runs along the following character
+    //     "underscore"
+    //  4. A box drawn around the following character
+    //     "hollow"
+    //
+    // Default: not set, defaults to "block"
+    "cursor_shape": null,
     // Set whether Alternate Scroll mode (code: ?1007) is active by default.
     // Alternate Scroll mode converts mouse scroll events into up / down key
     // presses when in the alternate screen (e.g. when running applications

crates/project/src/terminals.rs 🔗

@@ -216,6 +216,7 @@ impl Project {
             shell,
             env,
             Some(settings.blinking),
+            settings.cursor_shape.unwrap_or_default(),
             settings.alternate_scroll,
             settings.max_scroll_history_lines,
             window,

crates/terminal/src/terminal.rs 🔗

@@ -18,7 +18,9 @@ use alacritty_terminal::{
         Config, RenderableCursor, TermMode,
     },
     tty::{self},
-    vte::ansi::{ClearMode, Handler, NamedPrivateMode, PrivateMode},
+    vte::ansi::{
+        ClearMode, CursorStyle as AlacCursorStyle, Handler, NamedPrivateMode, PrivateMode,
+    },
     Term,
 };
 use anyhow::{bail, Result};
@@ -40,7 +42,7 @@ use serde::{Deserialize, Serialize};
 use settings::Settings;
 use smol::channel::{Receiver, Sender};
 use task::{HideStrategy, Shell, TaskId};
-use terminal_settings::{AlternateScroll, TerminalBlink, TerminalSettings};
+use terminal_settings::{AlternateScroll, CursorShape, TerminalBlink, TerminalSettings};
 use theme::{ActiveTheme, Theme};
 use util::truncate_and_trailoff;
 
@@ -314,6 +316,7 @@ impl TerminalBuilder {
         shell: Shell,
         mut env: HashMap<String, String>,
         blink_settings: Option<TerminalBlink>,
+        cursor_shape: CursorShape,
         alternate_scroll: AlternateScroll,
         max_scroll_history_lines: Option<usize>,
         window: AnyWindowHandle,
@@ -353,6 +356,7 @@ impl TerminalBuilder {
         // Setup Alacritty's env, which modifies the current process's environment
         alacritty_terminal::tty::setup_env();
 
+        let default_cursor_style = AlacCursorStyle::from(cursor_shape);
         let scrolling_history = if task.is_some() {
             // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
             // After the task finishes, we do not allow appending to that terminal, so small tasks output should not
@@ -365,6 +369,7 @@ impl TerminalBuilder {
         };
         let config = Config {
             scrolling_history,
+            default_cursor_style,
             ..Config::default()
         };
 
@@ -951,6 +956,10 @@ impl Terminal {
         &self.last_content
     }
 
+    pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape) {
+        self.term.lock().set_cursor_style(Some(cursor_shape.into()));
+    }
+
     pub fn total_lines(&self) -> usize {
         let term = self.term.clone();
         let terminal = term.lock_unfair();

crates/terminal/src/terminal_settings.rs 🔗

@@ -1,3 +1,6 @@
+use alacritty_terminal::vte::ansi::{
+    CursorShape as AlacCursorShape, CursorStyle as AlacCursorStyle,
+};
 use collections::HashMap;
 use gpui::{
     px, AbsoluteLength, AppContext, FontFallbacks, FontFeatures, FontWeight, Pixels, SharedString,
@@ -32,6 +35,7 @@ pub struct TerminalSettings {
     pub font_weight: Option<FontWeight>,
     pub line_height: TerminalLineHeight,
     pub env: HashMap<String, String>,
+    pub cursor_shape: Option<CursorShape>,
     pub blinking: TerminalBlink,
     pub alternate_scroll: AlternateScroll,
     pub option_as_meta: bool,
@@ -129,6 +133,11 @@ pub struct TerminalSettingsContent {
     ///
     /// Default: {}
     pub env: Option<HashMap<String, String>>,
+    /// Default cursor shape for the terminal.
+    /// Can be "bar", "block", "underscore", or "hollow".
+    ///
+    /// Default: None
+    pub cursor_shape: Option<CursorShape>,
     /// Sets the cursor blinking behavior in the terminal.
     ///
     /// Default: terminal_controlled
@@ -282,3 +291,37 @@ pub struct ToolbarContent {
     /// Default: true
     pub title: Option<bool>,
 }
+
+#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum CursorShape {
+    /// Cursor is a block like `█`.
+    #[default]
+    Block,
+    /// Cursor is an underscore like `_`.
+    Underline,
+    /// Cursor is a vertical bar like `⎸`.
+    Bar,
+    /// Cursor is a hollow box like `▯`.
+    Hollow,
+}
+
+impl From<CursorShape> for AlacCursorShape {
+    fn from(value: CursorShape) -> Self {
+        match value {
+            CursorShape::Block => AlacCursorShape::Block,
+            CursorShape::Underline => AlacCursorShape::Underline,
+            CursorShape::Bar => AlacCursorShape::Beam,
+            CursorShape::Hollow => AlacCursorShape::HollowBlock,
+        }
+    }
+}
+
+impl From<CursorShape> for AlacCursorStyle {
+    fn from(value: CursorShape) -> Self {
+        AlacCursorStyle {
+            shape: value.into(),
+            blinking: false,
+        }
+    }
+}

crates/terminal_view/src/terminal_view.rs 🔗

@@ -19,7 +19,7 @@ use terminal::{
         index::Point,
         term::{search::RegexSearch, TermMode},
     },
-    terminal_settings::{TerminalBlink, TerminalSettings, WorkingDirectory},
+    terminal_settings::{CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory},
     Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown,
     ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal,
     TerminalSize,
@@ -102,6 +102,7 @@ pub struct TerminalView {
     //Currently using iTerm bell, show bell emoji in tab until input is received
     has_bell: bool,
     context_menu: Option<(View<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
+    cursor_shape: CursorShape,
     blink_state: bool,
     blinking_on: bool,
     blinking_paused: bool,
@@ -171,6 +172,9 @@ impl TerminalView {
         let focus_out = cx.on_focus_out(&focus_handle, |terminal_view, _event, cx| {
             terminal_view.focus_out(cx);
         });
+        let cursor_shape = TerminalSettings::get_global(cx)
+            .cursor_shape
+            .unwrap_or_default();
 
         Self {
             terminal,
@@ -178,6 +182,7 @@ impl TerminalView {
             has_bell: false,
             focus_handle,
             context_menu: None,
+            cursor_shape,
             blink_state: true,
             blinking_on: false,
             blinking_paused: false,
@@ -255,6 +260,16 @@ impl TerminalView {
     fn settings_changed(&mut self, cx: &mut ViewContext<Self>) {
         let settings = TerminalSettings::get_global(cx);
         self.show_title = settings.toolbar.title;
+
+        let new_cursor_shape = settings.cursor_shape.unwrap_or_default();
+        let old_cursor_shape = self.cursor_shape;
+        if old_cursor_shape != new_cursor_shape {
+            self.cursor_shape = new_cursor_shape;
+            self.terminal.update(cx, |term, _| {
+                term.set_cursor_shape(self.cursor_shape);
+            });
+        }
+
         cx.notify();
     }
 
@@ -903,7 +918,10 @@ impl TerminalView {
     }
 
     fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
-        self.terminal.read(cx).focus_in();
+        self.terminal.update(cx, |terminal, _| {
+            terminal.set_cursor_shape(self.cursor_shape);
+            terminal.focus_in();
+        });
         self.blink_cursors(self.blink_epoch, cx);
         cx.invalidate_character_coordinates();
         cx.notify();
@@ -912,6 +930,7 @@ impl TerminalView {
     fn focus_out(&mut self, cx: &mut ViewContext<Self>) {
         self.terminal.update(cx, |terminal, _| {
             terminal.focus_out();
+            terminal.set_cursor_shape(CursorShape::Hollow);
         });
         cx.notify();
     }