1mod 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
34pub 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
73pub 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
100pub 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 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
282fn 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}