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
+3 -23
View File
@@ -1,26 +1,6 @@
- [x] improve: conflict resolution view. - [x] improve: editing - color the cursor to match the current mode.
- two-pane mode: - enabled by default but should be configurable.
- currently, we display the two panes as if it were a diff, where the left side will be displayed as if the code in question will be removed, and the left pane will display the code in question as if it will be added. - should also be able to configure the cursor character used based on mode (default is just the block cursor as it is now).
- this doesn't really make sense, since code on either side (either version) will be added/removed.
- i'm wondering if it would make sense to display in both panels both changes that would and changes that would add code, meaning:
- if some code would be removed, it would display in red with a minus
- if some code would be added, it would display in green with a plus
- this would be the case for both panes
- probably something similar for three-pane mode would make sense.
- [x] improve: code commenting format. example of comment in current format:
<attachment>
Comment 14 [unresolved] source/sema/resolve.hon (after) 22:9–22:19 @ working:a3b4159a340b3546e2108bc2dc0e7c302fc6d84b
[8]Builtin
can't we do `[_]Builtin` here instead for array size inference? if not, this is probably an oversight in the brolang compiler 🤔
</attachment>
- would probably be nice to instead do this though, if possible, i.e. wrapping the code snippet in question in a codeblock annotated with the right language, e.g.:
<attachment>
Comment 14 [unresolved] source/sema/resolve.hon (after) 22:9–22:19 @ working:a3b4159a340b3546e2108bc2dc0e7c302fc6d84b
```hon
[8]Builtin
```
can't we do `[_]Builtin` here instead for array size inference? if not, this is probably an oversight in the brolang compiler 🤔
</attachment>
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable. - [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission. - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+8
View File
@@ -15,6 +15,8 @@ Vamp.configure do |config|
config.clipboard_mode = "system" config.clipboard_mode = "system"
config.git_blame = true # false disables current-line attribution config.git_blame = true # false disables current-line attribution
config.git_gutter = "bar" # or "symbols", "line_numbers", "none" config.git_gutter = "bar" # or "symbols", "line_numbers", "none"
config.cursor = {} # mode colors enabled; all modes use a block cursor
# config.cursor = { mode_colors: false, insert: :bar, visual: :underline }
config.scroll_distance = 0 # nonnegative edge distance, or nil to center config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
@@ -58,6 +60,12 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors. `config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration. `config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
`config.cursor` accepts a partial hash. `mode_colors` defaults to `true`: the primary terminal cursor and secondary text carets use cyan in Normal, green in Insert, purple in Visual (including Visual Line), yellow in Command, and orange in Search. Set `config.cursor = { mode_colors: false }` to use the terminal's default cursor color; secondary carets retain their uncolored-mode magenta highlight.
The `normal`, `insert`, `visual`, `command`, and `search` keys select native terminal cursor shapes: `:block`, `:bar`, or `:underline` (strings also work). Every mode defaults to a steady block. For example, `config.cursor = { insert: :bar, visual: :underline }` changes those two shapes without disabling mode colors. Each assignment replaces the cursor configuration; omitted keys retain built-in defaults. Unknown keys, nonboolean `mode_colors`, unsupported shapes, and non-hash values reject the configuration.
Shapes apply to the primary hardware cursor, including command/search prompts and historical/diff text; secondary carets remain cell highlights. This uses terminal cursor protocols (OSC 12/112 for color and DECSCUSR for shape), not replacement text glyphs. Unsupported terminals may ignore them. Normal exit, errors, panics, and restart restore the terminal's configured cursor color and shape. No file contents or cursor positions change.
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit. `config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration. The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration.
+1
View File
@@ -80,6 +80,7 @@ The two skipped shell cleanup checks execute `/bin/ps`, which macOS refuses to l
- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes. - Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes.
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation. - Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed. - Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
- Cursor checks cover native color/shape output through Normal, Insert, Visual, Command, and Search transitions, independent color disabling and shape overrides, unchanged source text, and guard cleanup on return/error/panic. Live PTY smoke exercises defaults, Insert bar/Visual underline overrides, color-disabled shapes, prompt transitions, and exit restoration; actual appearance depends on terminal support.
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary. - Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
- Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate. - Queued-navigation checks cover counted ordered motions through short rows, Normal/Visual ranges, remapped press/repeat keys, rendered caret visibility, edit/layout/prefix and non-key barriers, and the 64-key fairness bound. Burst smoke must verify the final cursor position as well as frame count; batching reduces queued redraws without increasing the OS/terminal repeat rate.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses. - Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
+2
View File
@@ -5,6 +5,8 @@ Vamp.configure do |config|
config.leader = "space" config.leader = "space"
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
config.git_blame = true # false disables current-line inline blame config.git_blame = true # false disables current-line inline blame
config.cursor = {} # mode colors enabled; block cursor in every mode
# config.cursor = { mode_colors: false, insert: :bar, visual: :underline }
config.scroll_distance = 0 # nonnegative edge distance, or nil to center config.scroll_distance = 0 # nonnegative edge distance, or nil to center
config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count config.scroll_step_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count
config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.highlight_trailing_whitespace = false # true marks line-end whitespace in red
+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, bottom_bar: ChromeLine,
popup: PopupConfig, popup: PopupConfig,
labels: BTreeMap<String, String>, labels: BTreeMap<String, String>,
pub(crate) cursor_config: crate::cursor::CursorConfig,
scroll_distance: Option<usize>, scroll_distance: Option<usize>,
scroll_step_lines: Option<NonZeroUsize>, scroll_step_lines: Option<NonZeroUsize>,
highlight_trailing_whitespace: bool, highlight_trailing_whitespace: bool,
@@ -2353,6 +2354,7 @@ impl Editor {
bottom_bar: ChromeLine::bottom(), bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(), popup: PopupConfig::default(),
labels: BTreeMap::new(), labels: BTreeMap::new(),
cursor_config: crate::cursor::CursorConfig::default(),
scroll_distance: Some(0), scroll_distance: Some(0),
scroll_step_lines: None, scroll_step_lines: None,
highlight_trailing_whitespace: false, highlight_trailing_whitespace: false,
+1
View File
@@ -2,6 +2,7 @@ mod battery;
mod blame; mod blame;
pub mod chrome; pub mod chrome;
pub mod clipboard; pub mod clipboard;
pub(crate) mod cursor;
pub mod diff; pub mod diff;
pub mod editor; pub mod editor;
pub mod files; pub mod files;
+6
View File
@@ -43,6 +43,7 @@ pub struct Config {
pub clipboard_mode: String, pub clipboard_mode: String,
pub git_blame: bool, pub git_blame: bool,
pub git_gutter: crate::gutter::GutterStyle, pub git_gutter: crate::gutter::GutterStyle,
pub(crate) cursor: crate::cursor::CursorConfig,
pub top_bar: ChromeLine, pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine, pub bottom_bar: ChromeLine,
pub popup: PopupConfig, pub popup: PopupConfig,
@@ -101,6 +102,7 @@ impl Default for Config {
clipboard_mode: "system".into(), clipboard_mode: "system".into(),
git_blame: true, git_blame: true,
git_gutter: crate::gutter::GutterStyle::default(), git_gutter: crate::gutter::GutterStyle::default(),
cursor: crate::cursor::CursorConfig::default(),
top_bar: ChromeLine::top(), top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(), bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(), popup: PopupConfig::default(),
@@ -146,6 +148,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
clipboard_mode, clipboard_mode,
git_blame, git_blame,
git_gutter, git_gutter,
cursor,
top_bar, top_bar,
bottom_bar, bottom_bar,
popup, popup,
@@ -192,6 +195,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.clipboard_mode = clipboard_mode; config.clipboard_mode = clipboard_mode;
config.git_blame = git_blame; config.git_blame = git_blame;
config.git_gutter = git_gutter; config.git_gutter = git_gutter;
config.cursor = cursor;
config.top_bar = top_bar; config.top_bar = top_bar;
config.bottom_bar = bottom_bar; config.bottom_bar = bottom_bar;
config.popup = popup; config.popup = popup;
@@ -558,6 +562,8 @@ struct ConfigFrame {
clipboard_mode: String, clipboard_mode: String,
git_blame: bool, git_blame: bool,
git_gutter: crate::gutter::GutterStyle, git_gutter: crate::gutter::GutterStyle,
#[serde(default)]
cursor: crate::cursor::CursorConfig,
#[serde(deserialize_with = "crate::chrome::top")] #[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine, top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")] #[serde(deserialize_with = "crate::chrome::bottom")]
+28
View File
@@ -142,6 +142,7 @@ module Vamp
@clipboard_mode = "system" @clipboard_mode = "system"
@git_blame = true @git_blame = true
@git_gutter = "bar" @git_gutter = "bar"
@cursor = {}
@bar = BarConfig.new @bar = BarConfig.new
@popup = PopupConfig.new @popup = PopupConfig.new
@labels = {} @labels = {}
@@ -231,6 +232,32 @@ module Vamp
@git_gutter = style @git_gutter = style
end 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}] # @param values [Hash{String, Symbol => String}]
# @return [Hash] # @return [Hash]
def labels=(values) def labels=(values)
@@ -441,6 +468,7 @@ module Vamp
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode, "file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"git_blame" => @git_blame, "git_blame" => @git_blame,
"git_gutter" => @git_gutter, "git_gutter" => @git_gutter,
"cursor" => @cursor,
"scroll_distance" => @scroll_distance, "scroll_distance" => @scroll_distance,
"scroll_step_lines" => @scroll_step_lines, "scroll_step_lines" => @scroll_step_lines,
"highlight_trailing_whitespace" => @highlight_trailing_whitespace, "highlight_trailing_whitespace" => @highlight_trailing_whitespace,
+1
View File
@@ -516,6 +516,7 @@ impl EditorSession {
}), }),
None => Config::default(), None => Config::default(),
}; };
editor.cursor_config = config.cursor;
let (pull_request_preferences_store, mut preferences, notice) = match preferences_path { let (pull_request_preferences_store, mut preferences, notice) = match preferences_path {
Some(path) => crate::pull_request_preferences::Store::load(path), Some(path) => crate::pull_request_preferences::Store::load(path),
None => ( None => (
+79 -6
View File
@@ -14,7 +14,7 @@ use crossterm::event::{
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
}; };
use crossterm::{ use crossterm::{
cursor::Show, cursor::{SetCursorStyle, Show},
event::{ event::{
self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
@@ -98,6 +98,7 @@ fn restore_terminal() {
DisableMouseCapture, DisableMouseCapture,
DisableFocusChange, DisableFocusChange,
ColorSchemeNotifications(false), ColorSchemeNotifications(false),
ResetCursor,
LeaveAlternateScreen, LeaveAlternateScreen,
Show Show
); );
@@ -169,7 +170,8 @@ impl TerminalSession {
DisableBracketedPaste, DisableBracketedPaste,
DisableMouseCapture, DisableMouseCapture,
DisableFocusChange, 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>( fn draw_frame<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>, terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
output: &mut W, output: &mut W,
clear_screen: bool, clear_screen: bool,
cursor: Option<CursorAppearance>,
render: impl FnOnce(&mut Frame), render: impl FnOnce(&mut Frame),
) -> io::Result<()> { ) -> io::Result<()> {
// Do not suspend the terminal's presentation while computing the next frame. // 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)?; crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
// Terminals without synchronized updates still hide the painting cursor. // Terminals without synchronized updates still hide the painting cursor.
let result = terminal.hide_cursor().and_then(|()| { let result = terminal.hide_cursor().and_then(|()| {
if let Some(cursor) = cursor {
crossterm::queue!(terminal.backend_mut(), cursor)?;
}
if clear_screen { if clear_screen {
// Fresh Ratatui buffers are already empty; avoid its blocking cursor query. // Fresh Ratatui buffers are already empty; avoid its blocking cursor query.
crossterm::queue!( 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 query_theme = true;
let mut redraw = true; let mut redraw = true;
let mut pending = None; let mut pending = None;
let mut cursor = None;
while !session.editor().should_quit() { while !session.editor().should_quit() {
redraw |= session.poll(); redraw |= session.poll();
colors.expire(Instant::now()); colors.expire(Instant::now());
@@ -1334,9 +1394,15 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
redraw = true; redraw = true;
} }
if redraw { if redraw {
draw_frame(&mut terminal, &mut output, clear_screen, |frame| { let appearance = CursorAppearance::for_editor(session.editor());
viewports.draw_session(frame, session) 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; redraw = false;
clear_screen = false; clear_screen = false;
} }
@@ -3312,7 +3378,14 @@ fn paint_editor_line(
left, left,
column, column,
column.saturating_add(1), 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
}),
); );
} }
} }
+63
View File
@@ -653,6 +653,69 @@ fn git_gutter_config_modes_default_and_rejection() {
} }
} }
#[test]
fn cursor_config_partial_shapes_and_invalid_options() {
use crate::cursor::CursorShape::{Bar, Block, Underline};
let fixture = Fixture::new();
for (options, mode_colors, shapes) in [
(
r#"{ insert: :bar, "visual" => "underline" }"#,
true,
[Block, Bar, Block, Block, Underline],
),
(
r#"{ mode_colors: false, normal: "bar", insert: :underline, command: :bar, search: "underline", visual: :block }"#,
false,
[Bar, Underline, Bar, Underline, Block],
),
(
r#"{ "mode_colors" => true, normal: :underline, insert: "block", command: "underline", search: :bar, visual: "bar" }"#,
true,
[Underline, Block, Underline, Bar, Bar],
),
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.cursor = {options} }}"),
);
let cursor = load_config(&path).unwrap().cursor;
assert_eq!(cursor.mode_colors, mode_colors);
assert_eq!(
[
cursor.normal,
cursor.insert,
cursor.command,
cursor.search,
cursor.visual
],
shapes
);
}
for options in [
"nil",
"false",
"[]",
"{ mode_colors: nil }",
"{ mode_colors: 1 }",
r#"{ mode_colors: "true" }"#,
"{ insert: nil }",
"{ insert: false }",
"{ insert: 1 }",
"{ insert: :beam }",
r#"{ normal: "" }"#,
"{ unknown: :block }",
"{ 1 => :block }",
r#"{ insert: :bar, "insert" => :block }"#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.cursor = {options} }}"),
);
assert!(load_config(&path).is_err(), "{options}");
}
}
#[test] #[test]
fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() { fn git_blame_boolean_config_typed_toggle_palette_and_wire_validation() {
let fixture = Fixture::new(); let fixture = Fixture::new();
+116 -22
View File
@@ -10908,6 +10908,7 @@ fn search_visual_selection_and_secondary_carets_override_semantic_foreground() {
editor.handle(Input::Char('e')); editor.handle(Input::Char('e'));
editor.handle(Input::Ctrl('a')); editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 3); assert_eq!(editor.selection_count(), 3);
editor.cursor_config.mode_colors = false;
terminal terminal
.draw(|frame| viewports.draw(frame, &mut editor)) .draw(|frame| viewports.draw(frame, &mut editor))
.unwrap(); .unwrap();
@@ -14348,6 +14349,7 @@ fn status_identifies_active_buffer_and_restored_visual_selection_count() {
let b = directory.file("b", "other"); let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap(); let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None); editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.cursor_config.mode_colors = false;
let a_id = editor.buffer_id(); let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap();
let mut viewports = BufferViewports::default(); let mut viewports = BufferViewports::default();
@@ -15160,6 +15162,7 @@ fn tabs_and_wide_graphemes_scroll_in_terminal_cells() {
fn selection_set_renders_all_ranges_and_counts_offscreen_targets() { fn selection_set_renders_all_ranges_and_counts_offscreen_targets() {
let mut editor = Editor::open(None, vec![]).unwrap(); let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_gutter_style(crate::gutter::GutterStyle::None); editor.set_gutter_style(crate::gutter::GutterStyle::None);
editor.cursor_config.mode_colors = false;
editor editor
.apply(vec![crate::editor::Effect::Replace( .apply(vec![crate::editor::Effect::Replace(
"界\n界\n界\n界\n界".into(), "界\n界\n界\n界\n界".into(),
@@ -15214,25 +15217,28 @@ fn secondary_carets_render_on_empty_lines_and_at_line_end() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap();
let (mut top, mut left) = (0, 0); let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new(); let mut syntax = SyntaxCache::new();
terminal for (enabled, expected) in [(true, Color::Rgb(135, 215, 135)), (false, Color::Magenta)] {
.draw(|frame| { editor.cursor_config.mode_colors = enabled;
draw( terminal
frame, .draw(|frame| {
&editor, draw(
&mut top, frame,
&mut left, &editor,
&mut syntax, &mut top,
&mut DocumentViewport::default(), &mut left,
&mut WheelPanels::default(), &mut syntax,
) &mut DocumentViewport::default(),
}) &mut WheelPanels::default(),
.unwrap(); )
let buffer = terminal.backend().buffer(); })
for caret in editor.carets() { .unwrap();
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, caret.row as u16)].symbol(), " "); for caret in editor.carets() {
if caret != editor.cursor() { let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); assert_eq!(buffer[(x, caret.row as u16)].symbol(), " ");
if caret != editor.cursor() {
assert_eq!(buffer[(x, caret.row as u16)].bg, expected);
}
} }
} }
assert_eq!( assert_eq!(
@@ -15590,6 +15596,87 @@ fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll()
assert!(content.contains(terminal.get_cursor_position().unwrap())); assert!(content.contains(terminal.get_cursor_position().unwrap()));
} }
#[test]
fn cursor_appearance_tracks_editing_and_prompt_modes_without_replacing_text() {
use crate::cursor::CursorShape;
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace("hello\n".into())])
.unwrap();
editor.cursor_config.insert = CursorShape::Bar;
editor.cursor_config.visual = CursorShape::Underline;
let mut terminal = Terminal::with_options(
CrosstermBackend::new(Vec::new()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 60, 12)),
},
)
.unwrap();
let mut views = BufferViewports::default();
for (inputs, mode, shape, color) in [
(vec![], Mode::Normal, "\x1b[2 q", "#5fd7ff"),
(vec![Input::Char('i')], Mode::Insert, "\x1b[6 q", "#87d787"),
(
vec![Input::Escape, Input::Char('v')],
Mode::Visual,
"\x1b[4 q",
"#d787ff",
),
(
vec![Input::Escape, Input::Char(':')],
Mode::Command,
"\x1b[2 q",
"#ffd75f",
),
(
vec![Input::Escape, Input::Char('/')],
Mode::Search,
"\x1b[2 q",
"#ffaf5f",
),
(vec![Input::Escape], Mode::Normal, "\x1b[2 q", "#5fd7ff"),
] {
for input in inputs {
editor.handle(input);
}
assert_eq!(editor.mode(), mode);
let appearance = CursorAppearance::for_editor(&editor);
let mut output = Vec::new();
draw_frame(
&mut terminal,
&mut output,
false,
Some(appearance),
|frame| views.draw(frame, &mut editor),
)
.unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains(shape), "{mode}: {output:?}");
assert!(
output.contains(&format!("\x1b]12;{color}\x1b\\")),
"{mode}: {output:?}"
);
assert!(output.find(shape).unwrap() < output.find("\x1b[?25h").unwrap());
assert_eq!(editor.lines(), &["hello", ""]);
}
editor.cursor_config.mode_colors = false;
editor.handle(Input::Char('i'));
let mut output = Vec::new();
draw_frame(
&mut terminal,
&mut output,
false,
Some(CursorAppearance::for_editor(&editor)),
|frame| views.draw(frame, &mut editor),
)
.unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("\x1b[6 q"));
assert!(output.contains("\x1b]112\x1b\\"));
assert!(!output.contains("\x1b]12;"));
}
#[test] #[test]
fn terminal_can_present_previous_frame_while_next_frame_is_computed() { fn terminal_can_present_previous_frame_while_next_frame_is_computed() {
use std::{cell::Cell, rc::Rc}; use std::{cell::Cell, rc::Rc};
@@ -15628,7 +15715,7 @@ fn terminal_can_present_previous_frame_while_next_frame_is_computed() {
) )
.unwrap(); .unwrap();
for text in ["first frame", "second frame", "third frame"] { for text in ["first frame", "second frame", "third frame"] {
draw_frame(&mut terminal, &mut output, false, |frame| { draw_frame(&mut terminal, &mut output, false, None, |frame| {
assert!( assert!(
!synchronized.get(), !synchronized.get(),
"terminal presentation suspended during frame computation" "terminal presentation suspended during frame computation"
@@ -15659,7 +15746,7 @@ fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updat
let mut viewports = BufferViewports::default(); let mut viewports = BufferViewports::default();
for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) {
editor.handle(Input::Char(key)); editor.handle(Input::Char(key));
draw_frame(&mut terminal, &mut output, false, |frame| { draw_frame(&mut terminal, &mut output, false, None, |frame| {
viewports.draw(frame, &mut editor) viewports.draw(frame, &mut editor)
}) })
.unwrap(); .unwrap();
@@ -15739,7 +15826,7 @@ fn terminal_session_scopes_the_callers_panic_hook() {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
}, },
)?; )?;
draw_frame(&mut terminal, &mut io::stdout(), false, |_| { draw_frame(&mut terminal, &mut io::stdout(), false, None, |_| {
panic::panic_any(73_u64) panic::panic_any(73_u64)
}) })
} }
@@ -15866,6 +15953,13 @@ fn terminal_session_scopes_the_callers_panic_hook() {
"{mode}: {before:?}" "{mode}: {before:?}"
); );
} }
for sequence in ["\x1b[0 q", "\x1b]112\x1b\\"] {
assert_eq!(
before.matches(sequence).count(),
entered,
"{mode}: {before:?}"
);
}
for protocol in [1000, 1002, 1003, 1015, 1006] { for protocol in [1000, 1002, 1003, 1015, 1006] {
assert_eq!( assert_eq!(
before.matches(&format!("\x1b[?{protocol}h")).count(), before.matches(&format!("\x1b[?{protocol}h")).count(),