feat: configure cursor appearance by editing mode

This commit is contained in:
2026-10-26 15:01:19 +01:00
parent 846803a3e2
commit ce434a1c17
13 changed files with 356 additions and 51 deletions
+46
View File
@@ -0,0 +1,46 @@
use crate::editor::Mode;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct CursorConfig {
pub(crate) mode_colors: bool,
pub(crate) normal: CursorShape,
pub(crate) insert: CursorShape,
pub(crate) command: CursorShape,
pub(crate) search: CursorShape,
pub(crate) visual: CursorShape,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CursorShape {
Block,
Bar,
Underline,
}
impl Default for CursorConfig {
fn default() -> Self {
Self {
mode_colors: true,
normal: CursorShape::Block,
insert: CursorShape::Block,
command: CursorShape::Block,
search: CursorShape::Block,
visual: CursorShape::Block,
}
}
}
impl CursorConfig {
pub(crate) fn shape(self, mode: Mode) -> CursorShape {
match mode {
Mode::Normal => self.normal,
Mode::Insert => self.insert,
Mode::Command => self.command,
Mode::Search => self.search,
Mode::Visual => self.visual,
}
}
}
+2
View File
@@ -2228,6 +2228,7 @@ pub struct Editor {
bottom_bar: ChromeLine,
popup: PopupConfig,
labels: BTreeMap<String, String>,
pub(crate) cursor_config: crate::cursor::CursorConfig,
scroll_distance: Option<usize>,
scroll_step_lines: Option<NonZeroUsize>,
highlight_trailing_whitespace: bool,
@@ -2353,6 +2354,7 @@ impl Editor {
bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(),
labels: BTreeMap::new(),
cursor_config: crate::cursor::CursorConfig::default(),
scroll_distance: Some(0),
scroll_step_lines: None,
highlight_trailing_whitespace: false,
+1
View File
@@ -2,6 +2,7 @@ mod battery;
mod blame;
pub mod chrome;
pub mod clipboard;
pub(crate) mod cursor;
pub mod diff;
pub mod editor;
pub mod files;
+6
View File
@@ -43,6 +43,7 @@ pub struct Config {
pub clipboard_mode: String,
pub git_blame: bool,
pub git_gutter: crate::gutter::GutterStyle,
pub(crate) cursor: crate::cursor::CursorConfig,
pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine,
pub popup: PopupConfig,
@@ -101,6 +102,7 @@ impl Default for Config {
clipboard_mode: "system".into(),
git_blame: true,
git_gutter: crate::gutter::GutterStyle::default(),
cursor: crate::cursor::CursorConfig::default(),
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(),
@@ -146,6 +148,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
clipboard_mode,
git_blame,
git_gutter,
cursor,
top_bar,
bottom_bar,
popup,
@@ -192,6 +195,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.clipboard_mode = clipboard_mode;
config.git_blame = git_blame;
config.git_gutter = git_gutter;
config.cursor = cursor;
config.top_bar = top_bar;
config.bottom_bar = bottom_bar;
config.popup = popup;
@@ -558,6 +562,8 @@ struct ConfigFrame {
clipboard_mode: String,
git_blame: bool,
git_gutter: crate::gutter::GutterStyle,
#[serde(default)]
cursor: crate::cursor::CursorConfig,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
+28
View File
@@ -142,6 +142,7 @@ module Vamp
@clipboard_mode = "system"
@git_blame = true
@git_gutter = "bar"
@cursor = {}
@bar = BarConfig.new
@popup = PopupConfig.new
@labels = {}
@@ -231,6 +232,32 @@ module Vamp
@git_gutter = style
end
# @param options [Hash{String, Symbol => Boolean, String, Symbol}] mode_colors defaults to true; mode shapes default to block
# @return [Hash{String => Boolean, String}]
def cursor=(options)
raise TypeError, "cursor options must be a Hash" unless options.is_a?(Hash)
@cursor = options.each_with_object({}) do |(option, value), result|
option = option.to_s if option.is_a?(Symbol)
option = Vamp.string!(option, "cursor option")
raise ArgumentError, "duplicate cursor option: #{option}" if result.key?(option)
case option
when "mode_colors"
raise TypeError, "cursor mode_colors must be true or false" unless [true, false].include?(value)
when "normal", "insert", "command", "search", "visual"
value = value.to_s if value.is_a?(Symbol)
value = Vamp.string!(value, "cursor shape")
unless %w[block bar underline].include?(value)
raise ArgumentError, "cursor shape must be block, bar, or underline"
end
else
raise ArgumentError, "unknown cursor option: #{option}"
end
result[option] = value
end
end
# @param values [Hash{String, Symbol => String}]
# @return [Hash]
def labels=(values)
@@ -441,6 +468,7 @@ module Vamp
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"git_blame" => @git_blame,
"git_gutter" => @git_gutter,
"cursor" => @cursor,
"scroll_distance" => @scroll_distance,
"scroll_step_lines" => @scroll_step_lines,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
+1
View File
@@ -516,6 +516,7 @@ impl EditorSession {
}),
None => Config::default(),
};
editor.cursor_config = config.cursor;
let (pull_request_preferences_store, mut preferences, notice) = match preferences_path {
Some(path) => crate::pull_request_preferences::Store::load(path),
None => (
+79 -6
View File
@@ -14,7 +14,7 @@ use crossterm::event::{
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::{
cursor::Show,
cursor::{SetCursorStyle, Show},
event::{
self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
@@ -98,6 +98,7 @@ fn restore_terminal() {
DisableMouseCapture,
DisableFocusChange,
ColorSchemeNotifications(false),
ResetCursor,
LeaveAlternateScreen,
Show
);
@@ -169,7 +170,8 @@ impl TerminalSession {
DisableBracketedPaste,
DisableMouseCapture,
DisableFocusChange,
ColorSchemeNotifications(false)
ColorSchemeNotifications(false),
ResetCursor
)
}
}
@@ -1290,10 +1292,64 @@ fn wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct CursorAppearance {
shape: SetCursorStyle,
color: Option<[u8; 3]>,
}
fn mode_cursor_color(mode: Mode) -> [u8; 3] {
match mode {
Mode::Normal => [95, 215, 255],
Mode::Insert => [135, 215, 135],
Mode::Visual => [215, 135, 255],
Mode::Command => [255, 215, 95],
Mode::Search => [255, 175, 95],
}
}
impl CursorAppearance {
fn for_editor(editor: &Editor) -> Self {
use crate::cursor::CursorShape;
Self {
shape: match editor.cursor_config.shape(editor.mode()) {
CursorShape::Block => SetCursorStyle::SteadyBlock,
CursorShape::Bar => SetCursorStyle::SteadyBar,
CursorShape::Underline => SetCursorStyle::SteadyUnderScore,
},
color: editor
.cursor_config
.mode_colors
.then(|| mode_cursor_color(editor.mode())),
}
}
}
impl crossterm::Command for CursorAppearance {
fn write_ansi(&self, output: &mut impl std::fmt::Write) -> std::fmt::Result {
self.shape.write_ansi(output)?;
if let Some([red, green, blue]) = self.color {
write!(output, "\x1b]12;#{red:02x}{green:02x}{blue:02x}\x1b\\")
} else {
output.write_str("\x1b]112\x1b\\")
}
}
}
struct ResetCursor;
impl crossterm::Command for ResetCursor {
fn write_ansi(&self, output: &mut impl std::fmt::Write) -> std::fmt::Result {
SetCursorStyle::DefaultUserShape.write_ansi(output)?;
output.write_str("\x1b]112\x1b\\")
}
}
fn draw_frame<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
output: &mut W,
clear_screen: bool,
cursor: Option<CursorAppearance>,
render: impl FnOnce(&mut Frame),
) -> io::Result<()> {
// Do not suspend the terminal's presentation while computing the next frame.
@@ -1301,6 +1357,9 @@ fn draw_frame<W: io::Write>(
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
// Terminals without synchronized updates still hide the painting cursor.
let result = terminal.hide_cursor().and_then(|()| {
if let Some(cursor) = cursor {
crossterm::queue!(terminal.backend_mut(), cursor)?;
}
if clear_screen {
// Fresh Ratatui buffers are already empty; avoid its blocking cursor query.
crossterm::queue!(
@@ -1326,6 +1385,7 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
let mut query_theme = true;
let mut redraw = true;
let mut pending = None;
let mut cursor = None;
while !session.editor().should_quit() {
redraw |= session.poll();
colors.expire(Instant::now());
@@ -1334,9 +1394,15 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
redraw = true;
}
if redraw {
draw_frame(&mut terminal, &mut output, clear_screen, |frame| {
viewports.draw_session(frame, session)
})?;
let appearance = CursorAppearance::for_editor(session.editor());
draw_frame(
&mut terminal,
&mut output,
clear_screen,
(cursor != Some(appearance)).then_some(appearance),
|frame| viewports.draw_session(frame, session),
)?;
cursor = Some(appearance);
redraw = false;
clear_screen = false;
}
@@ -3312,7 +3378,14 @@ fn paint_editor_line(
left,
column,
column.saturating_add(1),
Style::default().fg(Color::Black).bg(Color::Magenta),
Style::default()
.fg(Color::Black)
.bg(if editor.cursor_config.mode_colors {
let [red, green, blue] = mode_cursor_color(editor.mode());
Color::Rgb(red, green, blue)
} else {
Color::Magenta
}),
);
}
}