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}