Skip to main content

sectioned_picker/
lib.rs

1//! Interactive terminal multi-select picker with non-selectable section headers.
2//!
3//! This crate provides a full-screen ratatui-based picker widget that groups
4//! selectable items under bold section headers. It's designed for CLI tools that
5//! need users to choose from categorized options.
6//!
7//! # Features
8//!
9//! - **Sectioned layout** — items grouped under bold, non-selectable headers
10//! - **Selection modes** — checkbox sections (`[x]`/`[ ]`, any number selected)
11//!   or radio sections (`●`/`○`, at most one selected)
12//! - **Keyboard navigation** — arrow keys, j/k, Space to toggle, Enter to confirm
13//! - **Section toggle** — `a` checks/unchecks all items in a checkbox section
14//! - **Collapsing** — Left/Right arrows collapse/expand a section
15//! - **Item descriptions** — optional per-item explanatory text shown inline
16//! - **Smart scrolling** — keeps cursor visible; snaps to section header when near top
17//! - **Custom actions** — bind arbitrary keys to caller-defined handlers that can
18//!   take over the terminal (e.g., for previews)
19//!
20//! # Example
21//!
22//! ```no_run
23//! use sectioned_picker::{Section, SectionItem, PickerOutcome, run_picker};
24//!
25//! let sections = vec![
26//!     Section::new(
27//!         "Features:",
28//!         vec![
29//!             SectionItem::new("logging", true),
30//!             SectionItem::new("metrics", false),
31//!         ],
32//!     ),
33//!     Section::new(
34//!         "Dependencies:",
35//!         vec![SectionItem::new("tokio (1.38)", true)],
36//!     ),
37//! ];
38//!
39//! match run_picker("my-app v1.0", sections, Vec::new()).unwrap() {
40//!     PickerOutcome::Confirmed(results) => {
41//!         // results[0] = [true, false] — features section
42//!         // results[1] = [true]        — dependencies section
43//!     }
44//!     PickerOutcome::Cancelled => {}
45//! }
46//! ```
47//!
48//! # Scrolling behavior
49//!
50//! When the list exceeds the viewport height, the view scrolls to keep the
51//! cursor visible:
52//!
53//! - **Near section top:** scrolling up snaps to the section header when it fits
54//!   in the viewport alongside the cursor.
55//! - **Tall sections:** does NOT snap to a distant header on every up-movement;
56//!   only snaps when the cursor is close enough to the top of its section.
57//!
58//! # Enter behavior
59//!
60//! If no items are checked when the user presses Enter, the item under the
61//! cursor is checked before submitting. This makes single-item selection a
62//! one-key operation (navigate + Enter). The convenience is skipped when the
63//! cursor is in a radio section, where an empty selection is a valid choice.
64//!
65//! Confirming is rejected when any radio section has more than one item
66//! checked; an inline error is shown and the picker stays open.
67
68mod render;
69mod state;
70
71#[cfg(test)]
72mod tests;
73
74use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
75use ratatui::{
76    TerminalOptions, Viewport,
77    crossterm::{
78        ExecutableCommand,
79        terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
80    },
81};
82use std::time::Duration;
83
84pub use render::render_picker;
85pub use state::PickerState;
86
87/// How many items a section allows to be selected at once.
88// [impl tui.picker.checkbox]
89// [impl tui.picker.radio]
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum SelectionMode {
92    /// Any number of items may be checked (rendered as `[x]`/`[ ]`).
93    #[default]
94    Checkbox,
95    /// At most one item should be checked (rendered as `●`/`○`). Selecting one
96    /// item deselects the others; confirming with more than one selected is
97    /// rejected.
98    Radio,
99}
100
101/// A section of items in the picker.
102pub struct Section {
103    pub title: String,
104    pub items: Vec<SectionItem>,
105    /// Whether this section is checkbox (default) or radio.
106    pub selection_mode: SelectionMode,
107    /// Whether this section starts collapsed (items hidden until expanded).
108    pub collapsed: bool,
109}
110
111impl Section {
112    /// Create a checkbox section that starts expanded.
113    pub fn new(title: impl Into<String>, items: Vec<SectionItem>) -> Self {
114        Self {
115            title: title.into(),
116            items,
117            selection_mode: SelectionMode::Checkbox,
118            collapsed: false,
119        }
120    }
121
122    /// Mark this section as radio (at most one selection).
123    pub fn radio(mut self) -> Self {
124        self.selection_mode = SelectionMode::Radio;
125        self
126    }
127
128    /// Mark this section as starting collapsed.
129    pub fn collapsed(mut self) -> Self {
130        self.collapsed = true;
131        self
132    }
133}
134
135/// A selectable item within a section.
136pub struct SectionItem {
137    pub label: String,
138    pub checked: bool,
139    /// Optional explanatory text shown inline after the label.
140    pub description: Option<String>,
141}
142
143impl SectionItem {
144    /// Create an item with no description.
145    pub fn new(label: impl Into<String>, checked: bool) -> Self {
146        Self {
147            label: label.into(),
148            checked,
149            description: None,
150        }
151    }
152
153    /// Attach an inline description to this item.
154    pub fn with_description(mut self, description: impl Into<String>) -> Self {
155        self.description = Some(description.into());
156        self
157    }
158}
159
160/// Context passed to action handlers when a custom key is pressed.
161///
162/// Provides access to the current cursor position and the terminal for
163/// full-screen takeover (e.g., rendering a preview).
164pub struct ActionContext<'a> {
165    section_idx: usize,
166    item_idx: usize,
167    terminal: &'a mut ratatui::DefaultTerminal,
168}
169
170impl ActionContext<'_> {
171    /// Which section the cursor is in (0-indexed, matching input order).
172    pub fn section(&self) -> usize {
173        self.section_idx
174    }
175
176    /// Which item within the section the cursor is on (0-indexed).
177    pub fn item(&self) -> usize {
178        self.item_idx
179    }
180
181    /// Mutable access to the terminal for drawing custom screens.
182    pub fn terminal(&mut self) -> &mut ratatui::DefaultTerminal {
183        self.terminal
184    }
185}
186
187/// Handler type for picker actions.
188pub type ActionHandler<'a> = Box<dyn FnMut(&mut ActionContext<'_>) + 'a>;
189
190/// A caller-defined action bound to a key.
191///
192/// When the user presses `key`, the picker calls `handler` with an
193/// [`ActionContext`] that provides the current section/item coordinates and
194/// mutable terminal access. The handler may take over the screen (e.g., for a
195/// preview) and should return when done — the picker redraws automatically.
196pub struct PickerAction<'a> {
197    pub key: char,
198    pub label: &'a str,
199    pub handler: ActionHandler<'a>,
200}
201
202/// The outcome of a picker interaction.
203pub enum PickerOutcome {
204    /// User confirmed — returns checked state per section (matching input order).
205    Confirmed(Vec<Vec<bool>>),
206    /// User cancelled (Esc).
207    Cancelled,
208}
209
210/// Run an interactive sectioned multi-select picker.
211///
212/// Sections are rendered with bold headers; items below them have checkboxes.
213/// Navigation skips headers automatically. Optional `actions` bind keys to
214/// caller-defined handlers that receive the terminal for full-screen takeover.
215pub fn run_picker(
216    title: &str,
217    sections: Vec<Section>,
218    actions: Vec<PickerAction<'_>>,
219) -> anyhow::Result<PickerOutcome> {
220    let height: u16 = sections
221        .iter()
222        .map(|sec| sec.items.len() as u16 + 2)
223        .sum::<u16>()
224        + 2;
225    let mut state = PickerState::new(sections);
226    if state.is_empty() {
227        return Ok(PickerOutcome::Confirmed(Vec::new()));
228    }
229
230    let mut is_fullscreen = false;
231    let viewport = if let Ok((_, r)) = ratatui::crossterm::terminal::size()
232        && r > height
233    {
234        Viewport::Inline(height)
235    } else {
236        is_fullscreen = true;
237        Viewport::Fullscreen
238    };
239
240    let mut terminal = ratatui::init_with_options(TerminalOptions { viewport });
241    enable_raw_mode()?;
242    if is_fullscreen {
243        terminal.backend_mut().execute(EnterAlternateScreen)?;
244    }
245    let result = run_picker_loop(&mut terminal, title, &mut state, actions);
246    disable_raw_mode()?;
247    if is_fullscreen {
248        terminal.backend_mut().execute(LeaveAlternateScreen)?;
249    }
250    result
251}
252
253fn run_picker_loop(
254    terminal: &mut ratatui::DefaultTerminal,
255    title: &str,
256    state: &mut PickerState,
257    mut actions: Vec<PickerAction<'_>>,
258) -> anyhow::Result<PickerOutcome> {
259    let action_keys: Vec<char> = actions.iter().map(|a| a.key).collect();
260
261    loop {
262        terminal.draw(|frame| render_picker(frame, title, state, &actions))?;
263
264        if event::poll(Duration::from_millis(100))?
265            && let Event::Key(key) = event::read()?
266        {
267            if key.kind != KeyEventKind::Press {
268                continue;
269            }
270
271            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
272                return Ok(PickerOutcome::Cancelled);
273            }
274
275            // Any keypress clears a stale confirm error before it is handled.
276            state.clear_confirm_error();
277
278            match key.code {
279                KeyCode::Up | KeyCode::Char('k') => state.move_up(),
280                KeyCode::Down | KeyCode::Char('j') => state.move_down(),
281                KeyCode::Char(' ') => state.toggle(),
282                // [impl tui.picker.collapse]
283                KeyCode::Left => state.collapse_current(),
284                KeyCode::Right => state.expand_current(),
285                KeyCode::Backspace => state.backspace(),
286                // [impl tui.picker.confirm-validation]
287                KeyCode::Enter => {
288                    // A one-key selection convenience: if nothing is checked,
289                    // check the cursor item first. Skipped for radio sections,
290                    // where a deliberate empty selection is valid.
291                    if !state.has_any_checked()
292                        && state.current_section_mode() != SelectionMode::Radio
293                    {
294                        state.toggle();
295                    }
296                    match state.try_confirm() {
297                        Ok(results) => return Ok(PickerOutcome::Confirmed(results)),
298                        Err(msg) => state.set_confirm_error(msg),
299                    }
300                }
301                KeyCode::Esc | KeyCode::Char('q') => {
302                    return Ok(PickerOutcome::Cancelled);
303                }
304                KeyCode::Char('a') => state.toggle_current_section(),
305                KeyCode::Char(c) => {
306                    if let Some(idx) = action_keys.iter().position(|&k| k == c) {
307                        let (section_idx, item_idx) = state.current_coordinates();
308                        let mut ctx = ActionContext {
309                            section_idx,
310                            item_idx,
311                            terminal,
312                        };
313                        (actions[idx].handler)(&mut ctx);
314                    }
315                }
316                _ => {}
317            }
318        }
319    }
320}