Skip to main content

tau_cli_picker/
lib.rs

1//! Synchronous, blocking, single-select terminal list picker.
2//!
3//! Three entry points are exposed:
4//!
5//! - [`pick`] — full convenience. Enables raw mode and writes to `stderr`. Must
6//!   not be called from inside a TUI that already owns raw mode.
7//! - [`pick_with_writer`] — enables raw mode but writes the picker frame to a
8//!   caller-provided writer.
9//! - [`pick_with_io`] — does *not* manage raw mode. Intended for tests or
10//!   non-terminal hosts that drive the picker via in-memory streams.
11//!
12//! The crate's terminal-ownership and input contracts are summarized in
13//! `ARCH-tau-cli-picker`.
14
15mod error;
16mod item;
17mod key;
18mod raw_mode;
19
20#[cfg(test)]
21mod tests;
22
23use std::io;
24
25use tau_term_screen::screen::Screen;
26use tau_term_screen::style::StyledText;
27use tau_term_screen::truncate_to_width;
28
29pub use crate::error::PickerError;
30pub use crate::item::PickerItem;
31use crate::key::{PickerEvent, PickerKey, read_byte_key, read_terminal_event};
32use crate::raw_mode::{RawModeCleanup, RawModeGuard};
33
34/// Prompts the user to pick one of `items`, rendering to `stderr`.
35///
36/// Returns the original index of the selected item. Disabled items are rendered
37/// but skipped by navigation and cannot be selected.
38///
39/// Enables terminal raw mode for the duration of the call. The picker
40/// must therefore not be invoked while another component already owns
41/// raw mode — the inner [`Drop`] would silently restore cooked mode and
42/// strand the parent. Simple byte-stream hosts that manage raw mode themselves
43/// can use [`pick_with_io`]. Embedded crossterm/TUI callers should not use this
44/// picker as a widget; they need a future host-event API that accepts
45/// caller-provided events, resize notifications, and size samples.
46///
47/// The picker frame is written to `stderr` (fd 2) so that the typical
48/// `cli | tool` pipe shape leaves `stdout` untouched.
49///
50/// # Errors
51///
52/// Returns [`PickerError::Empty`] when no items are supplied,
53/// [`PickerError::NoEnabledItems`] when every item is disabled,
54/// [`PickerError::Cancelled`] when the user cancels, and
55/// [`PickerError::Io`] for terminal raw-mode, input, rendering, or normal
56/// selection cleanup failures. Cleanup after cancellation or input errors is
57/// best-effort and does not replace the original error, except that explicit
58/// raw-mode restoration failures from this raw-mode-owning entry point are
59/// reported as [`PickerError::Io`] even if the picker otherwise selected,
60/// cancelled, or hit an input/rendering error, so callers are not told the
61/// terminal was safely restored when it may still be raw.
62pub fn pick(prompt: &str, items: &[PickerItem]) -> Result<usize, PickerError> {
63    pick_with_raw_mode(
64        prompt,
65        items,
66        io::stderr(),
67        RawModeGuard::enable,
68        read_terminal_event,
69        terminal_size,
70    )
71}
72
73/// Like [`pick`], but writes the picker frame to `writer`.
74///
75/// Returns the original index of the selected item. Disabled items are rendered
76/// but skipped by navigation and cannot be selected.
77///
78/// Enables terminal raw mode for the duration of the call; the same
79/// caveat as [`pick`] applies.
80///
81/// # Errors
82///
83/// Returns the same errors as [`pick`], including raw-mode setup failures and
84/// writer I/O failures.
85pub fn pick_with_writer(
86    prompt: &str,
87    items: &[PickerItem],
88    writer: impl io::Write,
89) -> Result<usize, PickerError> {
90    pick_with_raw_mode(
91        prompt,
92        items,
93        writer,
94        RawModeGuard::enable,
95        read_terminal_event,
96        terminal_size,
97    )
98}
99
100/// Drives the picker against caller-provided byte-stream IO.
101///
102/// Returns the original index of the selected item. Disabled items are rendered
103/// but skipped by navigation and cannot be selected.
104///
105/// Does **not** toggle terminal raw mode. Intended for tests and simple
106/// byte-stream hosts. Embedded crossterm/TUI hosts still need a public API that
107/// accepts host-provided events, resize notifications, and size samples.
108///
109/// EOF, bare Escape, Ctrl-C, Ctrl-D, and `q` cancel the picker. The byte-stream
110/// reader decodes Enter, Tab, BackTab, `j`/`k`, and simple CSI Up/Down arrow
111/// sequences; other printable keys are ignored, and Space is reserved.
112///
113/// # Errors
114///
115/// Returns [`PickerError::Empty`] when no items are supplied,
116/// [`PickerError::NoEnabledItems`] when every item is disabled,
117/// [`PickerError::Cancelled`] when the input stream cancels or reaches EOF, and
118/// [`PickerError::Io`] for reader, writer, rendering, or normal selection
119/// cleanup failures. Cleanup after cancellation or input errors is best-effort
120/// and does not replace the original error.
121pub fn pick_with_io(
122    prompt: &str,
123    items: &[PickerItem],
124    writer: impl io::Write,
125    mut reader: impl io::Read,
126) -> Result<usize, PickerError> {
127    pick_with_event_reader(
128        prompt,
129        items,
130        writer,
131        || read_byte_key(&mut reader).map(PickerEvent::Key),
132        terminal_size,
133    )
134}
135
136fn pick_with_raw_mode<G: RawModeCleanup>(
137    prompt: &str,
138    items: &[PickerItem],
139    writer: impl io::Write,
140    enable_raw_mode: impl FnOnce() -> io::Result<G>,
141    read_event: impl FnMut() -> io::Result<PickerEvent>,
142    current_size: impl FnMut() -> (usize, usize),
143) -> Result<usize, PickerError> {
144    validate_items(items)?;
145    let mut raw = enable_raw_mode()?;
146    let result = pick_with_event_reader(prompt, items, writer, read_event, current_size);
147    let restore_result = raw.restore_raw_mode();
148
149    match restore_result {
150        Ok(()) => result,
151        Err(err) => Err(PickerError::Io(err)),
152    }
153}
154
155fn validate_items(items: &[PickerItem]) -> Result<usize, PickerError> {
156    if items.is_empty() {
157        return Err(PickerError::Empty);
158    }
159    items
160        .iter()
161        .position(PickerItem::is_enabled)
162        .ok_or(PickerError::NoEnabledItems)
163}
164
165fn pick_with_event_reader(
166    prompt: &str,
167    items: &[PickerItem],
168    mut writer: impl io::Write,
169    mut read_event: impl FnMut() -> io::Result<PickerEvent>,
170    mut current_size: impl FnMut() -> (usize, usize),
171) -> Result<usize, PickerError> {
172    let mut selected = validate_items(items)?;
173    let (mut width, mut height) = current_size();
174    let mut screen = Screen::new(width);
175
176    let result = (|| -> Result<usize, PickerError> {
177        render(&mut screen, &mut writer, prompt, items, selected, height)?;
178        loop {
179            match read_event()? {
180                PickerEvent::Key(PickerKey::Down) => {
181                    selected = adjacent_enabled_item(items, selected, NavigationDirection::Forward);
182                }
183                PickerEvent::Key(PickerKey::Up) => {
184                    selected =
185                        adjacent_enabled_item(items, selected, NavigationDirection::Backward);
186                }
187                PickerEvent::Key(PickerKey::Enter) => {
188                    clear_picker_frame(&mut screen, &mut writer)?;
189                    return Ok(selected);
190                }
191                PickerEvent::Key(PickerKey::Cancelled) => {
192                    return Err(PickerError::Cancelled);
193                }
194                PickerEvent::Key(PickerKey::Ignored) => {}
195                PickerEvent::Resize {
196                    width: new_width,
197                    height: new_height,
198                } => {
199                    let new_width = resize_dimension(new_width, width);
200                    let new_height = resize_dimension(new_height, height);
201                    screen.erase_all(&mut writer)?;
202                    screen.invalidate();
203                    if new_width != width {
204                        screen.set_width(new_width);
205                        width = new_width;
206                    }
207                    height = new_height;
208                }
209            }
210            render(&mut screen, &mut writer, prompt, items, selected, height)?;
211        }
212    })();
213
214    if result.is_err() {
215        let _ = force_clear_picker_frame(&mut screen, &mut writer);
216    }
217    result
218}
219
220fn clear_picker_frame(screen: &mut Screen, writer: &mut impl io::Write) -> io::Result<()> {
221    screen.update(writer, &[], (0, 0))?;
222    writer.flush()
223}
224
225fn force_clear_picker_frame(screen: &mut Screen, writer: &mut impl io::Write) -> io::Result<()> {
226    screen.erase_all(writer)?;
227    screen.invalidate();
228    writer.flush()
229}
230
231fn render(
232    screen: &mut Screen,
233    writer: &mut impl io::Write,
234    prompt: &str,
235    items: &[PickerItem],
236    selected: usize,
237    terminal_height: usize,
238) -> io::Result<()> {
239    let (lines, cursor_row) =
240        picker_lines(prompt, items, selected, screen.width(), terminal_height);
241    screen.update(writer, &lines, (cursor_row, 0))?;
242    writer.flush()
243}
244
245fn picker_lines(
246    prompt: &str,
247    items: &[PickerItem],
248    selected: usize,
249    width: usize,
250    terminal_height: usize,
251) -> (Vec<tau_term_screen::CellRow>, usize) {
252    if terminal_height <= 1 {
253        let item = &items[selected];
254        let marker = if item.is_enabled() { '>' } else { 'X' };
255        let line = truncate_to_width(&format!("{marker} {} — ? {prompt}", item.label()), width);
256        return (vec![StyledText::from(line).to_cells().into()], 0);
257    }
258
259    // Reserve one row for the prompt; leave at least one item visible.
260    let visible = terminal_height.saturating_sub(1).max(1);
261    let window = visible_window(items.len(), selected, visible);
262    let mut lines = Vec::with_capacity(window.len() + 1);
263    lines.push(
264        StyledText::from(truncate_to_width(&format!("? {prompt}"), width))
265            .to_cells()
266            .into(),
267    );
268    for (idx, item) in items.iter().enumerate().take(window.end).skip(window.start) {
269        let marker = if !item.is_enabled() {
270            'X'
271        } else if idx == selected {
272            '>'
273        } else {
274            ' '
275        };
276        let line = truncate_to_width(&format!("{marker} {}", item.label()), width);
277        lines.push(StyledText::from(line).to_cells().into());
278    }
279    (lines, selected - window.start + 1)
280}
281
282/// Returns `[start, end)` of the items to render, ensuring `selected`
283/// is within view.
284fn visible_window(total: usize, selected: usize, visible: usize) -> std::ops::Range<usize> {
285    if total <= visible {
286        return 0..total;
287    }
288    let half = visible / 2;
289    let mut start = selected.saturating_sub(half);
290    if total < start + visible {
291        start = total - visible;
292    }
293    start..start + visible
294}
295
296enum NavigationDirection {
297    Forward,
298    Backward,
299}
300
301fn adjacent_enabled_item(
302    items: &[PickerItem],
303    selected: usize,
304    direction: NavigationDirection,
305) -> usize {
306    for offset in 1..items.len() {
307        let idx = match direction {
308            NavigationDirection::Forward => (selected + offset) % items.len(),
309            NavigationDirection::Backward => (selected + items.len() - offset) % items.len(),
310        };
311        if items[idx].is_enabled() {
312            return idx;
313        }
314    }
315    selected
316}
317
318fn resize_dimension(reported: u16, current: usize) -> usize {
319    if 0 < reported {
320        usize::from(reported)
321    } else {
322        current.max(1)
323    }
324}
325
326const DEFAULT_TERMINAL_WIDTH: usize = 80;
327const DEFAULT_TERMINAL_HEIGHT: usize = 24;
328
329fn terminal_size() -> (usize, usize) {
330    crossterm::terminal::size().map_or(
331        (DEFAULT_TERMINAL_WIDTH, DEFAULT_TERMINAL_HEIGHT),
332        |(w, h)| (usize::from(w).max(1), usize::from(h).max(1)),
333    )
334}