From 7efa9474f8e478969529f44788b6de7a3feb5b5d Mon Sep 17 00:00:00 2001 From: ventupx Date: Wed, 21 Oct 2026 14:50:47 +0800 Subject: [PATCH] feat(cursor): support five localized cursor blinking styles --- .../2026-10-20-cursor-blink-styles.md | 52 ++++++++ nebula_app/i18n/de-DE.json | 8 ++ nebula_app/i18n/en-US.json | 8 ++ nebula_app/i18n/es-ES.json | 8 ++ nebula_app/i18n/fr-FR.json | 8 ++ nebula_app/i18n/it-IT.json | 8 ++ nebula_app/i18n/ja-JP.json | 8 ++ nebula_app/i18n/ko-KR.json | 8 ++ nebula_app/i18n/pt-BR.json | 8 ++ nebula_app/i18n/ru-RU.json | 8 ++ nebula_app/i18n/zh-CN.json | 8 ++ nebula_app/i18n/zh-TW.json | 8 ++ nebula_app/src/gpui_shell/config.rs | 2 + nebula_app/src/gpui_shell/settings_pane.rs | 3 +- .../settings_pane/appearance_advanced.rs | 9 +- .../cursor_style_settings_tests.rs | 97 ++++++++++++++ .../settings_pane/initialization.rs | 22 +++ .../gpui_shell/settings_pane/localization.rs | 7 + .../settings_pane/theme_studio_tests.rs | 2 + .../src/gpui_shell/terminal/cursor_blink.rs | 125 ++++++++++++++++++ .../src/gpui_shell/terminal/cursor_painter.rs | 91 ++++++++++--- .../src/gpui_shell/terminal/effects/frame.rs | 55 ++++---- nebula_app/src/gpui_shell/terminal/element.rs | 1 + nebula_app/src/gpui_shell/terminal/mod.rs | 1 + nebula_app/src/gpui_shell/terminal/view.rs | 64 ++++++--- .../gpui_shell/terminal/view/cursor/tests.rs | 49 +++++++ .../src/gpui_shell/terminal/view/startup.rs | 1 + nebula_app/src/i18n/tests.rs | 21 +++ nebula_settings/src/cursor_blink.rs | 63 +++++++++ nebula_settings/src/lib.rs | 10 +- nebula_settings/src/reset.rs | 10 ++ 31 files changed, 698 insertions(+), 75 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/terminal/2026-10-20-cursor-blink-styles.md create mode 100644 nebula_app/src/gpui_shell/settings_pane/cursor_style_settings_tests.rs create mode 100644 nebula_app/src/gpui_shell/terminal/cursor_blink.rs create mode 100644 nebula_settings/src/cursor_blink.rs diff --git a/architecture/notes/nebula_app/gpui_shell/terminal/2026-10-20-cursor-blink-styles.md b/architecture/notes/nebula_app/gpui_shell/terminal/2026-10-20-cursor-blink-styles.md new file mode 100644 index 000000000..a46925c52 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/terminal/2026-10-20-cursor-blink-styles.md @@ -0,0 +1,52 @@ +# Cursor blinking presentation + +## Status +Implemented. + +## Context +The existing cursor timer only toggled visibility. Smooth fading, breathing and +vertical expansion need presentation frames without changing terminal geometry. + +## Evidence +[`TerminalView`](../../../../../nebula_app/src/gpui_shell/terminal/view.rs) already owns an epoch-cancelled cursor timer, focus/IME subscriptions +and input-triggered restarts. `cursor_painter.rs` supplies the same cursor bounds +to ordinary rendering, glyph inversion and the optional shader effect frame. + +## Decision +`nebula_settings::CursorBlinkStyle` owns `blink`, `smooth`, `phase`, `expand`, and +`solid` under `cursor_blink_style`. An explicit valid named style wins over the +legacy boolean. Missing/invalid styles honor `cursor_blink=0` as `solid`; otherwise +the default is `blink`. The effective boolean remains available to old consumers. +Reset removes both preferences. + +The GPUI presentation adapter samples opacity/vertical scale over a 1500ms cycle. +`blink` retains the 750ms binary timer; animated modes use 16ms ticks. Typing, +style changes and focus changes restart visibly. Epoch cancellation remains owned +by each terminal entity. Hidden output, unfocused windows/panes, IME preedit and +VT-hidden/steady cursors stop scheduling. `solid` suppresses even VT requests to +blink. Reduced motion substitutes binary blinking for the three animated modes. + +Painting scales the cursor about its vertical center, fades glyph inversion with +the block, and feeds the same rectangle/color into shader cursor uniforms. The +terminal snapshot, input/IME anchor, grid and movement trajectory are unchanged. + +## Rejected alternatives +A global animation timer would keep hidden panes alive. Animating the terminal +cursor coordinates would move input/IME anchors. Treating `solid` as an animation +with an always-visible frame would leave unnecessary wakeups. + +## Consequences +At most the visible focused pane runs a smooth blink timer. Steady cursors and +applications that hide the VT cursor retain their protocol semantics. + +## Validation +[Preference compatibility/round trips/reset](../../../../../nebula_settings/src/cursor_blink.rs); [waveform](../../../../../nebula_app/src/gpui_shell/terminal/cursor_blink.rs) and shape geometry tests; +rendered settings choices, reopen/cancel and existing focus/IME/motion regressions; +hot application preserves the session and logical cursor, including VT requests. + +## Supersedes +None. + +## Revisit when +Blinks need user-controlled timing or the rendering engine exposes a shared +animation clock that preserves these lifecycle gates. diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index f1a82bc8f..855aefe5b 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "Cursor-Blinkstil", + "blink": "Blinken", + "smooth": "Sanft", + "phase": "Pulsieren", + "expand": "Ausdehnen", + "solid": "Statisch" + }, "sidebar": { "application": "Anwendung", "appearance": "Darstellung", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index d1493326b..77902769c 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -160,6 +160,14 @@ "refresh": "Refresh system environment for new terminals", "refresh_description": "Read current Windows environment settings when opening a terminal. Turn off to inherit the environment used to launch Pebrel, including temporary PATH entries. Open terminals are unchanged." }, + "cursor_blink": { + "title": "Cursor blinking style", + "blink": "Blink", + "smooth": "Smooth", + "phase": "Phase", + "expand": "Expand", + "solid": "Solid" + }, "cursor_motion": { "title": "Smooth cursor", "description": "Animate terminal cursor movement; large jumps remain instant.", diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index 67afd390a..5f4e87a38 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "Estilo de parpadeo del cursor", + "blink": "Parpadeo", + "smooth": "Suave", + "phase": "Pulsación", + "expand": "Expansión", + "solid": "Fijo" + }, "sidebar": { "application": "Aplicación", "appearance": "Apariencia", diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index ed9e741c6..de120855d 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "Style de clignotement du curseur", + "blink": "Clignotement", + "smooth": "Fondu", + "phase": "Pulsation", + "expand": "Expansion", + "solid": "Fixe" + }, "sidebar": { "application": "Application", "appearance": "Apparence", diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 1d63c6a0f..9e9afb017 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "Stile di lampeggio del cursore", + "blink": "Lampeggio", + "smooth": "Dissolvenza", + "phase": "Pulsazione", + "expand": "Espansione", + "solid": "Fisso" + }, "sidebar": { "application": "Applicazione", "appearance": "Aspetto", diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index d99aca08a..4b5c28813 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -17,6 +17,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "カーソルの点滅スタイル", + "blink": "点滅", + "smooth": "フェード", + "phase": "呼吸", + "expand": "伸縮", + "solid": "常時表示" + }, "completion": { "mode": "補完モード", "cycle": "補完モードを切り替え", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 32e072204..500b7e41a 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "커서 깜박임 스타일", + "blink": "깜박임", + "smooth": "페이드", + "phase": "호흡", + "expand": "확장", + "solid": "항상 표시" + }, "sidebar": { "application": "애플리케이션", "appearance": "모양", diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index b87db41a0..8bac5c905 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "Estilo de intermitência do cursor", + "blink": "Piscar", + "smooth": "Suave", + "phase": "Pulsação", + "expand": "Expansão", + "solid": "Fixo" + }, "sidebar": { "application": "Aplicativo", "appearance": "Aparência", diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index 85edbb8ad..f00a78018 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -6,6 +6,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "Стиль мигания курсора", + "blink": "Мигание", + "smooth": "Плавное затухание", + "phase": "Пульсация", + "expand": "Расширение", + "solid": "Постоянный" + }, "sidebar": { "application": "Приложение", "appearance": "Внешний вид", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 7736ea709..5e495746b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -160,6 +160,14 @@ "refresh": "新建终端时刷新系统环境变量", "refresh_description": "打开终端时读取 Windows 当前环境设置。关闭后继承启动 Pebrel 时的环境,包括临时添加的 PATH。已打开的终端不受影响。" }, + "cursor_blink": { + "title": "光标闪烁样式", + "blink": "闪烁", + "smooth": "平滑", + "phase": "呼吸", + "expand": "伸缩", + "solid": "常亮" + }, "cursor_motion": { "title": "平滑光标", "description": "让终端光标平滑移动,大范围跳转仍立即定位。", diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index b370aabb3..5ef0b6faf 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -17,6 +17,14 @@ "en_us": "English" }, "settings": { + "cursor_blink": { + "title": "游標閃爍樣式", + "blink": "閃爍", + "smooth": "平滑", + "phase": "呼吸", + "expand": "伸縮", + "solid": "恆亮" + }, "completion": { "mode": "補齊模式", "cycle": "切換補齊模式", diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index c7859e911..0ab404de1 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -76,6 +76,7 @@ pub struct Settings { pub(crate) resolved_theme: Arc, pub cursor_shape: Option, pub cursor_blink: Option, + pub cursor_blink_style: nebula_settings::CursorBlinkStyle, pub cursor_motion: nebula_settings::CursorMotion, /// 选区完成即复制(旧壳 `copy_on_select` 设置)。 pub copy_on_select: bool, @@ -293,6 +294,7 @@ impl Settings { CursorShapeName::Hollow => CursorShape::HollowBlock, }), cursor_blink: runtime.cursor_blink, + cursor_blink_style: runtime.cursor_blink_style, cursor_motion: runtime.cursor_motion, copy_on_select: runtime.copy_on_select, scrollback_lines: runtime.scrollback_lines, diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index cb4c19f04..6cc340553 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -31,7 +31,7 @@ use setting_help::{SettingHelp, help}; use std::sync::Arc; use std::time::Duration; -use crate::gpui_shell::config::{DEFAULT_CURSOR_BLINK, effective_cursor_blink}; +use crate::gpui_shell::config::effective_cursor_blink; use crate::gpui_shell::prelude::*; use crate::gpui_shell::widgets::{NebulaButton, settings_control_height}; @@ -798,6 +798,7 @@ impl SettingsPane { "accept" => pick!(accept), "completion_style" => pick!(completion_style), "tabs_position" => pick!(tabs_position), + "cursor_blink_style" => pick!(cursor_blink_style), "tab_reveal" => pick!(tab_reveal), "density" => pick!(density), "new_tab_position" => pick!(new_tab_position), diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 4b4d820d6..de0aa0240 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -75,11 +75,10 @@ impl SettingsPane { help("cursor_shape", language), cx, )) - .child(self.switch_row( - "cursor_blink", - language.pick("光标闪烁", "Blink cursor"), - help("cursor_blink", language), - self.runtime.cursor_blink.unwrap_or(DEFAULT_CURSOR_BLINK), + .child(self.select_row( + "cursor_blink_style", + language.text(crate::i18n::Message::SettingsCursorBlinkTitle), + "", cx, )); let interface = self diff --git a/nebula_app/src/gpui_shell/settings_pane/cursor_style_settings_tests.rs b/nebula_app/src/gpui_shell/settings_pane/cursor_style_settings_tests.rs new file mode 100644 index 000000000..3b1b2a17c --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/cursor_style_settings_tests.rs @@ -0,0 +1,97 @@ +use super::*; + +#[gpui::test] +fn cursor_blink_language_changes_keep_the_selected_style(cx: &mut TestAppContext) { + use crate::gpui_shell::config::Settings; + use crate::i18n::{Message, UiLanguage}; + let _lock = lock_theme_studio(); + let _bytes = SettingsBytesGuard::capture(); + std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap(); + std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS).unwrap(); + let (pane, mut window) = open_settings(cx); + pane.update(&mut window, |pane, cx| { + pane.active_section = 1; + cx.notify(); + }); + window.simulate_resize(size(px(1200.0), px(2400.0))); + draw(&mut window); + click("settings-select-cursor_blink_style", &mut window); + press("down", &mut window); + press("enter", &mut window); + for language in UiLanguage::ALL { + pane.update(&mut window, |pane, cx| { + pane.persist(&[("language", language.code().to_owned())], cx); + }); + pane.update_in(&mut window, |pane, window, cx| { + pane.refresh_localized_controls(window, cx); + }); + draw(&mut window); + click("settings-select-cursor_blink_style", &mut window); + press("enter", &mut window); + assert_eq!( + RuntimeSettings::load().cursor_blink_style, + nebula_settings::CursorBlinkStyle::Smooth + ); + pane.read_with(&window, |pane, cx| { + assert_eq!(cx.global::().ui_language, *language); + let select = pane.select_of("cursor_blink_style").unwrap().read(cx); + assert_eq!(select.selected_index(cx).unwrap().row, 1); + assert_eq!( + select.selected_value().unwrap().as_ref(), + language.text(Message::SettingsCursorBlinkSmooth) + ); + }); + } +} + +#[gpui::test] +fn cursor_blink_style_persists_keyboard_choices_reopens_and_cancels(cx: &mut TestAppContext) { + use crate::gpui_shell::config::Settings; + let _lock = lock_theme_studio(); + let _bytes = SettingsBytesGuard::capture(); + std::fs::create_dir_all(nebula_settings::settings_dir()).unwrap(); + std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS).unwrap(); + let (pane, mut window) = open_settings(cx); + pane.update(&mut window, |pane, cx| { + pane.active_section = 1; + cx.notify(); + }); + window.simulate_resize(size(px(1200.0), px(2400.0))); + let values = nebula_settings::CursorBlinkStyle::VALUES; + for (row, &value) in values.iter().enumerate().skip(1) { + draw(&mut window); + click("settings-select-cursor_blink_style", &mut window); + press("down", &mut window); + press("enter", &mut window); + assert_eq!(RuntimeSettings::load().cursor_blink_style.settings_value(), value); + window.read(|cx| { + let settings = cx.global::(); + assert_eq!(settings.cursor_blink_style.settings_value(), value); + if value == "solid" { + assert!(!settings.term_config().default_cursor_style.blinking); + } + }); + let reopened = window.update(|window, cx| cx.new(|cx| SettingsPane::new(window, cx))); + reopened.read_with(&mut window, |pane, cx| { + assert_eq!( + pane.select_of("cursor_blink_style") + .unwrap() + .read(cx) + .selected_index(cx) + .unwrap() + .row, + row + ); + }); + } + click("settings-select-cursor_blink_style", &mut window); + press("up", &mut window); + press("escape", &mut window); + pane.read_with(&mut window, |pane, _| { + assert_eq!( + pane.runtime.cursor_blink_style.settings_value(), + *values.last().unwrap(), + "Escape cancels" + ); + }); +} diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index 2c8feb2de..72b68f980 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -44,6 +44,21 @@ impl SettingsPane { this.set_cursor_motion(value, window, cx); return; } + if key == "cursor_blink_style" { + if let Err(error) = + this.try_persist(&[(key, (*value).to_owned())], cx) + { + let current = this.runtime.cursor_blink_style.settings_value(); + this.sync_select(key, current, window, cx); + super::super::toast::toast( + window, + cx, + crate::display::ToastKind::Warning, + error.to_string(), + ); + } + return; + } if key == "notification_duration" { this.set_notification_duration(value, window, cx); return; @@ -73,6 +88,13 @@ impl SettingsPane { ); let cursor_current = runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam"); + add_select( + "cursor_blink_style", + nebula_settings::CursorBlinkStyle::VALUES, + runtime.cursor_blink_style.settings_value(), + window, + cx, + ); add_select( "cursor_motion", nebula_settings::CursorMotion::VALUES, diff --git a/nebula_app/src/gpui_shell/settings_pane/localization.rs b/nebula_app/src/gpui_shell/settings_pane/localization.rs index 75f235e31..580500d9f 100644 --- a/nebula_app/src/gpui_shell/settings_pane/localization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/localization.rs @@ -72,6 +72,13 @@ pub(super) fn localized_select_labels( language.text(crate::i18n::Message::SettingsCursorMotionOff), language.text(crate::i18n::Message::SettingsCursorMotionSmooth), ], + "cursor_blink_style" => vec![ + language.text(crate::i18n::Message::SettingsCursorBlinkBlink), + language.text(crate::i18n::Message::SettingsCursorBlinkSmooth), + language.text(crate::i18n::Message::SettingsCursorBlinkPhase), + language.text(crate::i18n::Message::SettingsCursorBlinkExpand), + language.text(crate::i18n::Message::SettingsCursorBlinkSolid), + ], "tabs_position" => { vec![language.pick("左侧边栏", "Left sidebar"), language.pick("顶部", "Top")] }, diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs index 5a5f01a2e..4aa3aacd9 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs @@ -15,6 +15,8 @@ use nebula_settings::{RawSettings, RuntimeSettings, ThemeDefinition, ThemeName}; #[path = "theme_background_tests.rs"] mod background_tests; +#[path = "cursor_style_settings_tests.rs"] +mod cursor_style_settings_tests; #[path = "theme_package_tests.rs"] mod package_tests; #[cfg(feature = "shader-background")] diff --git a/nebula_app/src/gpui_shell/terminal/cursor_blink.rs b/nebula_app/src/gpui_shell/terminal/cursor_blink.rs new file mode 100644 index 000000000..dea7c4978 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/cursor_blink.rs @@ -0,0 +1,125 @@ +//! Presentation-only blinking. VT visibility, focus and IME gating stay in the view. + +use nebula_settings::CursorBlinkStyle; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct BlinkFrame { + pub opacity: f32, + pub height: f32, +} + +impl Default for BlinkFrame { + fn default() -> Self { + Self { opacity: 1.0, height: 1.0 } + } +} + +pub(super) struct CursorBlink { + pub style: CursorBlinkStyle, + pub frame: BlinkFrame, + started: Instant, +} + +impl Default for CursorBlink { + fn default() -> Self { + Self { style: CursorBlinkStyle::Blink, frame: Default::default(), started: Instant::now() } + } +} + +impl CursorBlink { + pub fn restart(&mut self, style: CursorBlinkStyle, reduce_motion: bool) { + self.style = if reduce_motion + && !matches!(style, CursorBlinkStyle::Solid | CursorBlinkStyle::Blink) + { + CursorBlinkStyle::Blink + } else { + style + }; + self.started = Instant::now(); + self.frame = BlinkFrame::default(); + } + + pub fn interval(&self) -> Duration { + Duration::from_millis(if self.style == CursorBlinkStyle::Blink { 750 } else { 16 }) + } + + pub fn tick(&mut self) -> BlinkFrame { + self.frame = if self.style == CursorBlinkStyle::Blink { + BlinkFrame { opacity: if self.frame.opacity > 0.0 { 0.0 } else { 1.0 }, height: 1.0 } + } else { + sample(self.style, self.started.elapsed()) + }; + self.frame + } +} + +fn sample(style: CursorBlinkStyle, elapsed: Duration) -> BlinkFrame { + let phase = (elapsed.as_secs_f32() / 1.5).fract(); + let fade = |t: f32| { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) + }; + let envelope = if phase < 0.35 { + 1.0 + } else if phase < 0.5 { + 1.0 - fade((phase - 0.35) / 0.15) + } else if phase < 0.85 { + 0.0 + } else { + fade((phase - 0.85) / 0.15) + }; + let mut frame = BlinkFrame::default(); + match style { + CursorBlinkStyle::Blink => frame.opacity = if phase < 0.5 { 1.0 } else { 0.0 }, + CursorBlinkStyle::Smooth => frame.opacity = envelope, + CursorBlinkStyle::Phase => { + frame.opacity = 0.1 + 0.9 * (1.0 + (phase * std::f32::consts::TAU).cos()) * 0.5 + }, + CursorBlinkStyle::Expand => frame.height = envelope, + CursorBlinkStyle::Solid => {}, + } + frame +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn styles_have_distinct_opacity_and_height_cycles_with_no_discontinuity_at_restart() { + let time = Duration::from_millis(640); + assert_eq!(sample(CursorBlinkStyle::Blink, time).opacity, 1.0); + let smooth = sample(CursorBlinkStyle::Smooth, time); + assert!(smooth.opacity > 0.0 && smooth.opacity < 1.0); + assert_eq!(smooth.height, 1.0); + let expand = sample(CursorBlinkStyle::Expand, time); + assert_eq!(expand.opacity, 1.0); + assert_eq!(expand.height, smooth.opacity); + for style in [ + CursorBlinkStyle::Blink, + CursorBlinkStyle::Smooth, + CursorBlinkStyle::Phase, + CursorBlinkStyle::Expand, + CursorBlinkStyle::Solid, + ] { + assert_eq!(sample(style, Duration::ZERO), BlinkFrame::default()); + assert_eq!(sample(style, Duration::from_millis(1500)), BlinkFrame::default()); + } + assert_eq!(sample(CursorBlinkStyle::Solid, time), BlinkFrame::default()); + assert!(sample(CursorBlinkStyle::Phase, Duration::from_millis(750)).opacity >= 0.09); + assert_eq!(sample(CursorBlinkStyle::Blink, Duration::from_millis(750)).opacity, 0.0); + } + + #[test] + fn switching_styles_and_reduced_motion_reset_to_a_visible_cursor() { + let mut blink = CursorBlink::default(); + assert_eq!(blink.tick().opacity, 0.0); + blink.restart(CursorBlinkStyle::Solid, false); + assert_eq!(blink.frame, BlinkFrame::default()); + blink.restart(CursorBlinkStyle::Smooth, true); + assert_eq!(blink.style, CursorBlinkStyle::Blink); + blink.restart(CursorBlinkStyle::Solid, true); + assert_eq!(blink.style, CursorBlinkStyle::Solid); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/cursor_painter.rs b/nebula_app/src/gpui_shell/terminal/cursor_painter.rs index ec6ea3a00..732fd5f95 100644 --- a/nebula_app/src/gpui_shell/terminal/cursor_painter.rs +++ b/nebula_app/src/gpui_shell/terminal/cursor_painter.rs @@ -11,15 +11,35 @@ pub(super) struct CursorPaint { pub shape: CursorShape, pub focused: bool, pub visible: bool, + pub blink: super::cursor_blink::BlinkFrame, pub block_color: Rgba, pub stroke: Rgba, pub text_color: Option, } impl CursorPaint { + pub(super) fn visual_rect(&self) -> Bounds { + let mut rect = self.rect; + if self.shape == CursorShape::Beam { + rect.size.width = px(2.0); + } + if self.shape == CursorShape::Underline { + rect.origin.y = rect.bottom() - px(2.0); + rect.size.height = px(2.0); + } + let factor = if self.focused { self.blink.height } else { 1.0 }; + rect.origin.y += rect.size.height * ((1.0 - factor) * 0.5); + rect.size.height *= factor; + rect + } + + pub(super) fn visual_color(&self, color: Rgba) -> Rgba { + Rgba { a: color.a * if self.focused { self.blink.opacity } else { 1.0 }, ..color } + } + pub fn paint_background(&self, window: &mut Window) { if self.focused && self.visible && self.shape == CursorShape::Block { - window.paint_quad(fill(self.rect, self.block_color)); + window.paint_quad(fill(self.visual_rect(), self.visual_color(self.block_color))); } } @@ -30,22 +50,14 @@ impl CursorPaint { match (self.focused, self.shape) { (false, CursorShape::Block | CursorShape::HollowBlock) | (true, CursorShape::HollowBlock) => { - window.paint_quad(outline(self.rect, self.stroke, gpui::BorderStyle::Solid)); - }, - (_, CursorShape::Beam) => { - window.paint_quad(fill( - Bounds::new(self.rect.origin, size(px(2.0), self.rect.size.height)), - self.stroke, + window.paint_quad(outline( + self.visual_rect(), + self.visual_color(self.stroke), + gpui::BorderStyle::Solid, )); }, - (_, CursorShape::Underline) => { - window.paint_quad(fill( - Bounds::new( - point(self.rect.origin.x, self.rect.bottom() - px(2.0)), - size(self.rect.size.width, px(2.0)), - ), - self.stroke, - )); + (_, CursorShape::Beam | CursorShape::Underline) => { + window.paint_quad(fill(self.visual_rect(), self.visual_color(self.stroke))); }, _ => {}, } @@ -55,9 +67,13 @@ impl CursorPaint { (self.focused && self.visible && self.shape == CursorShape::Block - && self.rect.intersects(&bounds)) + && self.visual_rect().intersects(&bounds)) .then_some(self.text_color) .flatten() + .map(|mut color| { + color.a *= self.blink.opacity; + color + }) } /// Reuse the exact shaped layout, including ligature cluster positions. @@ -86,7 +102,7 @@ impl CursorPaint { let mut inverted = window.text_system().shape_line(shaped.text.clone(), shaped.font_size, &[run], None); *inverted = std::ops::Deref::deref(shaped).clone(); - window.with_content_mask(Some(ContentMask { bounds: self.rect }), |window| { + window.with_content_mask(Some(ContentMask { bounds: self.visual_rect() }), |window| { let _ = inverted.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx); }); } @@ -120,3 +136,44 @@ pub(super) fn paint_box_glyph( } } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn expand_keeps_every_shape_centered_and_fades_include_inverted_text() { + let mut cursor = CursorPaint { + rect: Bounds::new(point(px(10.0), px(20.0)), size(px(9.0), px(18.0))), + shape: CursorShape::Block, + focused: true, + visible: true, + blink: super::super::cursor_blink::BlinkFrame { opacity: 0.4, height: 0.5 }, + block_color: gpui::white().into(), + stroke: gpui::white().into(), + text_color: Some(gpui::black()), + }; + for shape in [ + CursorShape::Block, + CursorShape::Beam, + CursorShape::Underline, + CursorShape::HollowBlock, + ] { + cursor.shape = shape; + let visual = cursor.visual_rect(); + assert_eq!( + visual.center().y, + if shape == CursorShape::Underline { px(37.0) } else { px(29.0) } + ); + assert_eq!( + visual.size.height, + if shape == CursorShape::Underline { px(1.0) } else { px(9.0) } + ); + assert_eq!(cursor.visual_color(cursor.stroke).a, 0.4); + } + cursor.shape = CursorShape::Block; + assert_eq!(cursor.inverted_color(cursor.rect).unwrap().a, 0.4); + cursor.focused = false; + assert_eq!(cursor.visual_rect(), cursor.rect); + assert_eq!(cursor.visual_color(cursor.stroke).a, 1.0); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/effects/frame.rs b/nebula_app/src/gpui_shell/terminal/effects/frame.rs index fea1de38a..86020a1f8 100644 --- a/nebula_app/src/gpui_shell/terminal/effects/frame.rs +++ b/nebula_app/src/gpui_shell/terminal/effects/frame.rs @@ -29,6 +29,7 @@ mod tests { shape: CursorShape::Beam, focused: true, visible: true, + blink: Default::default(), block_color: palette.cursor, stroke: palette.cursor, text_color: None, @@ -91,38 +92,30 @@ impl History { let delta = if self.index == 0 { 0.0 } else { seconds - self.last_time }; self.last_time = seconds; let current = cursor - .map(|cursor| Cursor { - rect: [ - f32::from(cursor.rect.left() - bounds.left()) * scale, - f32::from(if cursor.shape == CursorShape::Underline { - cursor.rect.bottom() - gpui::px(2.0) - bounds.top() + .map(|cursor| { + let rect = cursor.visual_rect(); + Cursor { + rect: [ + f32::from(rect.left() - bounds.left()) * scale, + f32::from(rect.top() - bounds.top()) * scale, + f32::from(rect.size.width) * scale, + f32::from(rect.size.height) * scale, + ], + color: rgba(cursor.visual_color(if cursor.shape == CursorShape::Block { + cursor.block_color } else { - cursor.rect.top() - bounds.top() - }) * scale, - f32::from(if cursor.shape == CursorShape::Beam { - gpui::px(2.0) - } else { - cursor.rect.size.width - }) * scale, - f32::from(if cursor.shape == CursorShape::Underline { - gpui::px(2.0) - } else { - cursor.rect.size.height - }) * scale, - ], - color: rgba(if cursor.shape == CursorShape::Block { - cursor.block_color - } else { - cursor.stroke - }), - style: match cursor.shape { - CursorShape::Block if cursor.focused => 0, - CursorShape::Block | CursorShape::HollowBlock => 1, - CursorShape::Beam => 2, - CursorShape::Underline => 3, - _ => 4, - }, - visible: cursor.shape != CursorShape::Hidden && (!cursor.focused || cursor.visible), + cursor.stroke + })), + style: match cursor.shape { + CursorShape::Block if cursor.focused => 0, + CursorShape::Block | CursorShape::HollowBlock => 1, + CursorShape::Beam => 2, + CursorShape::Underline => 3, + _ => 4, + }, + visible: cursor.shape != CursorShape::Hidden + && (!cursor.focused || cursor.visible), + } }) .unwrap_or(Cursor { style: 4, ..Default::default() }); if self.current != Some(current) { diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 2d79f691b..ca7e75f91 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -463,6 +463,7 @@ impl Element for TerminalElement { shape: cursor.shape, focused, visible: cursor_visible, + blink: self.view.read(cx).cursor_blink_frame(), block_color: if host_cursor_follows_theme { rgba_rgb(theme_anchor, block_alpha) } else { diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index 29409fb35..cd00fa2bf 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -7,6 +7,7 @@ mod answer_reader; pub mod colors; mod completion_viewport; pub(super) mod confirmation; +mod cursor_blink; mod cursor_motion; mod cursor_painter; #[cfg(feature = "shader-background")] diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 30ee9100f..602a9392f 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -433,6 +433,7 @@ pub struct TerminalView { cursor_visible: bool, cursor_animation: cursor::CursorAnimation, cursor_blink_epoch: u64, + cursor_blink: super::cursor_blink::CursorBlink, /// OS 窗口前台状态与 pane 内焦点是两层独立条件。缓存它们是因为 blink /// timer 回调没有 `Window`,状态变化由 GPUI observer 立即重启相位。 cursor_window_active: bool, @@ -501,8 +502,6 @@ impl TerminalView { /// 尾沿去抖窗口:视口静默这么久才向 Term/ConPTY 提交一次 resize。 /// 见 `set_layout` 内的合同注释(conhost rewrap 漂移取证)。 const RESIZE_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(150); - /// 与 legacy `config::cursor::Cursor::default().blink_interval` 保持一致。 - const CURSOR_BLINK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(750); /// 首帧前的当前单元格度量(与 element prepaint 同一公式:shape "M" 的 /// advance / ascent / descent + 配置 offset)。让 spawn 网格与首帧布局一致,避免启动 @@ -545,7 +544,11 @@ impl TerminalView { super::effects::visibility_changed(self, cx); } - if std::mem::replace(&mut self.output_visible, visible) != visible && visible { + let changed = std::mem::replace(&mut self.output_visible, visible) != visible; + if changed { + self.reset_cursor_blink(cx); + } + if changed && visible { // Hidden output deliberately did not invalidate the cached view. // The workspace declares visibility during render, where GPUI can // absorb notifications. Invalidate after that draw so a cached child @@ -822,43 +825,60 @@ impl TerminalView { } fn cursor_should_blink(&self) -> bool { - self.session.as_ref().is_some_and(|session| { - let term = session.term.lock(); - let mode = term.mode(); - cursor_blink_allowed( - term.cursor_style().blinking, - mode.contains(TermMode::VI), - mode.contains(TermMode::SHOW_CURSOR), - self.marked_text.is_some(), - self.cursor_window_active, - self.cursor_pane_focused, - ) - }) + self.output_visible + && self.answer_reader.is_none() + && self.cursor_blink.style != nebula_settings::CursorBlinkStyle::Solid + && self.session.as_ref().is_some_and(|session| { + let term = session.term.lock(); + let mode = term.mode(); + cursor_blink_allowed( + term.cursor_style().blinking, + mode.contains(TermMode::VI), + mode.contains(TermMode::SHOW_CURSOR), + self.marked_text.is_some(), + self.cursor_window_active, + self.cursor_pane_focused, + ) + }) } fn restart_cursor_blink(&mut self, cx: &mut Context) { + self.reset_cursor_blink(cx); + cx.notify(); + } + + fn reset_cursor_blink(&mut self, cx: &mut Context) { + let style = cx + .try_global::() + .map(|settings| settings.cursor_blink_style) + .unwrap_or_default(); + self.cursor_blink.restart(style, cx.reduce_motion()); let epoch = restart_cursor_blink_phase(&mut self.cursor_visible, &mut self.cursor_blink_epoch); - self.schedule_cursor_blink_tick(epoch, cx); - cx.notify(); + if self.cursor_should_blink() { + self.schedule_cursor_blink_tick(epoch, cx); + } } fn schedule_cursor_blink_tick(&self, epoch: u64, cx: &mut Context) { let executor = cx.background_executor().clone(); + let interval = self.cursor_blink.interval(); cx.spawn(async move |this, cx| { - executor.timer(Self::CURSOR_BLINK_INTERVAL).await; + executor.timer(interval).await; let _ = this.update(cx, |view, cx| { if view.cursor_blink_epoch != epoch { return; } if !view.cursor_should_blink() { - if !view.cursor_visible { + if !view.cursor_visible || view.cursor_blink.frame != Default::default() { view.cursor_visible = true; + view.cursor_blink.frame = Default::default(); cx.notify(); } return; } - view.cursor_visible = !view.cursor_visible; + let frame = view.cursor_blink.tick(); + view.cursor_visible = frame.opacity > 0.0 && frame.height > 0.0; view.schedule_cursor_blink_tick(epoch, cx); cx.notify(); }); @@ -870,6 +890,10 @@ impl TerminalView { self.cursor_visible } + pub(super) fn cursor_blink_frame(&self) -> super::cursor_blink::BlinkFrame { + self.cursor_blink.frame + } + fn on_bell(&mut self, cx: &mut Context) { // Typed hooks own this agent's result and attention notifications. // BEL carries neither success nor error metadata and must not add a diff --git a/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs b/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs index 01986d007..85fc95d23 100644 --- a/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/cursor/tests.rs @@ -20,6 +20,55 @@ fn geometry(shape: CursorShape) -> CursorGeometry { } } +#[gpui::test] +fn blinking_styles_hot_apply_without_changing_the_session_and_obey_vt_visibility( + cx: &mut TestAppContext, +) { + use nebula_settings::CursorBlinkStyle; + let (view, window, _) = open(cx); + window.update(|_, cx| { + view.update(cx, |view, cx| { + let term = view.session.as_ref().unwrap().term.clone(); + let before = capture(view).cursor.unwrap(); + for style in [ + CursorBlinkStyle::Blink, + CursorBlinkStyle::Smooth, + CursorBlinkStyle::Phase, + CursorBlinkStyle::Expand, + CursorBlinkStyle::Solid, + ] { + let settings = cx.global_mut::(); + settings.cursor_blink_style = style; + settings.cursor_blink = Some(style != CursorBlinkStyle::Solid); + view.apply_settings(cx); + assert!(Arc::ptr_eq(&term, &view.session.as_ref().unwrap().term)); + assert_eq!( + view.cursor_blink_frame(), + crate::gpui_shell::terminal::cursor_blink::BlinkFrame::default() + ); + let after = capture(view).cursor.unwrap(); + assert_eq!((after.col, after.row), (before.col, before.row)); + } + view.cursor_window_active = true; + view.cursor_pane_focused = true; + feed(view, b"\x1b[5 q"); + view.restart_cursor_blink(cx); + assert!(!view.cursor_should_blink(), "solid must also override VT requests to blink"); + cx.global_mut::().cursor_blink_style = CursorBlinkStyle::Smooth; + view.restart_cursor_blink(cx); + assert!(view.cursor_should_blink()); + feed(view, b"\x1b[?25l"); + view.restart_cursor_blink(cx); + assert!( + !view.cursor_should_blink(), + "a VT-hidden cursor must never be animated back on" + ); + view.set_output_visible(false, cx); + assert!(!view.cursor_should_blink()); + }); + }); +} + #[gpui::test] fn cursor_motion_hot_applies_without_replacing_the_session_or_logical_cursor( cx: &mut TestAppContext, diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index 064b4aa50..d9704ce43 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -365,6 +365,7 @@ impl TerminalView { cursor_visible: true, cursor_animation: Default::default(), cursor_blink_epoch: 0, + cursor_blink: Default::default(), cursor_window_active, cursor_pane_focused, output_visible: true, diff --git a/nebula_app/src/i18n/tests.rs b/nebula_app/src/i18n/tests.rs index 08342d65e..78f8fc8ef 100644 --- a/nebula_app/src/i18n/tests.rs +++ b/nebula_app/src/i18n/tests.rs @@ -66,6 +66,27 @@ fn tab_context_menu_has_translations_for_every_supported_language() { assert_eq!(UiLanguage::KoKr.text(Message::TabMenuTabColor), "탭 색상"); } +#[test] +fn cursor_blink_choices_are_single_language_in_every_supported_locale() { + let choices = [ + Message::SettingsCursorBlinkBlink, + Message::SettingsCursorBlinkSmooth, + Message::SettingsCursorBlinkPhase, + Message::SettingsCursorBlinkExpand, + Message::SettingsCursorBlinkSolid, + ]; + for language in UiLanguage::ALL { + for choice in choices { + let label = language.text(choice); + assert!(!label.is_empty() && !label.contains('·'), "{}: {label}", language.code()); + if *language != UiLanguage::EnUs { + assert_ne!(label, UiLanguage::EnUs.text(choice), "{}: {choice:?}", language.code()); + } + } + } + assert!(super::message_id("settings.cursor_blink.description").is_none()); +} + #[test] fn inline_migration_preserves_bilingual_text_and_english_fallback() { assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络"); diff --git a/nebula_settings/src/cursor_blink.rs b/nebula_settings/src/cursor_blink.rs new file mode 100644 index 000000000..2a03f2ec1 --- /dev/null +++ b/nebula_settings/src/cursor_blink.rs @@ -0,0 +1,63 @@ +//! Cursor presentation, independent of its shape and movement animation. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CursorBlinkStyle { + #[default] + Blink, + Smooth, + Phase, + Expand, + Solid, +} + +impl CursorBlinkStyle { + pub const VALUES: &'static [&'static str] = &["blink", "smooth", "phase", "expand", "solid"]; + + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "blink" => Some(Self::Blink), + "smooth" => Some(Self::Smooth), + "phase" => Some(Self::Phase), + "expand" => Some(Self::Expand), + "solid" => Some(Self::Solid), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + Self::VALUES[self as usize] + } + + pub(crate) fn resolve(raw: &crate::RawSettings) -> Self { + raw.value("cursor_blink_style").and_then(Self::from_settings).unwrap_or_else(|| { + if raw.bool_on("cursor_blink") == Some(false) { Self::Solid } else { Self::Blink } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawSettings, RuntimeSettings}; + + #[test] + fn blink_styles_round_trip_and_preserve_the_legacy_disabled_preference() { + for value in CursorBlinkStyle::VALUES { + let raw = RawSettings::from_text(&format!("cursor_blink_style={value}\n")); + assert_eq!(RuntimeSettings::from_raw(&raw).cursor_blink_style.settings_value(), *value); + } + for (text, expected) in [ + ("", CursorBlinkStyle::Blink), + ("cursor_blink=0\n", CursorBlinkStyle::Solid), + ("cursor_blink=1\n", CursorBlinkStyle::Blink), + ("cursor_blink_style=invalid\ncursor_blink=0\n", CursorBlinkStyle::Solid), + ("cursor_blink_style=smooth\ncursor_blink=0\n", CursorBlinkStyle::Smooth), + ] { + assert_eq!( + RuntimeSettings::from_raw(&RawSettings::from_text(text)).cursor_blink_style, + expected + ); + } + assert_eq!(CursorBlinkStyle::from_settings(" PHASE "), Some(CursorBlinkStyle::Phase)); + } +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 3e660bcf3..dc3d0174b 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -25,7 +25,9 @@ mod background_effects; pub use background_effects::{BackgroundEffectRequest, BackgroundEffects}; mod terminal_effects; pub use terminal_effects::{EffectAnimation, TerminalEffects}; +mod cursor_blink; mod cursor_motion; +pub use cursor_blink::CursorBlinkStyle; mod custom_theme; pub use cursor_motion::CursorMotion; pub use custom_theme::{ @@ -1004,6 +1006,7 @@ pub struct RuntimeSettings { pub ligatures: Ligatures, pub cursor_shape: Option, pub cursor_blink: Option, + pub cursor_blink_style: CursorBlinkStyle, pub cursor_motion: CursorMotion, pub copy_on_select: bool, /// Maximum retained history for new terminals, without altering open sessions. @@ -1200,7 +1203,12 @@ impl RuntimeSettings { .and_then(Ligatures::from_settings) .unwrap_or_default(), cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings), - cursor_blink: raw.bool_on("cursor_blink"), + cursor_blink: raw + .value("cursor_blink_style") + .and_then(CursorBlinkStyle::from_settings) + .map(|style| style != CursorBlinkStyle::Solid) + .or_else(|| raw.bool_on("cursor_blink")), + cursor_blink_style: CursorBlinkStyle::resolve(raw), cursor_motion: raw .value("cursor_motion") .and_then(CursorMotion::from_settings) diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index a10d558da..023a5f16d 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -38,6 +38,7 @@ const RESET_KEYS: &[&str] = &[ "completion_style", "cjk_bold_regular", "tabs_position", + "cursor_blink_style", "tab_reveal", "density", "new_tab_position", @@ -150,6 +151,15 @@ mod tests { use super::*; use crate::{RawSettings, RuntimeSettings}; + #[test] + fn reset_restores_cursor_blink_style_and_preserves_unrelated_keys() { + let text = default_settings_text("cursor_blink_style=solid\ncursor_blink=0\ncustom=keep\n"); + let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&text)); + assert_eq!(runtime.cursor_blink_style, crate::CursorBlinkStyle::Blink); + assert_eq!(runtime.cursor_blink, None); + assert!(text.contains("custom=keep")); + } + #[test] fn reset_restores_auto_scrollbar_visibility() { let text = default_settings_text("scrollbar_visibility=always\ncustom=keep\n");