fix: render theme-aware software cursors and mode banners
This commit is contained in:
+178
-97
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user