zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! Interactive multi-row picker used by `zenops import` and (eventually)
//! every other command that needs the user to weigh in on each item of a
//! plan before it lands.
//!
//! The trait, [`Picker`], deliberately mirrors [`crate::prompt::Prompter`]:
//! both take a borrowed view of the work the command wants to do and let
//! the user shape it before any filesystem effect runs. The difference is
//! scope — [`Prompter`](crate::prompt::Prompter) confirms one change at a
//! time; [`Picker`] presents the whole set at once and lets the user
//! cursor through it, cycling each row's chosen action.
//!
//! Choices are indexed into a per-item `&[ChoiceLabel]` slice rather than
//! parameterised on a caller enum. That keeps the trait `dyn`-safe and
//! lets a single picker session render rows whose intrinsic shapes differ
//! (e.g. `import` reconcile mode mixes "move-and-symlink" rows with
//! "remove from repo" rows). Callers map [`PickItem::choice`] back to
//! their own enum at the boundary via [`ChoiceLabel::key`].
//!
//! Rows whose `choices` slice has length 1 are *disabled*: the cursor
//! still lands on them so the user can read the explanation in
//! [`PickItem::note`], but `space` is a no-op. This is intentional UX —
//! a hidden row reads as a bug ("why didn't `.git` show up?"); a visible
//! disabled row with a "vcs directory" note explains itself.

mod error;
mod scripted;
mod terminal;

pub use error::Error as PickerError;
pub use scripted::{ScriptedPicker, ScriptedStep};
pub use terminal::TerminalPicker;

use std::borrow::Cow;

use smol_str::SmolStr;

use crate::error::Error;

/// One row in a picker session.
///
/// The picker mutates [`Self::choice`] in place — callers read it back to
/// learn which action the user selected.
pub struct PickItem<'a> {
    /// Primary text. Shown on the row's main line.
    pub label: Cow<'a, str>,
    /// Secondary, dimmed text. Used to explain *why* a row is what it is
    /// — the home-side path of a removed file, the reason a row is
    /// disabled (vcs dir, symlink already in place), the rename target.
    /// Omit (`None`) when the label says it all.
    pub note: Option<Cow<'a, str>>,
    /// Allowed actions for this row, in cycle order. `space` advances the
    /// index modulo `choices.len()`. A single-entry slice marks the row
    /// disabled (cursor lands on it, but `space` is a no-op).
    pub choices: &'a [ChoiceLabel],
    /// Currently-selected index into [`Self::choices`]. The picker mutates
    /// this in place; callers seed it with the default action and read it
    /// back after the session.
    pub choice: usize,
}

/// One choice on a picker row's action cycle.
///
/// [`Self::key`] is a stable identifier callers match on after the
/// picker returns; [`Self::label`] is the human-readable string shown
/// inside `[ ]` on the row. They're distinct so the displayed text can
/// be tuned for the user without breaking the call-site dispatch logic.
#[derive(Debug, Clone)]
pub struct ChoiceLabel {
    /// Stable identifier — what the caller matches on.
    pub key: SmolStr,
    /// Human-readable text shown in the picker row's `[ ]` bracket.
    pub label: SmolStr,
}

impl ChoiceLabel {
    /// Construct a [`ChoiceLabel`] from a stable key + display label.
    pub fn new(key: impl Into<SmolStr>, label: impl Into<SmolStr>) -> Self {
        Self {
            key: key.into(),
            label: label.into(),
        }
    }
}

/// What the user chose to do at the end of a picker session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickOutcome {
    /// User pressed `enter`: apply the items with their current
    /// [`PickItem::choice`] values.
    Apply,
    /// User pressed `q` / `esc` / `ctrl-c`: callers should bail.
    Abort,
}

/// How a command consults the user before acting on a multi-item plan.
///
/// Borrowed mutably because real impls hold terminal state (raw mode,
/// alt-screen) and test impls hold a script of canned actions.
pub trait Picker {
    /// Render `items` under `title`, let the user cursor through them
    /// cycling each row's [`PickItem::choice`], and return what they
    /// decided when they pressed `enter` or `q`.
    fn pick(&mut self, title: &str, items: &mut [PickItem<'_>]) -> Result<PickOutcome, Error>;
}