feat(tui): show bar cursor in vim insert mode

Thread cursor style through the custom terminal and render tree so the chat composer can request an insert-like cursor while Vim mode is in Insert.

Restore the terminal default cursor style on Normal/non-Vim frames and TUI teardown, and cover the escape sequences plus composer mode transitions.
This commit is contained in:
Felipe Coury
2026-04-23 10:35:33 -03:00
parent 704225a300
commit ebc6522276
10 changed files with 382 additions and 85 deletions
+1
View File
@@ -1136,6 +1136,7 @@ Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.t
|frame| {
self.chat_widget.render(frame.area(), frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
frame.set_cursor_style(self.chat_widget.cursor_style(frame.area()));
frame.set_cursor_position((x, y));
}
},
+48 -6
View File
@@ -965,10 +965,6 @@ impl ChatComposer {
enabled
}
pub(crate) fn is_vim_insert(&self) -> bool {
self.textarea.is_vim_insert()
}
#[cfg(test)]
pub(crate) fn is_vim_enabled(&self) -> bool {
self.textarea.is_vim_enabled()
@@ -3295,8 +3291,6 @@ impl ChatComposer {
quit_shortcut_key: self.quit_shortcut_key,
collaboration_modes_enabled: self.collaboration_modes_enabled,
is_wsl,
context_window_percent: self.context_window_percent,
context_window_used_tokens: self.context_window_used_tokens,
status_line_value: self.status_line_value.clone(),
status_line_enabled: self.status_line_enabled,
active_agent_label: self.active_agent_label.clone(),
@@ -3941,6 +3935,14 @@ impl Renderable for ChatComposer {
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle {
if self.textarea.uses_vim_insert_cursor() {
crossterm::cursor::SetCursorStyle::SteadyBar
} else {
crossterm::cursor::SetCursorStyle::DefaultUserShape
}
}
fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -5226,6 +5228,46 @@ mod tests {
assert_eq!(composer.textarea.cursor(), "he".len());
}
#[test]
fn vim_insert_uses_bar_cursor_style() {
use crate::render::renderable::Renderable;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crossterm::queue;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
let area = Rect::new(0, 0, 80, 10);
let style_output = |style| {
let mut output = Vec::new();
queue!(output, style).expect("queue cursor style");
output
};
let default = style_output(SetCursorStyle::DefaultUserShape);
let steady_bar = style_output(SetCursorStyle::SteadyBar);
assert_eq!(style_output(composer.cursor_style(area)), default,);
composer.set_vim_enabled(true);
assert_eq!(style_output(composer.cursor_style(area)), default,);
composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
composer.set_text_content("hey".to_string(), Vec::new(), Vec::new());
assert_eq!(style_output(composer.cursor_style(area)), steady_bar);
composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(style_output(composer.cursor_style(area)), default,);
}
#[test]
fn clear_for_ctrl_c_preserves_image_draft_state() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
+68 -63
View File
@@ -74,8 +74,6 @@ pub(crate) struct FooterProps {
///
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
pub(crate) status_line_value: Option<Line<'static>>,
pub(crate) status_line_enabled: bool,
/// Active thread label shown when the footer is rendering contextual information instead of an
@@ -1131,11 +1129,27 @@ mod tests {
);
}
fn snapshot_footer_with_context(
name: &str,
props: FooterProps,
percent: Option<i64>,
used_tokens: Option<i64>,
) {
snapshot_footer_with_mode_indicator_and_context(
name,
/*width*/ 80,
&props,
/*collaboration_mode_indicator*/ None,
context_window_line(percent, used_tokens),
);
}
fn draw_footer_frame<B: Backend>(
terminal: &mut Terminal<B>,
height: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
context_line: Line<'static>,
) {
terminal
.draw(|f| {
@@ -1208,10 +1222,7 @@ mod tests {
compact
}
} else {
Some(context_window_line(
props.context_window_percent,
props.context_window_used_tokens,
))
Some(context_line.clone())
};
let right_width = right_line
.as_ref()
@@ -1305,20 +1316,49 @@ mod tests {
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
) {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator);
assert_snapshot!(name, terminal.backend());
snapshot_footer_with_mode_indicator_and_context(
name,
width,
props,
collaboration_mode_indicator,
context_window_line(/*percent*/ None, /*used_tokens*/ None),
);
}
fn render_footer_with_mode_indicator(
fn snapshot_footer_with_mode_indicator_and_context(
name: &str,
width: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
context_line: Line<'static>,
) {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
draw_footer_frame(
&mut terminal,
height,
props,
collaboration_mode_indicator,
context_line,
);
assert_snapshot!(name, terminal.backend());
}
fn render_footer_with_mode_indicator_and_context(
width: u16,
props: &FooterProps,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
context_line: Line<'static>,
) -> String {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal");
draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator);
draw_footer_frame(
&mut terminal,
height,
props,
collaboration_mode_indicator,
context_line,
);
terminal.backend().vt100().screen().contents()
}
@@ -1334,8 +1374,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1352,8 +1390,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1370,8 +1406,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1388,8 +1422,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1406,8 +1438,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1424,8 +1454,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1442,15 +1470,13 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
},
);
snapshot_footer(
snapshot_footer_with_context(
"footer_shortcuts_context_running",
FooterProps {
mode: FooterMode::ComposerEmpty,
@@ -1460,15 +1486,15 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(72),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
},
Some(72),
/*used_tokens*/ None,
);
snapshot_footer(
snapshot_footer_with_context(
"footer_context_tokens_used",
FooterProps {
mode: FooterMode::ComposerEmpty,
@@ -1478,12 +1504,12 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: Some(123_456),
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
},
/*percent*/ None,
Some(123_456),
);
snapshot_footer(
@@ -1496,8 +1522,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1512,8 +1536,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1541,8 +1563,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
@@ -1563,8 +1583,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
active_agent_label: None,
@@ -1580,8 +1598,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
active_agent_label: None,
@@ -1597,8 +1613,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
active_agent_label: None,
@@ -1614,18 +1628,17 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None, // command timed out / empty
status_line_enabled: true,
active_agent_label: None,
};
snapshot_footer_with_mode_indicator(
snapshot_footer_with_mode_indicator_and_context(
"footer_status_line_enabled_mode_right",
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
context_window_line(Some(50), /*used_tokens*/ None),
);
let props = FooterProps {
@@ -1636,18 +1649,17 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: None,
};
snapshot_footer_with_mode_indicator(
snapshot_footer_with_mode_indicator_and_context(
"footer_status_line_disabled_context_right",
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
context_window_line(Some(50), /*used_tokens*/ None),
);
let props = FooterProps {
@@ -1658,19 +1670,18 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: true,
active_agent_label: None,
};
// has status line and no collaboration mode
snapshot_footer_with_mode_indicator(
snapshot_footer_with_mode_indicator_and_context(
"footer_status_line_enabled_no_mode_right",
/*width*/ 120,
&props,
/*collaboration_mode_indicator*/ None,
context_window_line(Some(50), /*used_tokens*/ None),
);
let props = FooterProps {
@@ -1681,8 +1692,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: Some(Line::from(
"Status line content that should truncate before the mode indicator".to_string(),
)),
@@ -1690,11 +1699,12 @@ mod tests {
active_agent_label: None,
};
snapshot_footer_with_mode_indicator(
snapshot_footer_with_mode_indicator_and_context(
"footer_status_line_truncated_with_gap",
/*width*/ 40,
&props,
Some(CollaborationModeIndicator::Plan),
context_window_line(Some(50), /*used_tokens*/ None),
);
let props = FooterProps {
@@ -1705,8 +1715,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: None,
status_line_enabled: false,
active_agent_label: Some("Robie [explorer]".to_string()),
@@ -1722,8 +1730,6 @@ mod tests {
collaboration_modes_enabled: false,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_enabled: true,
active_agent_label: Some("Robie [explorer]".to_string()),
@@ -1742,8 +1748,6 @@ mod tests {
collaboration_modes_enabled: true,
is_wsl: false,
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: Some(Line::from(
"Status line content that is definitely too long to fit alongside the mode label"
.to_string(),
@@ -1752,10 +1756,11 @@ mod tests {
active_agent_label: None,
};
let screen = render_footer_with_mode_indicator(
let screen = render_footer_with_mode_indicator_and_context(
/*width*/ 80,
&props,
Some(CollaborationModeIndicator::Plan),
context_window_line(Some(50), /*used_tokens*/ None),
);
let collapsed = screen.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
+4 -5
View File
@@ -958,11 +958,6 @@ impl BottomPane {
self.composer.is_empty()
}
#[cfg(test)]
pub(crate) fn composer_is_vim_insert(&self) -> bool {
self.composer.is_vim_insert()
}
#[cfg(test)]
pub(crate) fn composer_is_vim_enabled(&self) -> bool {
self.composer.is_vim_enabled()
@@ -1377,6 +1372,10 @@ impl Renderable for BottomPane {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_renderable().cursor_pos(area)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.as_renderable().cursor_style(area)
}
}
#[cfg(test)]
+8 -8
View File
@@ -237,7 +237,7 @@ impl TextArea {
!self.vim_enabled || self.vim_mode == VimMode::Insert
}
pub(crate) fn is_vim_insert(&self) -> bool {
pub(crate) fn uses_vim_insert_cursor(&self) -> bool {
self.vim_enabled && self.vim_mode == VimMode::Insert
}
@@ -1987,7 +1987,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(t.text(), "h");
assert!(!t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Normal"));
assert_eq!(t.cursor(), 0);
}
@@ -1999,7 +1999,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Normal"));
assert_eq!(t.cursor(), 0);
}
@@ -2012,7 +2012,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Normal"));
assert_eq!(t.cursor(), "👍".len());
}
@@ -2026,7 +2026,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Normal"));
assert_eq!(t.cursor(), 1);
}
@@ -2039,7 +2039,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::NONE));
assert!(t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), 6);
}
@@ -2052,7 +2052,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
assert!(t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), 11);
}
@@ -2066,7 +2066,7 @@ mod tests {
t.input(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE));
assert_eq!(t.text(), "hello\n\nworld");
assert!(t.is_vim_insert());
assert_eq!(t.vim_mode_label(), Some("Insert"));
assert_eq!(t.cursor(), 6);
}
+4
View File
@@ -11884,6 +11884,10 @@ impl Renderable for ChatWidget {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_renderable().cursor_pos(area)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.as_renderable().cursor_style(area)
}
}
#[derive(Debug)]
@@ -1369,7 +1369,10 @@ async fn vim_mode_default_enabled_starts_composer_in_normal_mode() {
.await;
assert!(chat.bottom_pane.composer_is_vim_enabled());
assert!(!chat.bottom_pane.composer_is_vim_insert());
assert!(chat.composer_is_empty());
let mut chat = chat;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "");
}
async fn make_startup_chat_with_cli_overrides(
+167 -1
View File
@@ -25,6 +25,7 @@ use std::io;
use std::io::Write;
use crossterm::cursor::MoveTo;
use crossterm::cursor::SetCursorStyle;
use crossterm::queue;
use crossterm::style::Colors;
use crossterm::style::Print;
@@ -78,7 +79,6 @@ fn display_width(s: &str) -> usize {
visible.width()
}
#[derive(Debug, Hash)]
pub struct Frame<'a> {
/// Where should the cursor be after drawing this frame?
///
@@ -86,6 +86,9 @@ pub struct Frame<'a> {
/// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`.
pub(crate) cursor_position: Option<Position>,
/// Visible cursor shape to apply after drawing this frame.
cursor_style: SetCursorStyle,
/// The area of the viewport
pub(crate) viewport_area: Rect,
@@ -128,6 +131,11 @@ impl Frame<'_> {
self.cursor_position = Some(position.into());
}
/// After drawing this frame, set the terminal's visible cursor style.
pub fn set_cursor_style(&mut self, style: SetCursorStyle) {
self.cursor_style = style;
}
/// Gets the buffer that this `Frame` draws into as a mutable reference.
pub fn buffer_mut(&mut self) -> &mut Buffer {
self.buffer
@@ -167,6 +175,10 @@ where
#[allow(clippy::print_stderr)]
fn drop(&mut self) {
// Attempt to restore the cursor state
if let Err(err) = self.reset_cursor_style() {
eprintln!("Failed to reset the cursor style: {err}");
}
if self.hidden_cursor
&& let Err(err) = self.show_cursor()
{
@@ -205,6 +217,7 @@ where
pub fn get_frame(&mut self) -> Frame<'_> {
Frame {
cursor_position: None,
cursor_style: SetCursorStyle::DefaultUserShape,
viewport_area: self.viewport_area,
buffer: self.current_buffer_mut(),
}
@@ -362,6 +375,7 @@ where
// stdout first. But we also can't keep the frame around, since it holds a &mut to
// Buffer. Thus, we're taking the important data out of the Frame and dropping it.
let cursor_position = frame.cursor_position;
let cursor_style = frame.cursor_style;
// Draw to stdout
self.flush()?;
@@ -369,6 +383,7 @@ where
match cursor_position {
None => self.hide_cursor()?,
Some(position) => {
self.set_cursor_style(cursor_style)?;
self.show_cursor()?;
self.set_cursor_position(position)?;
}
@@ -395,6 +410,16 @@ where
Ok(())
}
/// Sets the visible terminal cursor style.
pub fn set_cursor_style(&mut self, style: SetCursorStyle) -> io::Result<()> {
queue!(self.backend, style)
}
/// Restores the user-configured terminal cursor style.
pub fn reset_cursor_style(&mut self) -> io::Result<()> {
self.set_cursor_style(SetCursorStyle::DefaultUserShape)
}
/// Gets the current cursor position.
///
/// This is the position of the cursor after the last draw call.
@@ -708,9 +733,110 @@ impl ModifierDiff {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::backend::WindowSize;
use ratatui::layout::Rect;
use ratatui::style::Style;
struct CaptureBackend {
output: Vec<u8>,
size: Size,
cursor: Position,
}
impl CaptureBackend {
fn new(width: u16, height: u16) -> Self {
Self {
output: Vec::new(),
size: Size { width, height },
cursor: Position { x: 0, y: 0 },
}
}
fn output(&self) -> String {
String::from_utf8_lossy(&self.output).into_owned()
}
}
impl Write for CaptureBackend {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.output.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Backend for CaptureBackend {
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(self.cursor)
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
self.cursor = position.into();
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
Ok(())
}
fn clear_region(&mut self, _clear_type: ClearType) -> io::Result<()> {
Ok(())
}
fn append_lines(&mut self, _line_count: u16) -> io::Result<()> {
Ok(())
}
fn scroll_region_up(
&mut self,
_region: std::ops::Range<u16>,
_scroll_by: u16,
) -> io::Result<()> {
Ok(())
}
fn scroll_region_down(
&mut self,
_region: std::ops::Range<u16>,
_scroll_by: u16,
) -> io::Result<()> {
Ok(())
}
fn size(&self) -> io::Result<Size> {
Ok(self.size)
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: self.size,
pixels: self.size,
})
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);
@@ -756,4 +882,44 @@ mod tests {
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
);
}
#[test]
fn terminal_draw_applies_requested_cursor_style() {
let mut output = Vec::new();
let mut terminal = Terminal::with_options(CaptureBackend::new(2, 1)).expect("terminal");
terminal.set_viewport_area(Rect::new(0, 0, 2, 1));
terminal
.try_draw(|frame| {
frame.set_cursor_style(SetCursorStyle::SteadyBar);
frame.set_cursor_position((0, 0));
io::Result::Ok(())
})
.expect("draw");
queue!(output, SetCursorStyle::SteadyBar).expect("queue style");
let expected = String::from_utf8(output).expect("utf8");
let actual = terminal.backend().output();
assert!(
actual.contains(&expected),
"expected terminal output to contain cursor style {expected:?}, got {actual:?}"
);
}
#[test]
fn reset_cursor_style_emits_default_user_shape() {
let mut output = Vec::new();
let mut terminal = Terminal::with_options(CaptureBackend::new(2, 1)).expect("terminal");
terminal.reset_cursor_style().expect("reset cursor style");
ratatui::backend::Backend::flush(terminal.backend_mut()).expect("flush backend");
queue!(output, SetCursorStyle::DefaultUserShape).expect("queue style");
let expected = String::from_utf8(output).expect("utf8");
let actual = terminal.backend().output();
assert!(
actual.contains(&expected),
"expected terminal output to contain cursor style reset {expected:?}, got {actual:?}"
);
}
}
+72
View File
@@ -1,5 +1,6 @@
use std::sync::Arc;
use crossterm::cursor::SetCursorStyle;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
@@ -16,6 +17,9 @@ pub trait Renderable {
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
fn cursor_style(&self, _area: Rect) -> SetCursorStyle {
SetCursorStyle::DefaultUserShape
}
}
pub enum RenderableItem<'a> {
@@ -44,6 +48,13 @@ impl<'a> Renderable for RenderableItem<'a> {
RenderableItem::Borrowed(child) => child.cursor_pos(area),
}
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
match self {
RenderableItem::Owned(child) => child.cursor_style(area),
RenderableItem::Borrowed(child) => child.cursor_style(area),
}
}
}
impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
@@ -127,6 +138,18 @@ impl<R: Renderable> Renderable for Option<R> {
0
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_ref()
.and_then(|renderable| renderable.cursor_pos(area))
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.as_ref()
.map_or(SetCursorStyle::DefaultUserShape, |renderable| {
renderable.cursor_style(area)
})
}
}
impl<R: Renderable> Renderable for Arc<R> {
@@ -136,6 +159,12 @@ impl<R: Renderable> Renderable for Arc<R> {
fn desired_height(&self, width: u16) -> u16 {
self.as_ref().desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_ref().cursor_pos(area)
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.as_ref().cursor_style(area)
}
}
pub struct ColumnRenderable<'a> {
@@ -180,6 +209,19 @@ impl Renderable for ColumnRenderable<'_> {
}
None
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
let mut y = area.y;
for child in &self.children {
let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width))
.intersection(area);
if !child_area.is_empty() && child.cursor_pos(child_area).is_some() {
return child.cursor_style(child_area);
}
y += child_area.height;
}
SetCursorStyle::DefaultUserShape
}
}
impl<'a> ColumnRenderable<'a> {
@@ -304,6 +346,19 @@ impl<'a> Renderable for FlexRenderable<'a> {
.zip(self.children.iter())
.find_map(|(rect, child)| child.child.cursor_pos(rect))
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.allocate(area)
.into_iter()
.zip(self.children.iter())
.find_map(|(rect, child)| {
child
.child
.cursor_pos(rect)
.map(|_| child.child.cursor_style(rect))
})
.unwrap_or(SetCursorStyle::DefaultUserShape)
}
}
pub struct RowRenderable<'a> {
@@ -354,6 +409,19 @@ impl Renderable for RowRenderable<'_> {
}
None
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
let mut x = area.x;
for (width, child) in &self.children {
let available_width = area.width.saturating_sub(x - area.x);
let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height);
if !child_area.is_empty() && child.cursor_pos(child_area).is_some() {
return child.cursor_style(child_area);
}
x = x.saturating_add(*width);
}
SetCursorStyle::DefaultUserShape
}
}
impl<'a> RowRenderable<'a> {
@@ -385,6 +453,10 @@ impl<'a> Renderable for InsetRenderable<'a> {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.child.cursor_pos(area.inset(self.insets))
}
fn cursor_style(&self, area: Rect) -> SetCursorStyle {
self.child.cursor_style(area.inset(self.insets))
}
}
impl<'a> InsetRenderable<'a> {
+6 -1
View File
@@ -14,6 +14,7 @@ use std::time::Duration;
use crossterm::Command;
use crossterm::SynchronizedUpdate;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableFocusChange;
use crossterm::event::EnableBracketedPaste;
@@ -342,7 +343,11 @@ fn restore_common(should_disable_raw_mode: bool) -> Result<()> {
if should_disable_raw_mode {
disable_raw_mode()?;
}
let _ = execute!(stdout(), crossterm::cursor::Show);
let _ = execute!(
stdout(),
SetCursorStyle::DefaultUserShape,
crossterm::cursor::Show
);
Ok(())
}