zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! Default [`Picker`](super::Picker) implementation that drives a real
//! terminal via crossterm.
//!
//! Owns no persistent state — [`TerminalPicker::pick`] enters raw mode +
//! the alt-screen, runs a render/read/dispatch loop, and unwinds both
//! through a [`TerminalGuard`] RAII drop so panics and `?`-bails restore
//! the terminal too.

use std::io::{self, Write};

use crossterm::{
    cursor, event,
    event::{Event, KeyCode, KeyEventKind, KeyModifiers},
    execute, queue, style,
    style::Stylize,
    terminal,
};

use crate::error::Error;

use super::{PickItem, PickOutcome, Picker, PickerError};

/// Picker that takes over the terminal — raw mode, alternate screen,
/// hidden cursor — for the duration of a [`Picker::pick`] call. Restores
/// every toggled bit on return (success, abort, panic, `?` propagation)
/// via an internal RAII drop guard.
pub struct TerminalPicker;

impl TerminalPicker {
    /// Construct a fresh picker. No terminal state changes until
    /// [`Picker::pick`] is called.
    pub fn new() -> Self {
        Self
    }
}

impl Default for TerminalPicker {
    fn default() -> Self {
        Self::new()
    }
}

impl Picker for TerminalPicker {
    fn pick(&mut self, title: &str, items: &mut [PickItem<'_>]) -> Result<PickOutcome, Error> {
        let _guard = TerminalGuard::enter()?;
        let mut stdout = io::stdout();

        let mut cursor_row: usize = 0;
        let choice_col_width = max_choice_label_width(items);
        loop {
            render(&mut stdout, title, items, cursor_row, choice_col_width)
                .map_err(PickerError::Io)?;

            match read_action().map_err(PickerError::Io)? {
                Action::CursorUp => {
                    if !items.is_empty() {
                        cursor_row = if cursor_row == 0 {
                            items.len() - 1
                        } else {
                            cursor_row - 1
                        };
                    }
                }
                Action::CursorDown => {
                    if !items.is_empty() {
                        cursor_row = (cursor_row + 1) % items.len();
                    }
                }
                Action::Cycle => {
                    if let Some(item) = items.get_mut(cursor_row)
                        && item.choices.len() > 1
                    {
                        item.choice = (item.choice + 1) % item.choices.len();
                    }
                }
                Action::Apply => return Ok(PickOutcome::Apply),
                Action::Abort => return Ok(PickOutcome::Abort),
                Action::Ignore => {}
            }
        }
    }
}

/// One interpreted keystroke from the picker's input loop. The raw
/// crossterm `KeyEvent` is collapsed into this small set so the dispatch
/// in `Picker::pick` reads as a flat match.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
    CursorUp,
    CursorDown,
    Cycle,
    Apply,
    Abort,
    Ignore,
}

/// Translate one [`Event`] into an [`Action`]. Filters out non-key
/// events (resize, mouse, focus), key-release events on platforms that
/// surface them (Windows), and modifier-loaded printables we don't
/// recognise.
fn classify_event(ev: Event) -> Action {
    let Event::Key(key) = ev else {
        return Action::Ignore;
    };
    if key.kind == KeyEventKind::Release {
        return Action::Ignore;
    }
    if key.modifiers.contains(KeyModifiers::CONTROL) {
        return match key.code {
            KeyCode::Char('c' | 'd') => Action::Abort,
            _ => Action::Ignore,
        };
    }
    match key.code {
        KeyCode::Up | KeyCode::Char('k') => Action::CursorUp,
        KeyCode::Down | KeyCode::Char('j') => Action::CursorDown,
        KeyCode::Char(' ') => Action::Cycle,
        KeyCode::Enter => Action::Apply,
        KeyCode::Esc | KeyCode::Char('q') => Action::Abort,
        _ => Action::Ignore,
    }
}

fn read_action() -> io::Result<Action> {
    loop {
        match classify_event(event::read()?) {
            Action::Ignore => continue,
            a => return Ok(a),
        }
    }
}

fn max_choice_label_width(items: &[PickItem<'_>]) -> usize {
    items
        .iter()
        .flat_map(|i| i.choices.iter())
        .map(|c| c.label.chars().count())
        .max()
        .unwrap_or(0)
}

/// Full-redraw renderer. Cheap at the row counts we care about and
/// avoids the bookkeeping a diffing renderer would need.
fn render(
    out: &mut io::Stdout,
    title: &str,
    items: &[PickItem<'_>],
    cursor_row: usize,
    choice_col_width: usize,
) -> io::Result<()> {
    queue!(
        out,
        terminal::Clear(terminal::ClearType::All),
        cursor::MoveTo(0, 0),
    )?;
    writeln!(out, "{}", style::style(title).bold())?;
    queue!(out, cursor::MoveToColumn(0))?;
    writeln!(out)?;
    queue!(out, cursor::MoveToColumn(0))?;

    for (idx, item) in items.iter().enumerate() {
        let is_cursor = idx == cursor_row;
        let pointer = if is_cursor { "" } else { " " };
        let choice = &item.choices[item.choice];
        let bracket = format!("[{:<width$}]", choice.label, width = choice_col_width);
        let bracket_styled = if item.choices.len() == 1 {
            style::style(bracket).dim().to_string()
        } else if is_cursor {
            style::style(bracket).cyan().to_string()
        } else {
            bracket
        };
        write!(out, "{pointer} {bracket_styled}  {}", item.label)?;
        if let Some(note) = &item.note {
            write!(out, "  {}", style::style(format!("({note})")).dim())?;
        }
        writeln!(out)?;
        queue!(out, cursor::MoveToColumn(0))?;
    }

    writeln!(out)?;
    queue!(out, cursor::MoveToColumn(0))?;
    let hint = "↑↓ move   space cycle   enter apply   q abort";
    writeln!(out, "{}", style::style(hint).dim())?;
    out.flush()
}

/// RAII guard that enters raw mode + the alt screen + hides the cursor on
/// construction and undoes all three on drop. Drop is best-effort — we
/// can't surface an I/O failure from the panic path, and the cleanup is
/// cosmetic at that point.
struct TerminalGuard;

impl TerminalGuard {
    fn enter() -> Result<Self, PickerError> {
        terminal::enable_raw_mode().map_err(PickerError::Io)?;
        if let Err(e) = execute!(io::stdout(), terminal::EnterAlternateScreen, cursor::Hide,) {
            let _ = terminal::disable_raw_mode();
            return Err(PickerError::Io(e));
        }
        Ok(Self)
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen);
        let _ = terminal::disable_raw_mode();
    }
}

#[cfg(test)]
mod tests {
    use std::borrow::Cow;

    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
    use similar_asserts::assert_eq;

    use super::*;
    use crate::picker::ChoiceLabel;

    fn key(code: KeyCode, mods: KeyModifiers) -> Event {
        Event::Key(KeyEvent {
            code,
            modifiers: mods,
            kind: KeyEventKind::Press,
            state: KeyEventState::NONE,
        })
    }

    #[test]
    fn arrow_and_letter_keys_move_cursor() {
        assert_eq!(
            classify_event(key(KeyCode::Up, KeyModifiers::NONE)),
            Action::CursorUp,
        );
        assert_eq!(
            classify_event(key(KeyCode::Char('k'), KeyModifiers::NONE)),
            Action::CursorUp,
        );
        assert_eq!(
            classify_event(key(KeyCode::Down, KeyModifiers::NONE)),
            Action::CursorDown,
        );
        assert_eq!(
            classify_event(key(KeyCode::Char('j'), KeyModifiers::NONE)),
            Action::CursorDown,
        );
    }

    #[test]
    fn space_cycles_enter_applies_q_aborts() {
        assert_eq!(
            classify_event(key(KeyCode::Char(' '), KeyModifiers::NONE)),
            Action::Cycle,
        );
        assert_eq!(
            classify_event(key(KeyCode::Enter, KeyModifiers::NONE)),
            Action::Apply,
        );
        assert_eq!(
            classify_event(key(KeyCode::Char('q'), KeyModifiers::NONE)),
            Action::Abort,
        );
        assert_eq!(
            classify_event(key(KeyCode::Esc, KeyModifiers::NONE)),
            Action::Abort,
        );
    }

    #[test]
    fn ctrl_c_and_ctrl_d_abort_other_modified_keys_ignored() {
        assert_eq!(
            classify_event(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            Action::Abort,
        );
        assert_eq!(
            classify_event(key(KeyCode::Char('d'), KeyModifiers::CONTROL)),
            Action::Abort,
        );
        assert_eq!(
            classify_event(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
            Action::Ignore,
        );
        assert_eq!(
            classify_event(key(KeyCode::Char(' '), KeyModifiers::CONTROL)),
            Action::Ignore,
        );
    }

    #[test]
    fn key_release_events_are_ignored() {
        let release = Event::Key(KeyEvent {
            code: KeyCode::Char('q'),
            modifiers: KeyModifiers::NONE,
            kind: KeyEventKind::Release,
            state: KeyEventState::NONE,
        });
        assert_eq!(classify_event(release), Action::Ignore);
    }

    #[test]
    fn non_key_events_are_ignored() {
        assert_eq!(classify_event(Event::Resize(80, 24)), Action::Ignore);
        assert_eq!(classify_event(Event::FocusGained), Action::Ignore);
    }

    #[test]
    fn max_choice_label_width_picks_longest_across_items() {
        let two = [ChoiceLabel::new("a", "do"), ChoiceLabel::new("b", "skip")];
        let three = [
            ChoiceLabel::new("a", "remove"),
            ChoiceLabel::new("b", "keep"),
        ];
        let items = [
            PickItem {
                label: Cow::Borrowed("x"),
                note: None,
                choices: &two,
                choice: 0,
            },
            PickItem {
                label: Cow::Borrowed("y"),
                note: None,
                choices: &three,
                choice: 0,
            },
        ];
        assert_eq!(max_choice_label_width(&items), 6);
    }

    #[test]
    fn max_choice_label_width_empty_returns_zero() {
        assert_eq!(max_choice_label_width(&[]), 0);
    }
}