From 39ceb35fdd75bfc4b47f5600f71bddcff9e977b1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:35:18 +0800 Subject: [PATCH] feat(cursor): give the shell prompt its own cursor shape (#963) A new prompt_cursor_style setting (follow, block, bar, underline) shapes the caret while the shell waits at a prompt, whether tty7's inline editor or the shell's own line editor draws it. `follow`, the default, keeps cursor_style everywhere, so nothing changes until it is set. Any other value leaves cursor_style to the programs the shell runs: bar here with cursor_style block gives kitty and ghostty's bar at the prompt and a block inside a TUI that never sets a shape, such as Claude Code. A vi-mode prompt keeps the shell's own insert/normal shapes. Settings shows both cursor shapes as dropdowns, kept in step with resets, language switches and config edits made outside the window. Closes #958 --- CHANGELOG.md | 9 +++ crates/tty7-core/src/core/config.rs | 50 ++++++++++++ docs/reference/configuration.mdx | 1 + src/terminal/element.rs | 19 ++++- src/terminal/view.rs | 74 +++++++++++++++++- src/ui/app.rs | 117 ++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 8 ++ src/ui/i18n/ja.rs | 8 ++ src/ui/i18n/mod.rs | 4 + src/ui/i18n/zh.rs | 8 ++ src/ui/settings.rs | 89 +++++++++++++++------ 11 files changed, 361 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8775bb..01682019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tree is remembered in the config. Both are built from the status the host already sends, so a remote repository gets them unchanged. Searching the diff text itself is not part of this. +- **The cursor at the shell prompt can have its own shape** (#958). A new + `prompt_cursor_style` setting (Settings → Appearance → Cursor) takes + `follow`, `block`, `bar` or `underline`. `follow`, the default, keeps + `cursor_style` everywhere, exactly as before. Any other value is used at the + prompt, drawn by tty7's inline editor or by the shell's own line editor, and + leaves `cursor_style` to the programs the shell runs — so `bar` with + `cursor_style: "block"` gives kitty and ghostty's bar-at-the-prompt, block in + a TUI that never sets a shape itself, such as Claude Code. A shell prompt in + vi mode keeps its own insert/normal shapes. ### Removed diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 5c6b9a18..37dc6c04 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -331,6 +331,13 @@ pub struct Config { #[serde(default, deserialize_with = "de_lenient")] pub cursor_style: CursorStyle, + /// The caret's shape while the shell waits at a prompt, whether tty7's + /// inline editor or the shell's own line editor draws it. `follow` keeps + /// `cursor_style` everywhere; any other value leaves `cursor_style` to the + /// programs the shell runs, which is how kitty and ghostty put a bar at + /// the prompt and a block inside a TUI that never sets a shape itself. + #[serde(default, deserialize_with = "de_lenient")] + pub prompt_cursor_style: PromptCursorStyle, pub macos_option_as_alt: bool, pub mouse_hide_while_typing: bool, @@ -536,6 +543,28 @@ pub enum CursorStyle { Underline, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PromptCursorStyle { + #[default] + Follow, + Block, + Bar, + Underline, +} + +impl PromptCursorStyle { + /// The shape this names, or `None` when the prompt follows `cursor_style`. + pub fn shape(self) -> Option { + match self { + Self::Follow => None, + Self::Block => Some(CursorStyle::Block), + Self::Bar => Some(CursorStyle::Bar), + Self::Underline => Some(CursorStyle::Underline), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NewTabPosition { @@ -763,6 +792,7 @@ impl Default for Config { tab_completion: true, history_search: true, cursor_style: CursorStyle::Block, + prompt_cursor_style: PromptCursorStyle::Follow, macos_option_as_alt: false, mouse_hide_while_typing: true, focus_follows_mouse: false, @@ -3057,4 +3087,24 @@ mod tests { assert_eq!(back.ssh_tab_title, mode); } } + + #[test] + fn prompt_cursor_style_defaults_to_following_cursor_style() { + assert_eq!( + Config::default().prompt_cursor_style, + PromptCursorStyle::Follow + ); + assert_eq!(PromptCursorStyle::Follow.shape(), None); + + let cfg: Config = + serde_json::from_str(r#"{"cursor_style": "block", "prompt_cursor_style": "bar"}"#) + .unwrap(); + assert_eq!(cfg.cursor_style, CursorStyle::Block); + assert_eq!(cfg.prompt_cursor_style.shape(), Some(CursorStyle::Bar)); + + // A value this build does not know falls back rather than failing the + // whole config. + let cfg: Config = serde_json::from_str(r#"{"prompt_cursor_style": "beam"}"#).unwrap(); + assert_eq!(cfg.prompt_cursor_style, PromptCursorStyle::Follow); + } } diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index f7af4865..5fa308eb 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -118,6 +118,7 @@ their id from the file name. [More about themes →](/customization/themes) | `env` | object | `{}` | Extra environment variables for every pane. | | `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | | `cursor_style` | enum | `"block"` | `block`, `bar`, `underline`. | +| `prompt_cursor_style` | enum | `"follow"` | The cursor at the shell prompt: `follow`, `block`, `bar`, `underline`. `follow` uses `cursor_style` everywhere; any other value leaves `cursor_style` to the programs the shell runs, e.g. `bar` here with `cursor_style: "block"` gives a bar at the prompt and a block in a TUI that sets no shape of its own. A shell prompt in vi mode keeps its own shapes. | | `cursor_blink` | bool | `true` | | | `bell` | enum | `"visual"` | `none`, `visual`, `audible`, `both`. | | `per_pane_history` | bool | `false` | Give each pane its own shell history file. | diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 68f810a1..00c02980 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use alacritty_terminal::grid::Dimensions as _; use alacritty_terminal::index::{Column as AlacColumn, Line as AlacLine, Point as AlacPoint}; use alacritty_terminal::selection::SelectionRange; +use alacritty_terminal::term::TermMode; use alacritty_terminal::term::cell::{Cell, Flags}; use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb}; use gpui::{ @@ -1817,6 +1818,17 @@ impl GridSnapshot { } impl TerminalElement { + /// The shape `prompt_cursor_style` puts on the grid cursor while the shell + /// waits at a prompt with its own line editor — `prompt_editor` off, or a + /// line handed back to ZLE/readline. Shell integration has no mark for "a + /// prompt plugin set this shape", so the one it does report, vi mode, keeps + /// the shell's own insert/normal shapes. + pub(super) fn prompt_cursor_shape(&self, cx: &App) -> Option { + let shape = cx.global::().prompt_cursor_style.shape()?; + let terminal = &self.view.read(cx).terminal; + (terminal.at_prompt() && !terminal.shell_vi_mode()).then_some(shape) + } + pub(super) fn build_grid( &self, colors: &PaintColors, @@ -1834,6 +1846,7 @@ impl TerminalElement { let mut any_selected = false; let display_offset; let history_size; + let prompt_shape = self.prompt_cursor_shape(cx); { let mut palette = self.view.read(cx).terminal.palette; if let Some(active) = cx.try_global::() { @@ -1949,7 +1962,11 @@ impl TerminalElement { col, ime_col, hidden: cursor_hidden, - style: cursor_style_from_shape(cur.shape), + // Checked under the grid lock: `on_alt_screen` would take + // it a second time. + style: prompt_shape + .filter(|_| !term.mode().contains(TermMode::ALT_SCREEN)) + .unwrap_or_else(|| cursor_style_from_shape(cur.shape)), }); } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5d6a4ecd..6bdec557 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6599,7 +6599,11 @@ impl TerminalView { } } - let cursor_style = cx.global::().cursor_style; + // This caret only exists at a prompt, so the prompt's own shape wins. + let cursor_style = { + let cfg = cx.global::(); + cfg.prompt_cursor_style.shape().unwrap_or(cfg.cursor_style) + }; let cursor_paint = input_caret_paint(focused, self.cursor_visible, cursor_style); let cursor_on = cursor_paint.is_some(); let block_cursor = cursor_paint == Some(InputCaretPaint::Block); @@ -16451,6 +16455,74 @@ mod gpui_tests { "a spawned pane counts its first agent, once" ); } + + /// #958: `prompt_cursor_style` shapes the shell's own caret at a prompt, + /// leaves the running program's cursor alone, and steps aside for a vi-mode + /// prompt, whose insert/normal shapes are the shell's to set. + #[gpui::test] + fn prompt_cursor_style_shapes_only_the_prompt(cx: &mut TestAppContext) { + use crate::core::config::{CursorStyle, PromptCursorStyle}; + + let (window, mut daemon) = harness(cx); + let shape = |cx: &mut TestAppContext| { + cx.update(|cx| { + let view = window.entity(cx).unwrap(); + TerminalElement::new(view).prompt_cursor_shape(cx) + }) + }; + let wait_for = |cx: &mut TestAppContext, want: &dyn Fn(&TerminalView) -> bool| { + for _ in 0..200 { + cx.run_until_parked(); + if window.update(cx, |view, _, _| want(view)).unwrap() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the pane never reached the expected prompt state"); + }; + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Bar); + + assert_eq!( + shape(cx), + None, + "no prompt reported yet: a program owns the cursor" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| view.terminal.at_prompt()); + assert_eq!(shape(cx), Some(CursorStyle::Bar)); + + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Follow); + assert_eq!(shape(cx), None, "follow leaves the prompt on cursor_style"); + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Bar); + + DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| view.terminal.shell_vi_mode()); + assert_eq!( + shape(cx), + None, + "a vi-mode prompt keeps the shell's own shapes" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| !view.terminal.at_prompt()); + assert_eq!(shape(cx), None, "a running command gets cursor_style back"); + } + /// Seeds a pane's history for the ghost tests: `(command, dirs, exit)`, /// oldest first, with `cwd` as the directory the pane is in. fn seed_ghost_history( diff --git a/src/ui/app.rs b/src/ui/app.rs index 1cd7ad3e..439de03b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3015,6 +3015,19 @@ impl Tty7App { self.apply_terminal_config_to_panes(&cfg, cx); } + pub(crate) fn set_prompt_cursor_style( + &mut self, + style: crate::core::config::PromptCursorStyle, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.prompt_cursor_style = style); + for tab in &self.tabs { + for leaf in tab.pane.terminals() { + leaf.update(cx, |_, cx| cx.notify()); + } + } + } + pub(crate) fn persist_settings_config(&mut self, cx: &mut Context) { let config = cx.global::().clone(); let error = config.try_save().err().map(|error| error.to_string()); @@ -6235,6 +6248,9 @@ impl Tty7App { } L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx), L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx), + L10nKey::SettingsPromptCursorShape => { + self.set_prompt_cursor_style(defaults.prompt_cursor_style, cx) + } L10nKey::SettingsScrollback => self.set_scrollback_limit(defaults.scrollback_limit, cx), L10nKey::SettingsNewTabPosition => { self.set_new_tab_position(defaults.new_tab_position, cx) @@ -6410,6 +6426,9 @@ impl Tty7App { s.ui_font_select = ui; } } + L10nKey::SettingsCursorShape | L10nKey::SettingsPromptCursorShape => { + self.sync_cursor_selects(window, cx) + } L10nKey::SettingsLanguage => { let value = self.build_language_select(&mut subs, window, cx); if let Some(s) = self.active_settings_mut() { @@ -6537,6 +6556,8 @@ impl Tty7App { let (font_select, font_bold_select, font_italic_select, ui_font_select) = self.build_font_selects(&mut subs, window, cx); let language_select = self.build_language_select(&mut subs, window, cx); + let (cursor_style_select, prompt_cursor_style_select) = + self.build_cursor_selects(&mut subs, window, cx); #[cfg(target_os = "windows")] let window_backdrop_select = self.build_window_backdrop_select(&mut subs, window, cx); let (shell_program_input, shell_args_input, wd_path_input) = @@ -6628,6 +6649,8 @@ impl Tty7App { font_italic_select, ui_font_select, language_select, + cursor_style_select, + prompt_cursor_style_select, #[cfg(target_os = "windows")] window_backdrop_select, shell_program_input, @@ -6833,6 +6856,98 @@ impl Tty7App { language_select } + /// The cursor shape and prompt cursor shape dropdowns. Each resolves the + /// picked label back through the row list it was built from. + fn build_cursor_selects( + &mut self, + subs: &mut Vec, + window: &mut Window, + cx: &mut Context, + ) -> ( + Entity>>, + Entity>>, + ) { + use crate::ui::settings::{ + CURSOR_SHAPES, PROMPT_CURSOR_SHAPES, cursor_shape_labels, prompt_cursor_shape_labels, + }; + let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx); + let shape = cx.new(|cx| { + SelectState::new( + SearchableVec::new(cursor_shape_labels()), + Some(IndexPath::default().row(shape_ix)), + window, + cx, + ) + }); + let prompt = cx.new(|cx| { + SelectState::new( + SearchableVec::new(prompt_cursor_shape_labels()), + Some(IndexPath::default().row(prompt_ix)), + window, + cx, + ) + }); + subs.push(cx.subscribe_in( + &shape, + window, + |this, _select, ev: &SelectEvent>, _window, cx| { + if let SelectEvent::Confirm(Some(label)) = ev { + if let Some(ix) = cursor_shape_labels().iter().position(|r| r == label) { + this.set_cursor_style(CURSOR_SHAPES[ix], cx); + } + } + }, + )); + subs.push(cx.subscribe_in( + &prompt, + window, + |this, _select, ev: &SelectEvent>, _window, cx| { + if let SelectEvent::Confirm(Some(label)) = ev { + if let Some(ix) = prompt_cursor_shape_labels().iter().position(|r| r == label) { + this.set_prompt_cursor_style(PROMPT_CURSOR_SHAPES[ix], cx); + } + } + }, + )); + (shape, prompt) + } + + /// The dropdown rows the config's two cursor shapes sit on. + fn cursor_select_rows(cx: &App) -> (usize, usize) { + use crate::ui::settings::{CURSOR_SHAPES, PROMPT_CURSOR_SHAPES}; + let cfg = cx.global::(); + let shape = CURSOR_SHAPES.iter().position(|s| *s == cfg.cursor_style); + let prompt = PROMPT_CURSOR_SHAPES + .iter() + .position(|s| *s == cfg.prompt_cursor_style); + (shape.unwrap_or(0), prompt.unwrap_or(0)) + } + + /// Puts both cursor dropdowns back on the config's values, relabelled in + /// the current language — after a reset, a language switch, or a config + /// edit made outside this window. + fn sync_cursor_selects(&mut self, window: &mut Window, cx: &mut Context) { + use crate::ui::settings::{cursor_shape_labels, prompt_cursor_shape_labels}; + let Some((shape, prompt)) = self.active_settings().map(|s| { + ( + s.cursor_style_select.clone(), + s.prompt_cursor_style_select.clone(), + ) + }) else { + return; + }; + let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx); + for (select, rows, ix) in [ + (shape, cursor_shape_labels(), shape_ix), + (prompt, prompt_cursor_shape_labels(), prompt_ix), + ] { + select.update(cx, |state, cx| { + state.set_items(SearchableVec::new(rows), window, cx); + state.set_selected_index(Some(IndexPath::default().row(ix)), window, cx); + }); + } + } + fn normalize_gui_language(code: &str) -> &'static str { crate::ui::i18n::find_language(code) .map(|lang| lang.code) @@ -6902,6 +7017,7 @@ impl Tty7App { } pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context) { + self.sync_cursor_selects(window, cx); self.sidebar_search.update(cx, |state, cx| { state.set_placeholder(t(L10nKey::SearchTabs), window, cx) }); @@ -7430,6 +7546,7 @@ impl Tty7App { self.terminal_scrollback_limit = config.scrollback_limit; self.apply_terminal_config_to_panes(&config, cx); } + self.sync_cursor_selects(window, cx); let (font_size, line_height, font_family, font_fallbacks, font_features) = { let cfg = cx.global::(); ( diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a2bd10f7..18618168 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -140,6 +140,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsCursor => "Cursor", L10nKey::SettingsCursorShape => "Cursor shape", L10nKey::SettingsCursorShapeDesc => "How the terminal cursor is drawn.", + L10nKey::SettingsPromptCursorShape => "Prompt cursor shape", + L10nKey::SettingsPromptCursorShapeDesc => { + "The cursor at the shell prompt. Follow uses the cursor shape above everywhere." + } L10nKey::SettingsCursorBlink => "Cursor blink", L10nKey::SettingsCursorBlinkDesc => "Pulse the cursor while the terminal is focused.", L10nKey::SettingsLanguage => "Language", @@ -839,6 +843,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchCopyOnSelectKeywords => "clipboard selection yank mouse", L10nKey::SettingsSearchCursorBlinkKeywords => "caret blinking flash", L10nKey::SettingsSearchCursorShapeKeywords => "caret block bar underline beam", + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "prompt cursor shape caret block bar underline beam shell integration" + } L10nKey::SettingsSearchCustomThemesKeywords => { "theme duplicate edit colors folder yaml import background image wallpaper" } @@ -1825,6 +1832,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CursorShapeBlock => "Block", L10nKey::CursorShapeBar => "Bar", L10nKey::CursorShapeUnderline => "Underline", + L10nKey::PromptCursorShapeFollow => "Follow", L10nKey::PaletteTryDifferentSearch => "Try a different search.", L10nKey::CompletionListingRemote => "listing remote…", L10nKey::CompletionRemoteListingFailed => "remote listing failed — {error}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0a8319b4..99941ff0 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -142,6 +142,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCursor => "カーソル", L10nKey::SettingsCursorShape => "カーソルの形状", L10nKey::SettingsCursorShapeDesc => "ターミナルカーソルの描画方法", + L10nKey::SettingsPromptCursorShape => "プロンプトのカーソル形状", + L10nKey::SettingsPromptCursorShapeDesc => { + "シェルプロンプトでのカーソル。「追従」は上のカーソル形状をどこでも使う" + } L10nKey::SettingsCursorBlink => "カーソルの点滅", L10nKey::SettingsCursorBlinkDesc => { "ターミナルがフォーカスされている間、カーソルを点滅させる" @@ -865,6 +869,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorShapeKeywords => { "カーソル 形状 ブロック バー アンダーライン ビーム cursor shape caret block bar underline beam" } + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "プロンプト カーソル 形状 ブロック バー 下線 prompt cursor shape caret block bar underline beam" + } L10nKey::SettingsSearchCustomThemesKeywords => { "テーマ 複製 編集 色 フォルダ 背景画像 壁紙 yaml インポート custom themes duplicate edit colors folder import background image wallpaper" } @@ -1897,6 +1904,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CursorShapeBlock => "ブロック", L10nKey::CursorShapeBar => "バー", L10nKey::CursorShapeUnderline => "下線", + L10nKey::PromptCursorShapeFollow => "追従", L10nKey::PaletteTryDifferentSearch => "別のキーワードを試してください。", L10nKey::CompletionListingRemote => "リモートを一覧しています…", L10nKey::CompletionRemoteListingFailed => "リモートの一覧に失敗しました — {error}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1eecd6d1..bbfcea66 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -186,6 +186,8 @@ l10n_keys! { SettingsCursor, SettingsCursorShape, SettingsCursorShapeDesc, + SettingsPromptCursorShape, + SettingsPromptCursorShapeDesc, SettingsCursorBlink, SettingsCursorBlinkDesc, SettingsLanguage, @@ -642,6 +644,7 @@ l10n_keys! { SettingsSearchCopyOnSelectKeywords, SettingsSearchCursorBlinkKeywords, SettingsSearchCursorShapeKeywords, + SettingsSearchPromptCursorShapeKeywords, SettingsSearchCustomThemesKeywords, SettingsSearchDetectUrlsKeywords, SettingsSearchDiffPreviewFromCountsKeywords, @@ -1475,6 +1478,7 @@ l10n_keys! { CursorShapeBlock, CursorShapeBar, CursorShapeUnderline, + PromptCursorShapeFollow, PaletteTryDifferentSearch, CompletionListingRemote, CompletionRemoteListingFailed, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 55105350..7f88acf5 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -126,6 +126,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCursor => "光标", L10nKey::SettingsCursorShape => "光标形状", L10nKey::SettingsCursorShapeDesc => "终端光标的绘制方式。", + L10nKey::SettingsPromptCursorShape => "提示符光标形状", + L10nKey::SettingsPromptCursorShapeDesc => { + "shell 提示符处的光标。跟随表示各处都用上面的光标形状。" + } L10nKey::SettingsCursorBlink => "光标闪烁", L10nKey::SettingsCursorBlinkDesc => "终端获得焦点时让光标闪烁。", L10nKey::SettingsLanguage => "语言", @@ -760,6 +764,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorShapeKeywords => { "光标形状 光标 块 竖线 下划线 cursor shape caret block bar underline beam" } + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "提示符 光标形状 光标 块 竖线 下划线 prompt cursor shape caret block bar underline beam" + } L10nKey::SettingsSearchCustomThemesKeywords => { "自定义主题 复制 编辑 颜色 文件夹 背景图片 壁纸 yaml 导入 theme custom edit duplicate colors import background image wallpaper" } @@ -1720,6 +1727,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CursorShapeBlock => "块状", L10nKey::CursorShapeBar => "竖线", L10nKey::CursorShapeUnderline => "下划线", + L10nKey::PromptCursorShapeFollow => "跟随", L10nKey::PaletteTryDifferentSearch => "换个关键词试试。", L10nKey::CompletionListingRemote => "正在列出远程目录…", L10nKey::CompletionRemoteListingFailed => "远程目录列表失败——{error}", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index dac43544..053c0008 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -24,7 +24,8 @@ use uuid::Uuid; use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, - SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + PromptCursorStyle, SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, + WindowBackdrop, }; use crate::core::keychain::{ CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, @@ -573,6 +574,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsCursorShape, keywords: SettingsSearchCursorShapeKeywords, }, + SearchEntry { + section: Appearance, + title: SettingsPromptCursorShape, + keywords: SettingsSearchPromptCursorShapeKeywords, + }, SearchEntry { section: Appearance, title: SettingsCursorBlink, @@ -902,12 +908,43 @@ fn settings_search_entries() -> &'static [SearchEntry] { ] } +/// The rows of the cursor shape dropdown, in `CURSOR_SHAPES` order. +pub(crate) fn cursor_shape_labels() -> Vec { + [ + L10nKey::CursorShapeBlock, + L10nKey::CursorShapeBar, + L10nKey::CursorShapeUnderline, + ] + .into_iter() + .map(|key| t(key).to_string()) + .collect() +} + +pub(crate) const CURSOR_SHAPES: [CursorStyle; 3] = + [CursorStyle::Block, CursorStyle::Bar, CursorStyle::Underline]; + +/// The rows of the prompt cursor shape dropdown, in `PROMPT_CURSOR_SHAPES` +/// order: follow, then the shapes. +pub(crate) fn prompt_cursor_shape_labels() -> Vec { + let mut rows = vec![t(L10nKey::PromptCursorShapeFollow).to_string()]; + rows.extend(cursor_shape_labels()); + rows +} + +pub(crate) const PROMPT_CURSOR_SHAPES: [PromptCursorStyle; 4] = [ + PromptCursorStyle::Follow, + PromptCursorStyle::Block, + PromptCursorStyle::Bar, + PromptCursorStyle::Underline, +]; + impl SearchEntry { fn config_key(&self) -> &'static str { match self.title { L10nKey::SettingsDimInactivePanes => "dim_inactive_panes", L10nKey::SettingsCursorBlink => "cursor_blink", L10nKey::SettingsCursorShape => "cursor_style", + L10nKey::SettingsPromptCursorShape => "prompt_cursor_style", L10nKey::SettingsScrollback => "scrollback_limit", L10nKey::SettingsNewTabPosition => "new_tab_position", L10nKey::SettingsTabBarPosition => "tab_bar_position", @@ -994,6 +1031,7 @@ impl SearchEntry { L10nKey::SettingsFontLigatures => t(L10nKey::SettingsFontLigaturesDesc), L10nKey::SettingsFontThicken => t(L10nKey::SettingsFontThickenDesc), L10nKey::SettingsCursorShape => t(L10nKey::SettingsCursorShapeDesc), + L10nKey::SettingsPromptCursorShape => t(L10nKey::SettingsPromptCursorShapeDesc), L10nKey::SettingsCursorBlink => t(L10nKey::SettingsCursorBlinkDesc), L10nKey::SettingsBackgroundImage => t(L10nKey::SettingsBackgroundImageDesc), L10nKey::SettingsImageOpacity => t(L10nKey::SettingsImageOpacityDesc), @@ -1060,6 +1098,9 @@ impl SearchEntry { } L10nKey::SettingsCursorBlink => cfg.cursor_blink != defaults.cursor_blink, L10nKey::SettingsCursorShape => cfg.cursor_style != defaults.cursor_style, + L10nKey::SettingsPromptCursorShape => { + cfg.prompt_cursor_style != defaults.prompt_cursor_style + } L10nKey::SettingsScrollback => cfg.scrollback_limit != defaults.scrollback_limit, L10nKey::SettingsNewTabPosition => cfg.new_tab_position != defaults.new_tab_position, L10nKey::SettingsTabBarPosition => cfg.tab_bar_position != defaults.tab_bar_position, @@ -1311,6 +1352,8 @@ pub(crate) struct SettingsState { pub(crate) font_italic_select: Entity>>, pub(crate) ui_font_select: Entity>>, pub(crate) language_select: Entity>>, + pub(crate) cursor_style_select: Entity>>, + pub(crate) prompt_cursor_style_select: Entity>>, #[cfg(target_os = "windows")] pub(crate) window_backdrop_select: Entity>>, pub(crate) shell_program_input: Entity, @@ -3535,8 +3578,14 @@ impl Tty7App { ), None => return div().into_any_element(), }; + let (cursor_style_select, prompt_cursor_style_select) = match self.active_settings() { + Some(s) => ( + s.cursor_style_select.clone(), + s.prompt_cursor_style_select.clone(), + ), + None => return div().into_any_element(), + }; let cfg = cx.global::(); - let cursor_style = cfg.cursor_style; let cursor_blink = cfg.cursor_blink; let font_thicken = cfg.font_thicken; let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| { @@ -3656,29 +3705,15 @@ impl Tty7App { ) }); - let cursor_idx = match cursor_style { - CursorStyle::Block => 0, - CursorStyle::Bar => 1, - CursorStyle::Underline => 2, + let cursor_dropdown = |state: &Entity>>| { + Select::new(state) + .small() + .w(px(FIELD_W)) + .h(px(24.)) + .into_any_element() }; - let cursor_style_control = self.segmented( - "cursor-style", - &[ - t(L10nKey::CursorShapeBlock), - t(L10nKey::CursorShapeBar), - t(L10nKey::CursorShapeUnderline), - ], - cursor_idx, - cx, - |this, ix, _w, cx| { - let style = match ix { - 0 => CursorStyle::Block, - 1 => CursorStyle::Bar, - _ => CursorStyle::Underline, - }; - this.set_cursor_style(style, cx); - }, - ); + let cursor_style_control = cursor_dropdown(&cursor_style_select); + let prompt_cursor_style_control = cursor_dropdown(&prompt_cursor_style_select); let blink_switch = crate::ui::theme::switch("cursor-blink", cx) .checked(cursor_blink) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx))) @@ -3755,6 +3790,12 @@ impl Tty7App { cursor_style_control, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsPromptCursorShape), + t(L10nKey::SettingsPromptCursorShapeDesc), + prompt_cursor_style_control, + cx, + )) .child(self.settings_row( t(L10nKey::SettingsCursorBlink), t(L10nKey::SettingsCursorBlinkDesc),