use std::io::IsTerminal; use anyhow::Result; use console::strip_ansi_codes; use crate::preview::{PreviewLayout, PreviewPlacement, SelectMode, SelectRow, SelectUiOptions}; /// Builder for select prompts with fuzzy search. pub struct SelectBuilder { pub(crate) message: String, pub(crate) options: Vec, pub(crate) starting_cursor: Option, pub(crate) default: Option, pub(crate) help_message: Option<&'static str>, pub(crate) initial_text: Option, pub(crate) header_lines: usize, pub(crate) preview: Option, pub(crate) preview_window: Option, } impl SelectBuilder { /// Set starting cursor position. pub fn with_starting_cursor(mut self, cursor: usize) -> Self { self.starting_cursor = Some(cursor); self } /// Set a preview command shown in a side panel as the user navigates items. pub fn with_preview(mut self, command: impl Into) -> Self { self.preview = Some(command.into()); self } /// Set the layout of the preview panel. pub fn with_preview_window(mut self, layout: impl Into) -> Self { self.preview_window = Some(layout.into()); self } /// Set default for confirm prompts using bool options. pub fn with_default(mut self, default: bool) -> Self { self.default = Some(default); self } /// Set help message displayed as a header above the list. pub fn with_help_message(mut self, message: &'static str) -> Self { self.help_message = Some(message); self } /// Set initial search text for fuzzy search. pub fn with_initial_text(mut self, text: impl Into) -> Self { self.initial_text = Some(text.into()); self } /// Set the number of header lines treated as non-selectable options. pub fn with_header_lines(mut self, n: usize) -> Self { self.header_lines = n; self } /// Execute select prompt with fuzzy search. /// /// # Returns /// /// - `Ok(Some(T))` when the user selects an option. /// - `Ok(None)` when no options are available or the user cancels. /// /// # Errors /// /// Returns an error if the picker cannot set up terminal interaction, /// render, process events, or run a preview command. pub fn prompt(self) -> Result> where T: std::fmt::Display + Clone, { if !std::io::stderr().is_terminal() { return Ok(None); } if std::any::TypeId::of::() == std::any::TypeId::of::() { return prompt_confirm_as(&self.message, self.default); } if self.options.is_empty() { return Ok(None); } let rows = self .options .iter() .enumerate() .map(|(index, item)| { let display = strip_ansi_codes(&item.to_string()).trim().to_string(); if index < self.header_lines { SelectRow::header(display) } else { SelectRow::new(index.to_string(), display.clone()).search(display) } }) .collect::>(); let header_count = self.header_lines.min(rows.len()); if rows.len() == header_count { return Ok(None); } let mut selector = SelectUiOptions::new(format!("{} ❯ ", self.message), rows) .header_lines(header_count) .mode(SelectMode::Single) .preview_layout(parse_preview_layout(self.preview_window.as_deref())); if let Some(query) = self.initial_text { selector = selector.query(Some(query)); } if let Some(preview) = self.preview { selector = selector.preview(Some(preview)); } if let Some(cursor) = self.starting_cursor { selector = selector.initial_raw(Some(cursor.to_string())); } if let Some(help) = self.help_message { selector.rows.insert(0, SelectRow::header(help)); selector.header_lines = selector.header_lines.saturating_add(1); } let selected = selector.prompt()?; Ok(selected.and_then(|row| { row.raw .parse::() .ok() .and_then(|index| self.options.get(index).cloned()) })) } } fn parse_preview_layout(layout: Option<&str>) -> PreviewLayout { let Some(layout) = layout else { return PreviewLayout::default(); }; let placement = if layout.contains("down") || layout.contains("bottom") { PreviewPlacement::Bottom } else { PreviewPlacement::Right }; let percent = layout .split(|ch: char| !ch.is_ascii_digit()) .find_map(|part| part.parse::().ok()) .unwrap_or_else(|| PreviewLayout::default().percent) .clamp(1, 99); PreviewLayout { placement, percent } } /// Runs a yes/no confirmation prompt. /// /// Returns `Ok(Some(true))` for Yes, `Ok(Some(false))` for No, and `Ok(None)` /// if cancelled. fn prompt_confirm(message: &str, default: Option) -> Result> { let rows = if default == Some(false) { vec![SelectRow::new("no", "No"), SelectRow::new("yes", "Yes")] } else { vec![SelectRow::new("yes", "Yes"), SelectRow::new("no", "No")] }; let selected = SelectUiOptions::new(format!("{} ❯ ", message), rows).prompt()?; Ok(selected.and_then(|row| match row.raw.as_str() { "yes" => Some(true), "no" => Some(false), _ => None, })) } /// Wrapper around [`prompt_confirm`] that safely converts the `bool` result /// into the generic type `T`. /// /// This must only be called when `T` is known to be `bool`. fn prompt_confirm_as( message: &str, default: Option, ) -> Result> { let result = prompt_confirm(message, default)?; Ok(result.and_then(|value| { let any_value: Box = Box::new(value); any_value.downcast::().ok().map(|boxed| *boxed) })) } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; use crate::ForgeWidget; #[test] fn test_select_builder_creates() { let builder = ForgeWidget::select("Test", vec!["a", "b", "c"]); assert_eq!(builder.message, "Test"); assert_eq!(builder.options, vec!["a", "b", "c"]); } #[test] fn test_confirm_builder_creates() { let builder = ForgeWidget::confirm("Confirm?"); assert_eq!(builder.message, "Confirm?"); } #[test] fn test_select_builder_with_initial_text() { let builder = ForgeWidget::select("Test", vec!["apple", "banana", "cherry"]).with_initial_text("app"); assert_eq!(builder.initial_text, Some("app".to_string())); } #[test] fn test_select_owned_builder_with_initial_text() { let builder = ForgeWidget::select("Test", vec!["apple", "banana", "cherry"]).with_initial_text("ban"); assert_eq!(builder.initial_text, Some("ban".to_string())); } #[test] fn test_ansi_stripping() { let fixture = ["\x1b[1mBold\x1b[0m", "\x1b[31mRed\x1b[0m"]; let actual: Vec = fixture .iter() .map(|value| strip_ansi_codes(value).to_string()) .collect(); let expected = vec!["Bold", "Red"]; assert_eq!(actual, expected); } #[test] fn test_display_options_are_trimmed() { let fixture = [ " openai [empty]", "✓ anthropic [api.anthropic.com]", ]; let actual: Vec = fixture .iter() .map(|value| strip_ansi_codes(value).trim().to_string()) .collect(); let expected = vec![ "openai [empty]".to_string(), "✓ anthropic [api.anthropic.com]".to_string(), ]; assert_eq!(actual, expected); } #[test] fn test_with_starting_cursor() { let builder = ForgeWidget::select("Test", vec!["a", "b", "c"]).with_starting_cursor(2); assert_eq!(builder.starting_cursor, Some(2)); } #[test] fn test_parse_preview_layout_defaults_to_right() { let fixture = None; let actual = parse_preview_layout(fixture); let expected = PreviewLayout { placement: PreviewPlacement::Right, percent: 50 }; assert_eq!(actual, expected); } #[test] fn test_parse_preview_layout_supports_bottom_percent() { let fixture = Some("down,60%"); let actual = parse_preview_layout(fixture); let expected = PreviewLayout { placement: PreviewPlacement::Bottom, percent: 60 }; assert_eq!(actual, expected); } }