fix(statusline): keep picker selection visible

Keep the /statusline picker selection visible when navigating through all
  available footer items in smaller terminal windows.

  The picker now scrolls its visible rows as the cursor moves, wraps Up/Down at
  the list edges, and renders the selected row with a continuous, slightly
  brighter background.
This commit is contained in:
reidliu41
2026-05-28 23:06:04 +08:00
committed by Hunter Bown
parent e2d6d2253a
commit c72dc28b38
+81 -19
View File
@@ -19,8 +19,12 @@ use ratatui::{
};
use crate::config::StatusItem;
use crate::localization::truncate_to_width;
use crate::palette;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
use unicode_width::UnicodeWidthStr;
const STATUS_PICKER_SELECTION_BG: ratatui::style::Color = ratatui::style::Color::Rgb(54, 72, 104);
/// Picker state. We hold both the user's working selection AND the original
/// snapshot so Esc can perfectly revert the live preview.
@@ -62,16 +66,21 @@ impl StatusPickerView {
}
fn move_up(&mut self) {
if self.cursor > 0 {
if self.rows.is_empty() {
return;
}
if self.cursor == 0 {
self.cursor = self.rows.len() - 1;
} else {
self.cursor -= 1;
}
}
fn move_down(&mut self) {
let max = self.rows.len().saturating_sub(1);
if self.cursor < max {
self.cursor += 1;
if self.rows.is_empty() {
return;
}
self.cursor = (self.cursor + 1) % self.rows.len();
}
fn toggle_current(&mut self) {
@@ -201,7 +210,16 @@ impl ModalView for StatusPickerView {
)));
lines.push(Line::from(""));
for (idx, item) in self.rows.iter().enumerate() {
let visible_rows = inner.height.saturating_sub(2) as usize;
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
for (idx, item) in self
.rows
.iter()
.enumerate()
.skip(row_start)
.take(visible_rows)
{
let checked = *self.selected.get(idx).unwrap_or(&false);
let is_cursor = idx == self.cursor;
let mark = if checked { "[✓]" } else { "[ ]" };
@@ -225,20 +243,50 @@ impl ModalView for StatusPickerView {
};
let pointer = if is_cursor { "" } else { " " };
lines.push(Line::from(vec![
Span::styled(format!(" {pointer} "), row_style),
Span::styled(mark.to_string(), row_style),
Span::raw(" "),
Span::styled(item.label().to_string(), row_style),
Span::raw(" "),
Span::styled(format!("({})", item.hint()), hint_style),
]));
if is_cursor {
let selected_style = Style::default()
.fg(palette::SELECTION_TEXT)
.bg(STATUS_PICKER_SELECTION_BG)
.add_modifier(Modifier::BOLD);
let line = status_row_text(pointer, mark, item, inner.width as usize);
lines.push(Line::from(Span::styled(line, selected_style)));
} else {
lines.push(Line::from(vec![
Span::styled(format!(" {pointer} "), row_style),
Span::styled(mark.to_string(), row_style),
Span::styled(" ", row_style),
Span::styled(item.label().to_string(), row_style),
Span::styled(" ", row_style),
Span::styled(format!("({})", item.hint()), hint_style),
]));
}
}
Paragraph::new(lines).render(inner, buf);
}
}
fn visible_row_start(total_rows: usize, cursor: usize, visible_rows: usize) -> usize {
if total_rows == 0 || visible_rows == 0 || total_rows <= visible_rows {
return 0;
}
let max_start = total_rows - visible_rows;
cursor
.saturating_add(1)
.saturating_sub(visible_rows)
.min(max_start)
}
fn status_row_text(pointer: &str, mark: &str, item: &StatusItem, width: usize) -> String {
let text = format!(" {pointer} {mark} {} ({})", item.label(), item.hint());
let mut text = truncate_to_width(&text, width);
let current_width = text.width();
if current_width < width {
text.push_str(&" ".repeat(width - current_width));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
@@ -317,18 +365,32 @@ mod tests {
}
#[test]
fn arrow_keys_move_cursor_within_bounds() {
fn arrow_keys_wrap_cursor_at_edges() {
let active = StatusItem::default_footer();
let mut view = StatusPickerView::new(&active);
assert_eq!(view.cursor, 0);
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(view.cursor, StatusItem::all().len() - 1);
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(view.cursor, 0);
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(view.cursor, 1);
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(view.cursor, 0);
// Move past the bottom shouldn't wrap.
for _ in 0..StatusItem::all().len() + 5 {
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
}
assert_eq!(view.cursor, StatusItem::all().len() - 1);
}
#[test]
fn visible_row_start_keeps_cursor_in_view() {
assert_eq!(visible_row_start(14, 0, 8), 0);
assert_eq!(visible_row_start(14, 7, 8), 0);
assert_eq!(visible_row_start(14, 8, 8), 1);
assert_eq!(visible_row_start(14, 13, 8), 6);
}
#[test]
fn selected_row_text_fills_available_width() {
let text = status_row_text("", "[ ]", &StatusItem::LastToolElapsed, 40);
assert_eq!(text.width(), 40);
assert!(text.starts_with(" ▸ [ ] Last tool elapsed"));
}
}