fix: render theme-aware software cursors and mode banners

This commit is contained in:
2026-10-26 15:52:30 +01:00
parent c6901eaaa6
commit 496c93774f
7 changed files with 407 additions and 212 deletions
+178 -97
View File
@@ -14,7 +14,7 @@ use crossterm::event::{
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::{
cursor::{SetCursorStyle, Show},
cursor::Show,
event::{
self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
@@ -28,8 +28,9 @@ use crossterm::{
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Position, Rect},
backend::{Backend, ClearType, CrosstermBackend, WindowSize},
buffer::Cell,
layout::{Constraint, Layout, Position, Rect, Size},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
@@ -98,7 +99,6 @@ fn restore_terminal() {
DisableMouseCapture,
DisableFocusChange,
ColorSchemeNotifications(false),
ResetCursor,
LeaveAlternateScreen,
Show
);
@@ -170,8 +170,7 @@ impl TerminalSession {
DisableBracketedPaste,
DisableMouseCapture,
DisableFocusChange,
ColorSchemeNotifications(false),
ResetCursor
ColorSchemeNotifications(false)
)
}
}
@@ -839,6 +838,7 @@ impl BufferViewports {
.filter_map(|(_, pane)| pane.panels.diff.as_ref()),
)
.any(|geometry| geometry.viewport.is_some());
paint_cursor(frame, editor, self.panels.cursor);
}
fn draw_resolver_history(
@@ -1292,86 +1292,149 @@ fn wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct CursorAppearance {
shape: SetCursorStyle,
color: Option<[u8; 3]>,
}
fn mode_accent(mode: Mode) -> [u8; 3] {
fn mode_accent(mode: Mode) -> Color {
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],
Mode::Normal => Color::Cyan,
Mode::Insert => Color::Green,
Mode::Visual => Color::Magenta,
Mode::Command => Color::Yellow,
Mode::Search => Color::Blue,
}
}
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_accent(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\\")
fn mode_style(mode: Mode) -> Style {
Style::default()
.fg(if mode == Mode::Search {
Color::White
} else {
output.write_str("\x1b]112\x1b\\")
}
Color::Black
})
.bg(mode_accent(mode))
}
fn paint_cursor(frame: &mut Frame, editor: &Editor, position: Option<Position>) {
use crate::cursor::CursorShape;
let Some(position) = position.filter(|&position| frame.area().contains(position)) else {
return;
};
let shape = editor.cursor_config.shape(editor.mode());
let colored = editor.cursor_config.mode_colors;
let color = if colored {
mode_accent(editor.mode())
} else {
Color::Reset
};
let visible = Modifier::HIDDEN | Modifier::DIM | Modifier::REVERSED;
let style = match shape {
CursorShape::Block if colored => mode_style(editor.mode()).remove_modifier(visible),
CursorShape::Block => Style::default()
.remove_modifier(Modifier::HIDDEN | Modifier::DIM)
.add_modifier(Modifier::REVERSED),
CursorShape::Underline => Style::default()
.fg(color)
.underline_color(color)
.remove_modifier(visible)
.add_modifier(Modifier::UNDERLINED),
CursorShape::Bar => Style::default().fg(color).remove_modifier(visible),
};
let buffer = frame.buffer_mut();
let width = buffer[position]
.symbol()
.width()
.max(1)
.min(usize::from(buffer.area.right() - position.x));
if shape == CursorShape::Bar {
// A cell-rendered bar overlays the hovered glyph; source text stays intact.
buffer[position].set_symbol("▏");
}
for offset in 0..width {
buffer[(position.x + offset as u16, position.y)].set_style(style);
}
}
struct ResetCursor;
// Keep Ratatui's logical cursor position (including IME placement), but never
// show a hardware cursor on top of the cell-rendered caret.
struct SoftwareCursorBackend<B>(B);
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\\")
impl<B: Backend> Backend for SoftwareCursorBackend<B> {
type Error = B::Error;
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.0.draw(content)
}
fn append_lines(&mut self, count: u16) -> Result<(), Self::Error> {
self.0.append_lines(count)
}
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
self.0.hide_cursor()
}
fn show_cursor(&mut self) -> Result<(), Self::Error> {
self.0.hide_cursor()
}
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
self.0.get_cursor_position()
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
self.0.set_cursor_position(position)
}
fn clear(&mut self) -> Result<(), Self::Error> {
self.0.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
self.0.clear_region(clear_type)
}
fn size(&self) -> Result<Size, Self::Error> {
self.0.size()
}
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
self.0.window_size()
}
fn flush(&mut self) -> Result<(), Self::Error> {
self.0.flush()
}
}
fn set_cursor(frame: &mut Frame, panels: &mut WheelPanels, position: Position) {
panels.cursor = Some(position);
frame.set_cursor_position(position);
}
fn draw_frame<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
terminal: &mut Terminal<SoftwareCursorBackend<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.
terminal.backend_mut().writer_mut().clear();
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
terminal.backend_mut().0.writer_mut().clear();
crossterm::queue!(terminal.backend_mut().0, 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!(
terminal.backend_mut(),
terminal.backend_mut().0,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
)?;
}
terminal.draw(render).map(|_| ())
});
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
let finish = execute!(terminal.backend_mut().0, EndSynchronizedUpdate);
result.and(finish)?;
output.write_all(terminal.backend().writer())?;
output.write_all(terminal.backend().0.writer())?;
output.flush()
}
@@ -1379,13 +1442,12 @@ fn draw_frame<W: io::Write>(
/// inside the first synchronized frame rather than exposing a blank screen.
pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> {
let mut output: Stdout = io::stdout();
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
let mut terminal = Terminal::new(SoftwareCursorBackend(CrosstermBackend::new(Vec::new())))?;
let mut viewports = BufferViewports::default();
let mut colors = TerminalColors::default();
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());
@@ -1394,15 +1456,9 @@ pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()
redraw = true;
}
if redraw {
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);
draw_frame(&mut terminal, &mut output, clear_screen, |frame| {
viewports.draw_session(frame, session)
})?;
redraw = false;
clear_screen = false;
}
@@ -1648,6 +1704,7 @@ struct ChromeItem<'a> {
config: &'a SegmentConfig,
tab: Option<usize>,
accent: bool,
mode_style: Style,
bold: bool,
first: bool,
mandatory: bool,
@@ -1772,6 +1829,7 @@ fn chrome_items<'a>(
config,
tab: Some(index),
accent: active,
mode_style: mode_style(editor.mode()),
bold: active,
first: index == 0,
mandatory: active,
@@ -1897,6 +1955,7 @@ fn chrome_items<'a>(
text,
config,
tab: None,
mode_style: mode_style(editor.mode()),
accent: !matches!(
name.as_str(),
"path"
@@ -2015,7 +2074,7 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
break;
}
let available = width - x;
let highlight = Style::default().fg(Color::Black).bg(Color::Cyan);
let highlight = item.mode_style;
let highlight = if item.bold {
highlight.add_modifier(Modifier::BOLD)
} else {
@@ -2039,7 +2098,7 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
let leading_margin = &PADDING[..item.config.margin[0]];
let trailing_margin = &PADDING[..item.config.margin[1]];
let edges = item.tab.is_none() || item.accent;
let edge = Style::default().fg(Color::Cyan);
let edge = Style::default().fg(item.mode_style.bg.unwrap_or(Color::Reset));
Line::from(vec![
Span::styled(leading_margin, Style::reset()),
Span::styled(if edges { item.leading_edge() } else { "" }, edge),
@@ -2853,7 +2912,7 @@ fn draw_document(
}
}
fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<Rect> {
fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<(Rect, Position)> {
if area.width == 0 || area.height == 0 {
return None;
}
@@ -2914,7 +2973,10 @@ fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option
content,
);
frame.set_cursor_position((content.x + (column - offset) as u16, content.y));
Some(panel)
Some((
panel,
Position::new(content.x + (column - offset) as u16, content.y),
))
}
fn draw_notification(frame: &mut Frame, editor: &Editor, area: Rect) -> Option<Rect> {
@@ -2998,7 +3060,10 @@ fn draw_overlays(
.is_some_and(|picker| picker.presentation == FilePresentation::Floating);
if !covered {
if matches!(editor.mode(), Mode::Command | Mode::Search) {
panels.input = draw_floating_input(frame, editor, area);
if let Some((panel, cursor)) = draw_floating_input(frame, editor, area) {
panels.input = Some(panel);
panels.cursor = Some(cursor);
}
} else if editor.diff().is_none_or(|diff| diff.phase == Phase::View)
&& !editor.file_picker().is_some_and(|picker| picker.focused)
{
@@ -3013,7 +3078,7 @@ fn draw_overlays(
.notification
.is_some_and(|panel| panel.contains(position))
{
frame.set_cursor_position(position);
set_cursor(frame, panels, position);
}
if let Some(completion) = editor.completion()
&& let Some((_, caret)) = caret
@@ -3052,10 +3117,11 @@ fn draw_overlays(
draw_hover(frame, hover, area, panels);
}
if let Some(diagnostic) = editor.startup_diagnostic() {
draw_startup_diagnostic(frame, diagnostic, area);
panels.cursor = draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", None);
}
if let Some(output) = editor.command_output() {
draw_diagnostic_panel(frame, &output.content, area, &output.title, Some(output));
panels.cursor =
draw_diagnostic_panel(frame, &output.content, area, &output.title, Some(output));
}
}
@@ -3378,14 +3444,11 @@ fn paint_editor_line(
left,
column,
column.saturating_add(1),
Style::default()
.fg(Color::Black)
.bg(if editor.cursor_config.mode_colors {
let [red, green, blue] = mode_accent(editor.mode());
Color::Rgb(red, green, blue)
} else {
Color::Magenta
}),
if editor.cursor_config.mode_colors {
mode_style(editor.mode())
} else {
Style::default().fg(Color::Black).bg(Color::Magenta)
},
);
}
}
@@ -3721,7 +3784,11 @@ fn draw_diff(
&& query_area.height > 0
&& editor.is_none_or(|editor| editor.render_pane_focused())
{
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
set_cursor(
frame,
panels,
Position::new(query_area.x + (query_width - offset) as u16, query_area.y),
);
}
if let Some(notice) = notice {
frame.render_widget(
@@ -4970,17 +5037,13 @@ fn floating_panel(area: Rect) -> Rect {
panel
}
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
draw_diagnostic_panel(frame, diagnostic, area, "Configuration error", None);
}
fn draw_diagnostic_panel(
frame: &mut Frame,
diagnostic: &StartupDiagnostic,
area: Rect,
title: &str,
output: Option<&crate::editor::ShellOutput>,
) {
) -> Option<Position> {
let startup = output.is_none();
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
@@ -5061,7 +5124,11 @@ fn draw_diagnostic_panel(
help_area,
);
if startup && text_area.width > 0 && text_area.height > 0 {
frame.set_cursor_position((text_area.x, text_area.y));
let position = Position::new(text_area.x, text_area.y);
frame.set_cursor_position(position);
Some(position)
} else {
None
}
}
@@ -5780,6 +5847,7 @@ impl Default for DiffColors {
struct WheelPanels {
workspace_managed: bool,
caret: Option<(Rect, Position)>,
cursor: Option<Position>,
input: Option<Rect>,
notification: Option<Rect>,
completion: Option<Rect>,
@@ -5899,6 +5967,7 @@ impl WheelPanels {
}
self.hover = None;
self.caret = None;
self.cursor = None;
self.input = None;
self.notification = None;
self.completion = None;
@@ -6434,7 +6503,11 @@ fn draw_file_list(
&& (!browsing || picker.entries().len() == 0 || results_area.height == 0)
{
let cursor_x = if browsing { 0 } else { query_width - offset };
frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y));
set_cursor(
frame,
panels,
Position::new(query_area.x + cursor_x as u16, query_area.y),
);
}
if let Some(root) = root {
frame.render_widget(root, root_area);
@@ -6565,10 +6638,14 @@ fn draw_file_list(
&& selected_index >= first
&& selected_index - first < count
{
frame.set_cursor_position((
results_area.x,
results_area.y + (selected_index - first) as u16,
));
set_cursor(
frame,
panels,
Position::new(
results_area.x,
results_area.y + (selected_index - first) as u16,
),
);
}
}
if !show_help {
@@ -6664,7 +6741,11 @@ fn draw_palette(
.style(Style::default().fg(Color::Yellow)),
query_area,
);
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
set_cursor(
frame,
panels,
Position::new(query_area.x + (query_width - offset) as u16, query_area.y),
);
if palette.entries.is_empty() {
panels.picker_scroll.palette = None;
frame.render_widget(