Skip to main content

xbp_cli/cli/
interactive.rs

1//! Shared interactive pickers (type-to-filter FuzzySelect + ANSI theme).
2//!
3//! Used by `xbp deploy`, `xbp service`, `xbp linear`, versioning, etc.
4//! Cursor / active-row highlight uses a dark indigo 256-color background so
5//! selection stays readable against busy multi-column labels.
6
7use console::{style, Style};
8use dialoguer::theme::ColorfulTheme;
9use dialoguer::{Confirm, FuzzySelect, MultiSelect, Select};
10use std::fmt::Display;
11
12/// Dark indigo background (~indigo-900) for the fuzzy cursor + active row.
13const INDIGO_BG: u8 = 54;
14/// Mid indigo accent for prefixes / hints.
15const INDIGO_MID: u8 = 61;
16/// Brighter indigo/lavender for accents and match highlights.
17const INDIGO_LIGHT: u8 = 141;
18/// Soft lavender for fuzzy match characters.
19const INDIGO_MATCH: u8 = 183;
20/// Muted indigo-gray for dim secondary text.
21const INDIGO_MUTED: u8 = 103;
22
23/// Rich dialoguer theme used across XBP interactive prompts.
24///
25/// Darker indigo cursor/active highlight (not the default black-on-white
26/// fuzzy cursor), with stronger ANSI separation between prompt, values,
27/// and inactive rows — same visual language as the Linear TUI selection.
28pub fn xbp_theme() -> ColorfulTheme {
29    ColorfulTheme {
30        defaults_style: Style::new().for_stderr().color256(INDIGO_LIGHT),
31        prompt_style: Style::new().for_stderr().white().bold(),
32        prompt_prefix: style("◆".to_string()).for_stderr().color256(INDIGO_LIGHT).bold(),
33        prompt_suffix: style("›".to_string()).for_stderr().color256(INDIGO_MID),
34        success_prefix: style("✔".to_string()).for_stderr().green().bright(),
35        success_suffix: style("·".to_string()).for_stderr().black().bright(),
36        error_prefix: style("✘".to_string()).for_stderr().red().bright(),
37        error_style: Style::new().for_stderr().red().bright(),
38        hint_style: Style::new().for_stderr().color256(INDIGO_MUTED),
39        values_style: Style::new().for_stderr().color256(INDIGO_LIGHT).bold(),
40        // Active row: white bold on dark indigo (high contrast, matches Linear TUI).
41        active_item_style: Style::new()
42            .for_stderr()
43            .white()
44            .bold()
45            .on_color256(INDIGO_BG),
46        inactive_item_style: Style::new().for_stderr().color256(252),
47        active_item_prefix: style("❯".to_string())
48            .for_stderr()
49            .color256(INDIGO_LIGHT)
50            .bold(),
51        inactive_item_prefix: style(" ".to_string()).for_stderr().color256(INDIGO_MUTED),
52        checked_item_prefix: style("✔".to_string()).for_stderr().color256(INDIGO_LIGHT),
53        unchecked_item_prefix: style("⬚".to_string()).for_stderr().color256(INDIGO_MID),
54        picked_item_prefix: style("❯".to_string()).for_stderr().color256(INDIGO_LIGHT),
55        unpicked_item_prefix: style(" ".to_string()).for_stderr(),
56        // Fuzzy search caret: white on darker indigo (not black-on-white).
57        fuzzy_cursor_style: Style::new()
58            .for_stderr()
59            .white()
60            .bold()
61            .on_color256(INDIGO_BG),
62        fuzzy_match_highlight_style: Style::new()
63            .for_stderr()
64            .color256(INDIGO_MATCH)
65            .bold()
66            .underlined(),
67    }
68}
69
70/// Ratatui selection style matching [`xbp_theme`] (dark indigo + bright text).
71/// Used by the full-screen Linear issues browser so pickers and TUI feel identical.
72pub fn xbp_selection_style() -> ratatui::style::Style {
73    use ratatui::style::{Color, Modifier, Style};
74    Style::default()
75        .bg(Color::Indexed(INDIGO_BG))
76        .fg(Color::White)
77        .add_modifier(Modifier::BOLD)
78}
79
80/// Accent color for TUI headers / borders (mid–light indigo).
81pub fn xbp_accent_color() -> ratatui::style::Color {
82    ratatui::style::Color::Indexed(INDIGO_LIGHT)
83}
84
85/// Print a short colored picker header above a select prompt.
86pub fn print_picker_header(title: &str, subtitle: &str) {
87    use colored::Colorize;
88    println!();
89    println!(
90        "{} {}",
91        "◆".truecolor(0x63, 0x66, 0xf1).bold(),
92        title.bright_white().bold()
93    );
94    if !subtitle.is_empty() {
95        println!("{}", subtitle.truecolor(0x67, 0x65, 0x8b));
96    }
97}
98
99/// Searchable single-select (type to filter). Returns `None` if cancelled.
100pub fn searchable_select<T: Display>(
101    prompt: &str,
102    items: &[T],
103    default: usize,
104) -> Result<Option<usize>, String> {
105    if items.is_empty() {
106        return Ok(None);
107    }
108    let default = default.min(items.len().saturating_sub(1));
109    FuzzySelect::with_theme(&xbp_theme())
110        .with_prompt(format!("{prompt} (type to search)"))
111        .items(items)
112        .default(default)
113        .highlight_matches(true)
114        .interact_opt()
115        .map_err(|error| format!("Prompt failed: {error}"))
116}
117
118/// Searchable single-select that errors if the user cancels (Esc).
119pub fn searchable_select_required<T: Display>(
120    prompt: &str,
121    items: &[T],
122    default: usize,
123) -> Result<usize, String> {
124    searchable_select(prompt, items, default)?
125        .ok_or_else(|| "Selection cancelled".to_string())
126}
127
128/// Non-fuzzy single select (short lists: modes, env, etc.).
129pub fn select_one<T: Display>(
130    prompt: &str,
131    items: &[T],
132    default: usize,
133) -> Result<Option<usize>, String> {
134    if items.is_empty() {
135        return Ok(None);
136    }
137    let default = default.min(items.len().saturating_sub(1));
138    Select::with_theme(&xbp_theme())
139        .with_prompt(prompt)
140        .items(items)
141        .default(default)
142        .interact_opt()
143        .map_err(|error| format!("Prompt failed: {error}"))
144}
145
146/// Searchable multi-select. Returns selected indices (may be empty).
147pub fn searchable_multi_select<T: Display>(
148    prompt: &str,
149    items: &[T],
150    defaults: &[bool],
151) -> Result<Vec<usize>, String> {
152    if items.is_empty() {
153        return Ok(Vec::new());
154    }
155    let defaults = if defaults.len() == items.len() {
156        defaults.to_vec()
157    } else {
158        vec![false; items.len()]
159    };
160    MultiSelect::with_theme(&xbp_theme())
161        .with_prompt(format!("{prompt} (space to toggle)"))
162        .items(items)
163        .defaults(&defaults)
164        .interact()
165        .map_err(|error| format!("Prompt failed: {error}"))
166}
167
168/// Confirm with the shared theme.
169pub fn confirm(prompt: &str, default: bool) -> Result<bool, String> {
170    Confirm::with_theme(&xbp_theme())
171        .with_prompt(prompt)
172        .default(default)
173        .interact()
174        .map_err(|error| format!("Prompt failed: {error}"))
175}
176
177/// Pad a string to `width` columns (char count; labels should stay ASCII-friendly).
178pub fn pad_label(s: &str, width: usize) -> String {
179    let len = s.chars().count();
180    if len >= width {
181        s.chars().take(width).collect()
182    } else {
183        format!("{s}{}", " ".repeat(width - len))
184    }
185}