mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
@@ -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));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user