feat: Add new system for multi-selections.

This commit is contained in:
2026-03-14 13:55:55 -04:00
parent f6f1efdf8e
commit 2e29dfc103
13 changed files with 930 additions and 63 deletions

View File

@@ -83,12 +83,15 @@ async fn run_inner(
let mut mode = Mode::Insert;
let mut pending = PendingKey::None;
let mut last_generation: u64 = 0;
let mut multi_enabled = false;
let mut visual_anchor: Option<usize> = None;
loop {
if let Some(ref vs) = view_state {
let ft = filter_text.clone();
let anchor = visual_anchor;
terminal.draw(|frame| {
render_menu(frame, vs, &ft);
render_menu(frame, vs, &ft, anchor);
})?;
}
@@ -100,9 +103,24 @@ async fn run_inner(
let event = event_result?;
match event {
Event::Key(key) => {
if let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) {
// Handle multi-action keys (Tab, Space in normal/visual)
// before the single-action map_key_event path.
let handled = handle_multi_action_key(
key, mode, multi_enabled, &mut visual_anchor,
&view_state, action_tx,
).await;
if !handled
&& let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending)
{
// Track mode locally for key mapping
if let Action::SetMode(m) = &action {
if *m == Mode::Visual {
if let Some(ref vs) = view_state {
visual_anchor = Some(vs.cursor);
}
} else if mode == Mode::Visual {
visual_anchor = None;
}
mode = *m;
pending = PendingKey::None;
}
@@ -136,6 +154,7 @@ async fn run_inner(
mode = vs.mode;
pending = PendingKey::None;
}
multi_enabled = vs.multi_enabled;
view_state = Some(vs);
}
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
@@ -155,9 +174,83 @@ async fn run_inner(
Ok(())
}
/// Handle keys that need to send multiple actions (Tab, Space
/// in normal mode, visual mode apply/cancel). Returns true
/// if the key was consumed.
async fn handle_multi_action_key(
key: KeyEvent,
mode: Mode,
multi_enabled: bool,
visual_anchor: &mut Option<usize>,
view_state: &Option<ViewState>,
action_tx: &mpsc::Sender<Action>,
) -> bool {
match mode {
Mode::Insert => {
if !multi_enabled {
return false;
}
match key.code {
KeyCode::Tab => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
true
}
KeyCode::BackTab => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveUp(1)).await;
true
}
_ => false,
}
}
Mode::Normal => {
if !multi_enabled {
return false;
}
match (key.code, key.modifiers) {
(KeyCode::Char(' '), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
true
}
(KeyCode::Tab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
true
}
(KeyCode::BackTab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveUp(1)).await;
true
}
_ => false,
}
}
Mode::Visual => {
match (key.code, key.modifiers) {
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) if !m.intersects(KeyModifiers::ALT) => {
// Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor;
let _ = action_tx.send(Action::SelectRange {
start: anchor,
end: cursor,
}).await;
}
*visual_anchor = None;
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await;
true
}
_ => false,
}
}
}
}
/// Render the menu into the given frame. Extracted from the
/// event loop so it can be tested with a [`TestBackend`].
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, visual_anchor: Option<usize>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
@@ -169,9 +262,10 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
let mode_indicator = match vs.mode {
Mode::Insert => "[I]",
Mode::Normal => "[N]",
Mode::Visual => "[V]",
};
let prompt = Paragraph::new(Line::from(vec![
let mut prompt_spans = vec![
Span::styled(
format!("{mode_indicator}> "),
Style::default().fg(Color::Cyan),
@@ -181,7 +275,16 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
format!(" {filtered_count}/{total_count}"),
Style::default().fg(Color::DarkGray),
),
]));
];
if vs.selection_count > 0 {
prompt_spans.push(Span::styled(
format!(" [{}]", vs.selection_count),
Style::default().fg(Color::Yellow),
));
}
let prompt = Paragraph::new(Line::from(prompt_spans));
frame.render_widget(prompt, chunks[0]);
// mode_indicator len + "> " = mode_indicator.len() + 2
@@ -192,18 +295,42 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
}
// Determine visual range for highlighting
let visual_range = if vs.mode == Mode::Visual {
visual_anchor.map(|anchor| {
let min = anchor.min(vs.cursor);
let max = anchor.max(vs.cursor);
min..=max
})
} else {
None
};
let items: Vec<ListItem> = vs
.visible_items
.iter()
.enumerate()
.map(|(i, vi)| {
let style = if i == vs.cursor {
let in_visual = visual_range.as_ref().is_some_and(|r| r.contains(&i));
let mut style = if i == vs.cursor {
Style::default().add_modifier(Modifier::REVERSED)
} else if in_visual {
Style::default().fg(Color::Black).bg(Color::Blue)
} else {
Style::default()
};
if vi.selected {
style = style.fg(Color::Green);
}
let marker = if vi.selected { "* " } else { " " };
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
ListItem::new(text).style(style)
if vs.multi_enabled {
ListItem::new(format!("{marker}{text}")).style(style)
} else {
ListItem::new(text).style(style)
}
})
.collect();
@@ -223,6 +350,7 @@ fn map_key_event(
match mode {
Mode::Insert => map_insert_mode(key, filter_text),
Mode::Normal => map_normal_mode(key, pending),
Mode::Visual => map_visual_mode(key, pending),
}
}
@@ -289,6 +417,12 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
Some(Action::Cancel)
}
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Visual)),
(KeyCode::Char('U'), _) => Some(Action::ClearSelections),
(KeyCode::Char('u'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
Some(Action::UndoSelection)
}
(KeyCode::Char('r'), KeyModifiers::CONTROL) => Some(Action::RedoSelection),
(KeyCode::Enter, _) => Some(Action::Confirm),
(KeyCode::Esc, _) => Some(Action::Cancel),
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
@@ -297,6 +431,41 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
}
}
/// Visual mode: movement keys extend the range, V cancels,
/// Space/Enter apply (handled in handle_multi_action_key).
fn map_visual_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
// Handle pending `g` key for `gg` sequence
if *pending == PendingKey::G {
*pending = PendingKey::None;
if key.code == KeyCode::Char('g')
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Some(Action::MoveToTop);
}
}
match (key.code, key.modifiers) {
(KeyCode::Char('j'), m) | (KeyCode::Down, m) if !m.intersects(KeyModifiers::ALT) => {
Some(Action::MoveDown(1))
}
(KeyCode::Char('k'), m) | (KeyCode::Up, m) if !m.intersects(KeyModifiers::ALT) => {
Some(Action::MoveUp(1))
}
(KeyCode::Char('G'), _) => Some(Action::MoveToBottom),
(KeyCode::Char('g'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
*pending = PendingKey::G;
None
}
// V cancels visual mode, no selection applied
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Normal)),
// Escape always cancels the menu
(KeyCode::Esc, _) => Some(Action::Cancel),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -331,16 +500,19 @@ mod tests {
label: "alpha".into(),
formatted_text: None,
index: 0,
selected: false,
},
VisibleItem {
label: "bravo".into(),
formatted_text: None,
index: 1,
selected: false,
},
VisibleItem {
label: "charlie".into(),
formatted_text: None,
index: 2,
selected: false,
},
],
cursor: 0,
@@ -348,6 +520,8 @@ mod tests {
total_items: 5,
total_filtered: 3,
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
generation: 1,
}
}
@@ -703,7 +877,7 @@ mod tests {
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
terminal
.draw(|frame| {
render_menu(frame, vs, filter);
render_menu(frame, vs, filter, None);
})
.ok()
.expect("draw");
@@ -820,6 +994,8 @@ mod tests {
total_items: 0,
total_filtered: 0,
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
generation: 1,
};
let backend = render_to_backend(30, 4, &vs, "");
@@ -853,7 +1029,7 @@ mod tests {
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
terminal
.draw(|frame| {
render_menu(frame, &vs, "hi");
render_menu(frame, &vs, "hi", None);
})
.ok()
.expect("draw");
@@ -871,7 +1047,7 @@ mod tests {
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
terminal
.draw(|frame| {
render_menu(frame, &vs, "");
render_menu(frame, &vs, "", None);
})
.ok()
.expect("draw");