feat: Add new system for multi-selections.
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user