xbp 10.57.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Shared interactive pickers (type-to-filter FuzzySelect + ANSI theme).
//!
//! Used by `xbp deploy`, `xbp service`, `xbp linear`, versioning, etc.
//! Cursor / active-row highlight uses a dark indigo 256-color background so
//! selection stays readable against busy multi-column labels.

use console::{style, Style};
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, FuzzySelect, MultiSelect, Select};
use std::fmt::Display;

/// Dark indigo background (~indigo-900) for the fuzzy cursor + active row.
const INDIGO_BG: u8 = 54;
/// Mid indigo accent for prefixes / hints.
const INDIGO_MID: u8 = 61;
/// Brighter indigo/lavender for accents and match highlights.
const INDIGO_LIGHT: u8 = 141;
/// Soft lavender for fuzzy match characters.
const INDIGO_MATCH: u8 = 183;
/// Muted indigo-gray for dim secondary text.
const INDIGO_MUTED: u8 = 103;

/// Rich dialoguer theme used across XBP interactive prompts.
///
/// Darker indigo cursor/active highlight (not the default black-on-white
/// fuzzy cursor), with stronger ANSI separation between prompt, values,
/// and inactive rows — same visual language as the Linear TUI selection.
pub fn xbp_theme() -> ColorfulTheme {
    ColorfulTheme {
        defaults_style: Style::new().for_stderr().color256(INDIGO_LIGHT),
        prompt_style: Style::new().for_stderr().white().bold(),
        prompt_prefix: style("".to_string()).for_stderr().color256(INDIGO_LIGHT).bold(),
        prompt_suffix: style("".to_string()).for_stderr().color256(INDIGO_MID),
        success_prefix: style("".to_string()).for_stderr().green().bright(),
        success_suffix: style("·".to_string()).for_stderr().black().bright(),
        error_prefix: style("".to_string()).for_stderr().red().bright(),
        error_style: Style::new().for_stderr().red().bright(),
        hint_style: Style::new().for_stderr().color256(INDIGO_MUTED),
        values_style: Style::new().for_stderr().color256(INDIGO_LIGHT).bold(),
        // Active row: white bold on dark indigo (high contrast, matches Linear TUI).
        active_item_style: Style::new()
            .for_stderr()
            .white()
            .bold()
            .on_color256(INDIGO_BG),
        inactive_item_style: Style::new().for_stderr().color256(252),
        active_item_prefix: style("".to_string())
            .for_stderr()
            .color256(INDIGO_LIGHT)
            .bold(),
        inactive_item_prefix: style(" ".to_string()).for_stderr().color256(INDIGO_MUTED),
        checked_item_prefix: style("".to_string()).for_stderr().color256(INDIGO_LIGHT),
        unchecked_item_prefix: style("".to_string()).for_stderr().color256(INDIGO_MID),
        picked_item_prefix: style("".to_string()).for_stderr().color256(INDIGO_LIGHT),
        unpicked_item_prefix: style(" ".to_string()).for_stderr(),
        // Fuzzy search caret: white on darker indigo (not black-on-white).
        fuzzy_cursor_style: Style::new()
            .for_stderr()
            .white()
            .bold()
            .on_color256(INDIGO_BG),
        fuzzy_match_highlight_style: Style::new()
            .for_stderr()
            .color256(INDIGO_MATCH)
            .bold()
            .underlined(),
    }
}

/// Ratatui selection style matching [`xbp_theme`] (dark indigo + bright text).
/// Used by the full-screen Linear issues browser so pickers and TUI feel identical.
pub fn xbp_selection_style() -> ratatui::style::Style {
    use ratatui::style::{Color, Modifier, Style};
    Style::default()
        .bg(Color::Indexed(INDIGO_BG))
        .fg(Color::White)
        .add_modifier(Modifier::BOLD)
}

/// Accent color for TUI headers / borders (mid–light indigo).
pub fn xbp_accent_color() -> ratatui::style::Color {
    ratatui::style::Color::Indexed(INDIGO_LIGHT)
}

/// Print a short colored picker header above a select prompt.
pub fn print_picker_header(title: &str, subtitle: &str) {
    use colored::Colorize;
    println!();
    println!(
        "{} {}",
        "".truecolor(0x63, 0x66, 0xf1).bold(),
        title.bright_white().bold()
    );
    if !subtitle.is_empty() {
        println!("{}", subtitle.truecolor(0x67, 0x65, 0x8b));
    }
}

/// Searchable single-select (type to filter). Returns `None` if cancelled.
pub fn searchable_select<T: Display>(
    prompt: &str,
    items: &[T],
    default: usize,
) -> Result<Option<usize>, String> {
    if items.is_empty() {
        return Ok(None);
    }
    let default = default.min(items.len().saturating_sub(1));
    FuzzySelect::with_theme(&xbp_theme())
        .with_prompt(format!("{prompt} (type to search)"))
        .items(items)
        .default(default)
        .highlight_matches(true)
        .interact_opt()
        .map_err(|error| format!("Prompt failed: {error}"))
}

/// Searchable single-select that errors if the user cancels (Esc).
pub fn searchable_select_required<T: Display>(
    prompt: &str,
    items: &[T],
    default: usize,
) -> Result<usize, String> {
    searchable_select(prompt, items, default)?
        .ok_or_else(|| "Selection cancelled".to_string())
}

/// Non-fuzzy single select (short lists: modes, env, etc.).
pub fn select_one<T: Display>(
    prompt: &str,
    items: &[T],
    default: usize,
) -> Result<Option<usize>, String> {
    if items.is_empty() {
        return Ok(None);
    }
    let default = default.min(items.len().saturating_sub(1));
    Select::with_theme(&xbp_theme())
        .with_prompt(prompt)
        .items(items)
        .default(default)
        .interact_opt()
        .map_err(|error| format!("Prompt failed: {error}"))
}

/// Searchable multi-select. Returns selected indices (may be empty).
pub fn searchable_multi_select<T: Display>(
    prompt: &str,
    items: &[T],
    defaults: &[bool],
) -> Result<Vec<usize>, String> {
    if items.is_empty() {
        return Ok(Vec::new());
    }
    let defaults = if defaults.len() == items.len() {
        defaults.to_vec()
    } else {
        vec![false; items.len()]
    };
    MultiSelect::with_theme(&xbp_theme())
        .with_prompt(format!("{prompt} (space to toggle)"))
        .items(items)
        .defaults(&defaults)
        .interact()
        .map_err(|error| format!("Prompt failed: {error}"))
}

/// Confirm with the shared theme.
pub fn confirm(prompt: &str, default: bool) -> Result<bool, String> {
    Confirm::with_theme(&xbp_theme())
        .with_prompt(prompt)
        .default(default)
        .interact()
        .map_err(|error| format!("Prompt failed: {error}"))
}

/// Pad a string to `width` columns (char count; labels should stay ASCII-friendly).
pub fn pad_label(s: &str, width: usize) -> String {
    let len = s.chars().count();
    if len >= width {
        s.chars().take(width).collect()
    } else {
        format!("{s}{}", " ".repeat(width - len))
    }
}