mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
## What changed - Add the disabled-by-default `tui_extensions_statusline` feature to render configured status items and their picker preview through a shared UI tree renderer. - Support text, HTTP(S) links, rows, spacers, dividers, and gaps with rendering budgets, text sanitization, and width-aware truncation that keeps the ellipsis outside hyperlinks. - Use plain text for the extension status line and hide the picker's theme color toggle while preserving the saved color preference. ## Testing Add coverage for tree serialization and URL validation, Unicode widths, hyperlink clipping, picker previews, color preference preservation, and token usage updates across renderer switches. GitOrigin-RevId: a6b87c2b1096765d61296d73e142834fcd53ceee
1152 lines
39 KiB
Rust
1152 lines
39 KiB
Rust
//! 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
|
||
//! - **Required selection**: Optional nonempty confirmation; cancellation always remains available
|
||
//! - **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
|
||
//! - **Picker appearance**: Full-width focus with overflow hints in spacer rows;
|
||
//! checkboxes remain independent of the focused row
|
||
//!
|
||
//! # Example
|
||
//!
|
||
//! ```ignore
|
||
//! let picker = MultiSelectPicker::builder(
|
||
//! "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::Paragraph;
|
||
use ratatui::widgets::Widget;
|
||
use ratatui::widgets::Wrap;
|
||
|
||
use super::footer_content::FooterContent;
|
||
use super::picker_rows;
|
||
use super::picker_style;
|
||
use super::selection_popup_common::ColumnWidthConfig;
|
||
use super::selection_popup_common::GenericDisplayRow;
|
||
use super::selection_popup_common::wrap_styled_line;
|
||
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_with_col_width_mode;
|
||
use crate::key_hint;
|
||
use crate::key_hint::KeyBindingListExt;
|
||
use crate::key_hint::is_plain_text_key_event;
|
||
use crate::keymap::ListAction;
|
||
use crate::keymap::ListKeymap;
|
||
use crate::keymap::RuntimeKeymap;
|
||
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";
|
||
|
||
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.
|
||
type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<FooterContent> + 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::builder`].
|
||
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,
|
||
require_selection: 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<FooterContent>,
|
||
|
||
/// 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))
|
||
}
|
||
|
||
fn footer_lines(&self, width: u16) -> Vec<Line<'_>> {
|
||
wrap_styled_line(&self.footer_hint, width.saturating_sub(/*rhs*/ 2))
|
||
}
|
||
|
||
/// Calculates the height needed for the row list area.
|
||
fn rows_height(&self, rows: &BuiltRows) -> u16 {
|
||
let height: u16 = rows
|
||
.rows
|
||
.len()
|
||
.clamp(/*min*/ 1, MAX_POPUP_ROWS)
|
||
.try_into()
|
||
.unwrap_or(/*default*/ 1);
|
||
height.saturating_add(/*rhs*/ 2)
|
||
}
|
||
|
||
/// 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(),
|
||
selection_style: Some(picker_style::selection_style()),
|
||
..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,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 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 || (self.require_selection && !self.items.iter().any(|item| item.enabled))
|
||
{
|
||
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 keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
|
||
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
|
||
}
|
||
|
||
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);
|
||
|
||
if allow_plain_char_navigation
|
||
&& self.ordering_enabled
|
||
&& self.keymap.move_left.is_pressed(key_event)
|
||
{
|
||
self.move_selected_item(Direction::Up);
|
||
return;
|
||
}
|
||
if allow_plain_char_navigation
|
||
&& self.ordering_enabled
|
||
&& self.keymap.move_right.is_pressed(key_event)
|
||
{
|
||
self.move_selected_item(Direction::Down);
|
||
return;
|
||
}
|
||
if allow_plain_char_navigation
|
||
&& let Some(action) = self.keymap.vertical_action_for(key_event)
|
||
{
|
||
let len = self.visible_len();
|
||
self.state
|
||
.apply_vertical_action(action, len, Self::max_visible_rows(len));
|
||
return;
|
||
}
|
||
|
||
match key_event {
|
||
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.saturating_add(self.footer_lines(width).len() as u16 + 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 hint_lines = self.footer_lines(area.width);
|
||
let hint_height = hint_lines.len() as u16;
|
||
let footer_height = hint_height + 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_height = self.rows_height(&rows);
|
||
let [header_area, search_area, list_area] =
|
||
picker_rows::layout(content_area, header_height, /*search*/ 1, rows_height);
|
||
|
||
self.header.render(header_area, buf);
|
||
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 body = list_area.inset(Insets::vh(u16::from(list_area.height >= 3), /*h*/ 0));
|
||
let rendered = render_rows_single_line_with_col_width_mode(
|
||
body,
|
||
buf,
|
||
&rows.rows,
|
||
&rows.state,
|
||
usize::from(body.height),
|
||
"no matches",
|
||
ColumnWidthConfig::default(),
|
||
);
|
||
picker_style::render_scroll_indicators(list_area, buf, rendered);
|
||
}
|
||
|
||
let hint_area = if let Some(preview_line) = &self.preview_line {
|
||
let [preview_area, hint_area] =
|
||
Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)])
|
||
.areas(footer_area);
|
||
let preview_area = preview_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2));
|
||
preview_line.clone().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,
|
||
};
|
||
Paragraph::new(hint_lines).dim().render(hint_area, buf);
|
||
}
|
||
}
|
||
|
||
/// Builder for constructing a [`MultiSelectPicker`] with a fluent API.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// let picker = MultiSelectPicker::builder("Title".into(), /*subtitle*/ 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,
|
||
require_selection: 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,
|
||
require_selection: 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>(self, callback: F) -> Self
|
||
where
|
||
F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync + 'static,
|
||
{
|
||
self.on_preview_content(move |items| {
|
||
callback(items).map(|line| FooterContent::HyperlinkLine(line.into()))
|
||
})
|
||
}
|
||
|
||
pub(crate) fn on_preview_content(
|
||
mut self,
|
||
callback: impl Fn(&[MultiSelectItem]) -> Option<FooterContent> + Send + Sync + 'static,
|
||
) -> Self {
|
||
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
|
||
}
|
||
|
||
/// Requires a nonempty selection to confirm without restricting cancellation.
|
||
pub fn require_selection(mut self) -> Self {
|
||
self.require_selection = true;
|
||
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(Paragraph::new(self.title.bold()).wrap(Wrap { trim: false }));
|
||
if let Some(subtitle) = self.subtitle {
|
||
header.push(Paragraph::new(subtitle.dim()).wrap(Wrap { trim: false }));
|
||
}
|
||
|
||
let instructions = if self.instructions.is_empty() {
|
||
let mut spans = vec![key_hint::plain(KeyCode::Char(' ')).into(), " toggle".dim()];
|
||
if self.ordering_enabled
|
||
&& let (Some(left), Some(right)) = (
|
||
self.keymap.primary_hint(ListAction::MoveLeft),
|
||
self.keymap.primary_hint(ListAction::MoveRight),
|
||
)
|
||
{
|
||
spans.push(" · ".dim());
|
||
spans.extend(key_hint::key_label_spans(&format!(
|
||
"{}/{}",
|
||
left.display_label(),
|
||
right.display_label()
|
||
)));
|
||
spans.push(" reorder".dim());
|
||
}
|
||
for (action, label) in [(ListAction::Accept, "save"), (ListAction::Cancel, "cancel")] {
|
||
if let Some(hint) = self.keymap.primary_hint(action) {
|
||
spans.push(" · ".dim());
|
||
spans.extend(hint.spans());
|
||
spans.push(format!(" {label}").dim());
|
||
}
|
||
}
|
||
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,
|
||
require_selection: self.require_selection,
|
||
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 match
|
||
pub(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.handle_key_event(KeyEvent::from(KeyCode::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.move_left = keymap.page_down.clone();
|
||
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*/ false,
|
||
/*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));
|
||
}
|
||
|
||
#[test]
|
||
fn short_picker_preserves_selected_row_when_overflow_spacers_yield() {
|
||
let mut picker = test_picker(
|
||
(0..12)
|
||
.map(|index| {
|
||
item(
|
||
&format!("field-{index:02}"),
|
||
/*orderable*/ true,
|
||
/*section_break_after*/ false,
|
||
)
|
||
})
|
||
.collect(),
|
||
);
|
||
picker.header = Box::new(());
|
||
picker.handle_key_event(KeyEvent::from(KeyCode::End));
|
||
let mut snapshots = Vec::new();
|
||
for height in 1..=7 {
|
||
let area = Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 80, height);
|
||
let mut buf = Buffer::empty(area);
|
||
picker.render(area, &mut buf);
|
||
let text = buf
|
||
.content
|
||
.chunks(usize::from(area.width))
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(ratatui::buffer::Cell::symbol)
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_string()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
if height >= 4 {
|
||
assert!(text.contains("› [ ] field-11"), "{text}");
|
||
}
|
||
assert_eq!(picker.state.selected_idx, Some(11));
|
||
snapshots.push(format!("height={height}\n{text}"));
|
||
}
|
||
picker.toggle_selected();
|
||
assert_eq!(
|
||
picker
|
||
.items
|
||
.iter()
|
||
.filter(|item| item.enabled)
|
||
.map(|item| item.id.as_str())
|
||
.collect::<Vec<_>>(),
|
||
["field-11"]
|
||
);
|
||
insta::assert_snapshot!(snapshots.join("\n\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn picker_appearance_renders_checkboxes_preview_and_overflow() {
|
||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||
let mut picker = MultiSelectPicker::builder(
|
||
"Status line".to_string(),
|
||
/*subtitle*/ None,
|
||
AppEventSender::new(tx),
|
||
)
|
||
.items(
|
||
(0..12)
|
||
.map(|index| MultiSelectItem {
|
||
id: format!("field-{index}"),
|
||
name: format!("Field {index}"),
|
||
description: Some("Status detail".to_string()),
|
||
enabled: index == 0,
|
||
section_break_after: index == 0,
|
||
..Default::default()
|
||
})
|
||
.collect(),
|
||
)
|
||
.enable_ordering()
|
||
.on_preview(|items| {
|
||
Some(Line::from(
|
||
items
|
||
.iter()
|
||
.filter(|item| item.enabled)
|
||
.map(|item| item.name.as_str())
|
||
.collect::<Vec<_>>()
|
||
.join(" · "),
|
||
))
|
||
})
|
||
.build();
|
||
let render = |picker: &MultiSelectPicker| {
|
||
let area = Rect::new(
|
||
/*x*/ 0,
|
||
/*y*/ 0,
|
||
/*width*/ 48,
|
||
picker.desired_height(/*width*/ 48),
|
||
);
|
||
let mut buf = Buffer::empty(area);
|
||
picker.render(area, &mut buf);
|
||
buf
|
||
};
|
||
let first = render(&picker);
|
||
picker.handle_key_event(KeyEvent::from(KeyCode::End));
|
||
picker.toggle_selected();
|
||
picker.move_selected_item(Direction::Up);
|
||
let last = render(&picker);
|
||
picker.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
|
||
let empty = render(&picker);
|
||
insta::assert_snapshot!(format!(
|
||
"First page:\n{first:?}\nLast page:\n{last:?}\nNo matches:\n{empty:?}"
|
||
));
|
||
}
|
||
}
|