Codex Handbook
tui/src/bottom_pane/multi_select_picker.rs 1095 lines
//! 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));    }}