//! Multi-select picker widget for selecting multiple items from a list.//!//! This module provides a fuzzy-searchable, scrollable picker that allows users//! to toggle multiple items on/off. It supports://!//! - **Fuzzy search**: Type to filter items by name//! - **Toggle selection**: Space to toggle items on/off//! - **Reordering**: Optional left/right arrow support to reorder items//! - **Live preview**: Optional callback to show a preview of current selections//! - **Callbacks**: Hooks for change, confirm, and cancel events//!//! # Example//!//! ```ignore//! let picker = MultiSelectPicker::new(//! "Select Items".to_string(),//! Some("Choose which items to enable".to_string()),//! app_event_tx,//! )//! .items(vec![//! MultiSelectItem {//! id: "a".into(),//! name: "Item A".into(),//! description: None,//! enabled: true,//! orderable: true,//! section_break_after: false,//! },//! MultiSelectItem {//! id: "b".into(),//! name: "Item B".into(),//! description: None,//! enabled: false,//! orderable: true,//! section_break_after: false,//! },//! ])//! .on_confirm(|selected_ids, tx| { /* handle confirmation */ })//! .build();//! ```use codex_utils_fuzzy_match::fuzzy_match;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyModifiers;use ratatui::buffer::Buffer;use ratatui::layout::Constraint;use ratatui::layout::Layout;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::text::Span;use ratatui::widgets::Block;use ratatui::widgets::Widget;use super::selection_popup_common::GenericDisplayRow;use crate::app_event_sender::AppEventSender;use crate::bottom_pane::CancellationEvent;use crate::bottom_pane::bottom_pane_view::BottomPaneView;use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;use crate::bottom_pane::scroll_state::ScrollState;use crate::bottom_pane::selection_popup_common::render_rows_single_line;use crate::key_hint;use crate::key_hint::KeyBindingListExt;use crate::key_hint::is_plain_text_key_event;use crate::keymap::ListKeymap;use crate::keymap::RuntimeKeymap;use crate::keymap::primary_binding;use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;use crate::render::Insets;use crate::render::RectExt;use crate::render::renderable::ColumnRenderable;use crate::render::renderable::Renderable;use crate::style::user_message_style;use crate::text_formatting::truncate_text;/// Maximum display length for item names before truncation.const ITEM_NAME_TRUNCATE_LEN: usize = 21;/// Placeholder text shown in the search input when empty.const SEARCH_PLACEHOLDER: &str = "Type to search";/// Prefix displayed before the search query (mimics a command prompt).const SEARCH_PROMPT_PREFIX: &str = "> ";const SECTION_BREAK_ROW: &str = " ───────────────────────";/// Direction for reordering items in the list.enum Direction { Up, Down,}/// Callback invoked when any item's state changes (toggled or reordered)./// Receives the full list of items and the event sender.pub type ChangeCallBack = Box<dyn Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync>;/// Callback invoked when the user confirms their selection (presses Enter)./// Receives a list of IDs for all enabled items.pub type ConfirmCallback = Box<dyn Fn(&[String], &AppEventSender) + Send + Sync>;/// Callback invoked when the user cancels the picker (presses Escape).pub type CancelCallback = Box<dyn Fn(&AppEventSender) + Send + Sync>;/// Callback to generate an optional preview line based on current item states./// Returns `None` to hide the preview area.pub type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync>;/// A single selectable item in the multi-select picker.////// Each item has a unique identifier, display name, optional description,/// and an enabled/disabled state that can be toggled by the user.pub(crate) struct MultiSelectItem { /// Unique identifier returned in the confirm callback when this item is enabled. pub id: String, /// Display name shown in the picker list. Will be truncated if too long. pub name: String, /// Optional description shown alongside the name (dimmed). pub description: Option<String>, /// Whether this item is currently selected/enabled. pub enabled: bool, /// Whether this item can be moved when ordering is enabled. pub orderable: bool, /// Whether to draw a divider after this item when another visible item follows. pub section_break_after: bool,}impl Default for MultiSelectItem { fn default() -> Self { Self { id: String::new(), name: String::new(), description: None, enabled: false, orderable: true, section_break_after: false, } }}struct BuiltRows { rows: Vec<GenericDisplayRow>, state: ScrollState,}/// A multi-select picker widget with fuzzy search and optional reordering.////// The picker displays a scrollable list of items with checkboxes. Users can:/// - Type to fuzzy-search and filter the list/// - Use Up/Down (or Ctrl+P/Ctrl+N) to navigate/// - Press Space to toggle the selected item/// - Press Enter to confirm and close/// - Press Escape to cancel and close/// - Use Left/Right arrows to reorder items (if ordering is enabled)////// Create instances using the builder pattern via [`MultiSelectPicker::new`].pub(crate) struct MultiSelectPicker { /// All items in the picker (unfiltered). items: Vec<MultiSelectItem>, /// Scroll and selection state for the visible list. state: ScrollState, /// Whether the picker has been closed (confirmed or cancelled). pub(crate) complete: bool, /// Channel for sending application events. app_event_tx: AppEventSender, /// Header widget displaying title and subtitle. header: Box<dyn Renderable>, /// Footer line showing keyboard hints. footer_hint: Line<'static>, /// Current search/filter query entered by the user. search_query: String, /// Indices into `items` that match the current filter, in display order. filtered_indices: Vec<usize>, /// Whether left/right arrow reordering is enabled. ordering_enabled: bool, /// Shared list keybindings for navigation and completion. keymap: ListKeymap, /// Optional callback to generate a preview line from current item states. preview_builder: Option<PreviewCallback>, /// Cached preview line (updated on item changes). preview_line: Option<Line<'static>>, /// Callback invoked when items change (toggle or reorder). on_change: Option<ChangeCallBack>, /// Callback invoked when the user confirms their selection. on_confirm: Option<ConfirmCallback>, /// Callback invoked when the user cancels the picker. on_cancel: Option<CancelCallback>,}impl MultiSelectPicker { /// Creates a new builder for constructing a `MultiSelectPicker`. /// /// # Arguments /// /// * `title` - The main title displayed at the top of the picker /// * `subtitle` - Optional subtitle displayed below the title (dimmed) /// * `app_event_tx` - Event sender for dispatching application events pub fn builder( title: String, subtitle: Option<String>, app_event_tx: AppEventSender, ) -> MultiSelectPickerBuilder { MultiSelectPickerBuilder::new(title, subtitle, app_event_tx) } /// Applies the current search query to filter and sort items. /// /// Updates `filtered_indices` to contain only matching items, sorted by /// fuzzy match score. Attempts to preserve the current selection if it /// still matches the filter. fn apply_filter(&mut self) { // Filter + sort while preserving the current selection when possible. let previously_selected = self .state .selected_idx .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()); let filter = self.search_query.trim(); if filter.is_empty() { self.filtered_indices = (0..self.items.len()).collect(); } else { let mut matches: Vec<(usize, i32)> = Vec::new(); for (idx, item) in self.items.iter().enumerate() { let display_name = item.name.as_str(); if let Some((_indices, score)) = match_item(filter, display_name, &item.name) { matches.push((idx, score)); } } matches.sort_by(|a, b| { a.1.cmp(&b.1).then_with(|| { let an = self.items[a.0].name.as_str(); let bn = self.items[b.0].name.as_str(); an.cmp(bn) }) }); self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect(); } let len = self.filtered_indices.len(); self.state.selected_idx = previously_selected .and_then(|actual_idx| { self.filtered_indices .iter() .position(|idx| *idx == actual_idx) }) .or_else(|| (len > 0).then_some(0)); let visible = Self::max_visible_rows(len); self.state.clamp_selection(len); self.state.ensure_visible(len, visible); } /// Returns the number of items visible after filtering. fn visible_len(&self) -> usize { self.filtered_indices.len() } /// Returns the maximum number of rows that can be displayed at once. fn max_visible_rows(len: usize) -> usize { MAX_POPUP_ROWS.min(len.max(1)) } /// Calculates the width available for row content (accounts for borders). fn rows_width(total_width: u16) -> u16 { total_width.saturating_sub(2) } /// Calculates the height needed for the row list area. fn rows_height(&self, rows: &BuiltRows) -> u16 { rows.rows .len() .clamp(1, MAX_POPUP_ROWS) .try_into() .unwrap_or(1) } /// Builds the display rows for all currently visible (filtered) items. /// /// Each row shows: `› [x] Item Name` where `›` indicates cursor position /// and `[x]` or `[ ]` indicates enabled/disabled state. fn build_rows(&self) -> BuiltRows { let mut rows = Vec::new(); let mut visible_to_row = Vec::with_capacity(self.filtered_indices.len()); for (visible_idx, actual_idx) in self.filtered_indices.iter().enumerate() { let Some(item) = self.items.get(*actual_idx) else { continue; }; visible_to_row.push(rows.len()); let is_selected = self.state.selected_idx == Some(visible_idx); let prefix = if is_selected { '›' } else { ' ' }; let marker = if item.enabled { 'x' } else { ' ' }; let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN); let name = format!("{prefix} [{marker}] {item_name}"); rows.push(GenericDisplayRow { name, description: item.description.clone(), ..Default::default() }); if item.section_break_after && visible_idx + 1 < self.filtered_indices.len() { rows.push(GenericDisplayRow { name: SECTION_BREAK_ROW.to_string(), is_disabled: true, ..Default::default() }); } } let selected_idx = self .state .selected_idx .and_then(|visible_idx| visible_to_row.get(visible_idx).copied()); let scroll_top = visible_to_row .get(self.state.scroll_top) .copied() .unwrap_or(0); BuiltRows { rows, state: ScrollState { selected_idx, scroll_top, }, } } /// Moves the selection cursor up, wrapping to the bottom if at the top. fn move_up(&mut self) { let len = self.visible_len(); self.state.move_up_wrap(len); let visible = Self::max_visible_rows(len); self.state.ensure_visible(len, visible); } /// Moves the selection cursor down, wrapping to the top if at the bottom. fn move_down(&mut self) { let len = self.visible_len(); self.state.move_down_wrap(len); let visible = Self::max_visible_rows(len); self.state.ensure_visible(len, visible); } fn page_up(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.page_up_clamped(len, visible); } fn page_down(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.page_down_clamped(len, visible); } fn jump_top(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.jump_top(len, visible); } fn jump_bottom(&mut self) { let len = self.visible_len(); let visible = Self::max_visible_rows(len); self.state.jump_bottom(len, visible); } /// Toggles the enabled state of the currently selected item. /// /// Updates the preview line and invokes the `on_change` callback if set. fn toggle_selected(&mut self) { let Some(idx) = self.state.selected_idx else { return; }; let Some(actual_idx) = self.filtered_indices.get(idx).copied() else { return; }; let Some(item) = self.items.get_mut(actual_idx) else { return; }; item.enabled = !item.enabled; self.update_preview_line(); if let Some(on_change) = &self.on_change { on_change(&self.items, &self.app_event_tx); } } /// Confirms the current selection and closes the picker. /// /// Collects the IDs of all enabled items and passes them to the /// `on_confirm` callback. Does nothing if already complete. fn confirm_selection(&mut self) { if self.complete { return; } self.complete = true; if let Some(on_confirm) = &self.on_confirm { let selected_ids: Vec<String> = self .items .iter() .filter(|item| item.enabled) .map(|item| item.id.clone()) .collect(); on_confirm(&selected_ids, &self.app_event_tx); } } /// Moves the currently selected item up or down in the list. /// /// Only works when: /// - The search query is empty (reordering is disabled during filtering) /// - Ordering is enabled via [`MultiSelectPickerBuilder::enable_ordering`] /// /// Updates the preview line and invokes the `on_change` callback. fn move_selected_item(&mut self, direction: Direction) { if !self.search_query.is_empty() { return; } let Some(visible_idx) = self.state.selected_idx else { return; }; let Some(actual_idx) = self.filtered_indices.get(visible_idx).copied() else { return; }; let len = self.items.len(); if len == 0 { return; } if !self .items .get(actual_idx) .is_some_and(|item| item.orderable) { return; } let new_idx = match direction { Direction::Up if actual_idx > 0 => actual_idx - 1, Direction::Down if actual_idx + 1 < len => actual_idx + 1, _ => return, }; if !self.items.get(new_idx).is_some_and(|item| item.orderable) { return; } // move item in underlying list self.items.swap(actual_idx, new_idx); self.update_preview_line(); if let Some(on_change) = &self.on_change { on_change(&self.items, &self.app_event_tx); } // rebuild filtered indices to keep search/filter consistent self.apply_filter(); // restore selection to moved item let moved_idx = new_idx; if let Some(new_visible_idx) = self .filtered_indices .iter() .position(|idx| *idx == moved_idx) { self.state.selected_idx = Some(new_visible_idx); } } /// Regenerates the preview line using the preview callback. /// /// Called after any item state change (toggle or reorder). fn update_preview_line(&mut self) { self.preview_line = self .preview_builder .as_ref() .and_then(|builder| builder(&self.items)); } /// Closes the picker without confirming, invoking the `on_cancel` callback. /// /// Does nothing if already complete. pub fn close(&mut self) { if self.complete { return; } self.complete = true; if let Some(on_cancel) = &self.on_cancel { on_cancel(&self.app_event_tx); } }}impl BottomPaneView for MultiSelectPicker { fn is_complete(&self) -> bool { self.complete } fn on_ctrl_c(&mut self) -> CancellationEvent { self.close(); CancellationEvent::Handled } fn handle_key_event(&mut self, key_event: KeyEvent) { // Printable characters always feed search. Movement aliases such as // plain j/k only apply through non-text events or modified bindings. let allow_plain_char_navigation = !is_plain_text_key_event(key_event); match key_event { _ if allow_plain_char_navigation && self.ordering_enabled && self.keymap.move_left.is_pressed(key_event) => { self.move_selected_item(Direction::Up); } _ if allow_plain_char_navigation && self.ordering_enabled && self.keymap.move_right.is_pressed(key_event) => { self.move_selected_item(Direction::Down); } _ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => { self.move_up() } _ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => { self.move_down() } _ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => { self.page_up() } _ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => { self.page_down() } _ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => { self.jump_top() } _ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => { self.jump_bottom() } KeyEvent { code: KeyCode::Backspace, .. } => { self.search_query.pop(); self.apply_filter(); } KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => self.toggle_selected(), _ if self.keymap.accept.is_pressed(key_event) => self.confirm_selection(), _ if self.keymap.cancel.is_pressed(key_event) => self.close(), KeyEvent { code: KeyCode::Char(c), modifiers, .. } if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { self.search_query.push(c); self.apply_filter(); } _ => {} } }}impl Renderable for MultiSelectPicker { fn desired_height(&self, width: u16) -> u16 { let rows = self.build_rows(); let rows_height = self.rows_height(&rows); let preview_height = if self.preview_line.is_some() { 1 } else { 0 }; let mut height = self.header.desired_height(width.saturating_sub(4)); height = height.saturating_add(rows_height + 3); height = height.saturating_add(2); height.saturating_add(1 + preview_height) } fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; } // Reserve the footer line for the key-hint row. let preview_height = if self.preview_line.is_some() { 1 } else { 0 }; let footer_height = 1 + preview_height; let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]).areas(area); Block::default() .style(user_message_style()) .render(content_area, buf); let header_height = self .header .desired_height(content_area.width.saturating_sub(4)); let rows = self.build_rows(); let rows_width = Self::rows_width(content_area.width); let rows_height = self.rows_height(&rows); let [header_area, _, search_area, list_area] = Layout::vertical([ Constraint::Max(header_height), Constraint::Max(1), Constraint::Length(2), Constraint::Length(rows_height), ]) .areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2))); self.header.render(header_area, buf); // Render the search prompt as two lines to mimic the composer. if search_area.height >= 2 { let [placeholder_area, input_area] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area); Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf); let line = if self.search_query.is_empty() { Line::from(vec![SEARCH_PROMPT_PREFIX.dim()]) } else { Line::from(vec![ SEARCH_PROMPT_PREFIX.dim(), self.search_query.clone().into(), ]) }; line.render(input_area, buf); } else if search_area.height > 0 { let query_span = if self.search_query.is_empty() { SEARCH_PLACEHOLDER.dim() } else { self.search_query.clone().into() }; Line::from(query_span).render(search_area, buf); } if list_area.height > 0 { let render_area = Rect { x: list_area.x.saturating_sub(2), y: list_area.y, width: rows_width.max(1), height: list_area.height, }; render_rows_single_line( render_area, buf, &rows.rows, &rows.state, render_area.height as usize, "no matches", ); } let hint_area = if let Some(preview_line) = &self.preview_line { let [preview_area, hint_area] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area); let preview_area = Rect { x: preview_area.x + 2, y: preview_area.y, width: preview_area.width.saturating_sub(2), height: preview_area.height, }; let max_preview_width = preview_area.width.saturating_sub(2) as usize; let preview_line = truncate_line_with_ellipsis_if_overflow(preview_line.clone(), max_preview_width); preview_line.render(preview_area, buf); hint_area } else { footer_area }; let hint_area = Rect { x: hint_area.x + 2, y: hint_area.y, width: hint_area.width.saturating_sub(2), height: hint_area.height, }; self.footer_hint.clone().dim().render(hint_area, buf); }}/// Builder for constructing a [`MultiSelectPicker`] with a fluent API.////// # Example////// ```ignore/// let picker = MultiSelectPicker::new("Title".into(), None, tx)/// .items(items)/// .enable_ordering()/// .on_preview(|items| Some(Line::from("Preview")))/// .on_confirm(|ids, tx| { /* handle */ })/// .on_cancel(|tx| { /* handle */ })/// .build();/// ```pub(crate) struct MultiSelectPickerBuilder { title: String, subtitle: Option<String>, instructions: Vec<Span<'static>>, items: Vec<MultiSelectItem>, ordering_enabled: bool, app_event_tx: AppEventSender, keymap: ListKeymap, preview_builder: Option<PreviewCallback>, on_change: Option<ChangeCallBack>, on_confirm: Option<ConfirmCallback>, on_cancel: Option<CancelCallback>,}impl MultiSelectPickerBuilder { /// Creates a new builder with the given title, optional subtitle, and event sender. pub fn new(title: String, subtitle: Option<String>, app_event_tx: AppEventSender) -> Self { Self { title, subtitle, instructions: Vec::new(), items: Vec::new(), ordering_enabled: false, app_event_tx, keymap: RuntimeKeymap::defaults().list, preview_builder: None, on_change: None, on_confirm: None, on_cancel: None, } } /// Sets the list of selectable items. pub fn items(mut self, items: Vec<MultiSelectItem>) -> Self { self.items = items; self } /// Enables left/right arrow keys for reordering items. /// /// Reordering is only active when the search query is empty. pub fn enable_ordering(mut self) -> Self { self.ordering_enabled = true; self } /// Sets the shared list keymap used for navigation and completion. pub fn list_keymap(mut self, keymap: ListKeymap) -> Self { self.keymap = keymap; self } /// Sets a callback to generate a preview line from the current item states. /// /// The callback receives all items and should return a [`Line`] to display, /// or `None` to hide the preview area. pub fn on_preview<F>(mut self, callback: F) -> Self where F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync + 'static, { self.preview_builder = Some(Box::new(callback)); self } /// Sets a callback invoked whenever an item's state changes. /// /// This includes both toggles and reordering operations. #[allow(dead_code)] pub fn on_change<F>(mut self, callback: F) -> Self where F: Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync + 'static, { self.on_change = Some(Box::new(callback)); self } /// Sets a callback invoked when the user confirms their selection (Enter). /// /// The callback receives a list of IDs for all enabled items. pub fn on_confirm<F>(mut self, callback: F) -> Self where F: Fn(&[String], &AppEventSender) + Send + Sync + 'static, { self.on_confirm = Some(Box::new(callback)); self } /// Sets a callback invoked when the user cancels the picker (Escape). pub fn on_cancel<F>(mut self, callback: F) -> Self where F: Fn(&AppEventSender) + Send + Sync + 'static, { self.on_cancel = Some(Box::new(callback)); self } /// Builds the [`MultiSelectPicker`] with all configured options. /// /// Initializes the filter to show all items and generates the initial /// preview line if a preview callback was set. pub fn build(self) -> MultiSelectPicker { let mut header = ColumnRenderable::new(); header.push(Line::from(self.title.bold())); if let Some(subtitle) = self.subtitle { header.push(Line::from(subtitle.dim())); } let instructions = if self.instructions.is_empty() { let mut spans = vec![ "Press ".into(), key_hint::plain(KeyCode::Char(' ')).into(), " to toggle".into(), ]; if self.ordering_enabled && let (Some(move_left), Some(move_right)) = ( primary_binding(&self.keymap.move_left), primary_binding(&self.keymap.move_right), ) { spans.push("; ".into()); spans.push(move_left.into()); spans.push("/".into()); spans.push(move_right.into()); spans.push(" to move".into()); } if let Some(accept) = primary_binding(&self.keymap.accept) { spans.push("; ".into()); spans.push(accept.into()); spans.push(" to confirm and close".into()); } if let Some(cancel) = primary_binding(&self.keymap.cancel) { spans.push("; ".into()); spans.push(cancel.into()); spans.push(" to close".into()); } spans } else { self.instructions }; let mut view = MultiSelectPicker { items: self.items, state: ScrollState::new(), complete: false, app_event_tx: self.app_event_tx, header: Box::new(header), footer_hint: Line::from(instructions), ordering_enabled: self.ordering_enabled, keymap: self.keymap, search_query: String::new(), filtered_indices: Vec::new(), preview_builder: self.preview_builder, preview_line: None, on_change: self.on_change, on_confirm: self.on_confirm, on_cancel: self.on_cancel, }; view.apply_filter(); view.update_preview_line(); view }}/// Performs fuzzy matching on an item against a filter string.////// Tries to match against the display name first, then falls back to name if different. Returns/// the matching character indices (if matched on display name) and a score for sorting.////// # Arguments////// * `filter` - The search query to match against/// * `display_name` - The primary name to match (shown to user)/// * `name` - A secondary/canonical name to try if display name doesn't match////// # Returns////// * `Some((Some(indices), score))` - Matched on display name with highlight indices/// * `Some((None, score))` - Matched on skill name only (no highlights for display)/// * `None` - No matchpub(crate) fn match_item( filter: &str, display_name: &str, name: &str,) -> Option<(Option<Vec<usize>>, i32)> { if let Some((indices, score)) = fuzzy_match(display_name, filter) { return Some((Some(indices), score)); } if display_name != name && let Some((_indices, score)) = fuzzy_match(name, filter) { return Some((None, score)); } None}#[cfg(test)]mod tests { use super::*; use crate::app_event::AppEvent; use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; fn test_picker(items: Vec<MultiSelectItem>) -> MultiSelectPicker { let (tx, _rx) = unbounded_channel::<AppEvent>(); MultiSelectPicker::builder( "Test".to_string(), /*subtitle*/ None, AppEventSender::new(tx), ) .items(items) .enable_ordering() .build() } fn item(id: &str, orderable: bool, section_break_after: bool) -> MultiSelectItem { MultiSelectItem { id: id.to_string(), name: id.to_string(), orderable, section_break_after, ..Default::default() } } #[test] fn non_orderable_items_cannot_move_or_be_crossed() { let mut picker = test_picker(vec![ item( "theme-colors", /*orderable*/ false, /*section_break_after*/ true, ), item( "model", /*orderable*/ true, /*section_break_after*/ false, ), item( "branch", /*orderable*/ true, /*section_break_after*/ false, ), ]); picker.move_selected_item(Direction::Down); assert_eq!( picker .items .iter() .map(|item| item.id.as_str()) .collect::<Vec<_>>(), vec!["theme-colors", "model", "branch"] ); picker.move_down(); picker.move_selected_item(Direction::Up); assert_eq!( picker .items .iter() .map(|item| item.id.as_str()) .collect::<Vec<_>>(), vec!["theme-colors", "model", "branch"] ); } #[test] fn horizontal_list_keys_reorder_orderable_items() { let mut picker = test_picker(vec![ item( "model", /*orderable*/ true, /*section_break_after*/ false, ), item( "branch", /*orderable*/ true, /*section_break_after*/ false, ), ]); picker.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); assert_eq!( picker .items .iter() .map(|item| item.id.as_str()) .collect::<Vec<_>>(), vec!["branch", "model"] ); picker.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); assert_eq!( picker .items .iter() .map(|item| item.id.as_str()) .collect::<Vec<_>>(), vec!["model", "branch"] ); } #[test] fn section_break_after_item_renders_separator_row() { let picker = test_picker(vec![ item( "theme-colors", /*orderable*/ false, /*section_break_after*/ true, ), item( "model", /*orderable*/ true, /*section_break_after*/ false, ), ]); let rows = picker.build_rows(); assert_eq!( rows.rows .iter() .map(|row| row.name.as_str()) .collect::<Vec<_>>(), vec!["› [ ] theme-colors", SECTION_BREAK_ROW, " [ ] model"] ); assert_eq!(rows.state.selected_idx, Some(0)); } #[test] fn searchable_plain_j_updates_query_instead_of_navigating() { let mut picker = test_picker(vec![ item( "alpha", /*orderable*/ true, /*section_break_after*/ false, ), item( "jupiter", /*orderable*/ true, /*section_break_after*/ false, ), ]); picker.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); assert_eq!(picker.search_query, "j"); assert_eq!(picker.filtered_indices, vec![1]); assert_eq!(picker.state.selected_idx, Some(0)); } #[test] fn page_and_jump_navigation_use_list_keymap() { let (tx, _rx) = unbounded_channel::<AppEvent>(); let mut keymap = RuntimeKeymap::defaults().list; keymap.page_down = vec![key_hint::ctrl(KeyCode::Char('d'))]; keymap.page_up = vec![key_hint::ctrl(KeyCode::Char('u'))]; keymap.jump_bottom = vec![key_hint::ctrl(KeyCode::Char('e'))]; keymap.jump_top = vec![key_hint::ctrl(KeyCode::Char('a'))]; let mut picker = MultiSelectPicker::builder( "Test".to_string(), /*subtitle*/ None, AppEventSender::new(tx), ) .items( (0..12) .map(|idx| { item( &format!("item-{idx}"), /*orderable*/ true, /*section_break_after*/ false, ) }) .collect(), ) .list_keymap(keymap) .build(); picker.handle_key_event(KeyEvent::from(KeyCode::PageDown)); assert_eq!(picker.state.selected_idx, Some(0)); picker.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); assert_eq!(picker.state.selected_idx, Some(8)); picker.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); assert_eq!(picker.state.selected_idx, Some(0)); picker.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); assert_eq!(picker.state.selected_idx, Some(11)); picker.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); assert_eq!(picker.state.selected_idx, Some(0)); }}