a3s-tui 0.1.4

TEA (The Elm Architecture) framework for terminal user interfaces
Documentation
use crate::element::{BorderStyle, BoxElement, Element, FlexDirection, TextElement};
use crate::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use crate::style::{center_visible, fit_visible, wrap_words, Color, Style};
use crossterm::event::KeyCode;

/// A confirmation dialog with Yes/No options.
pub struct Confirm {
    title: Option<String>,
    message: String,
    selected: bool,
    yes_label: String,
    no_label: String,
    hint: Option<String>,
    max_width: usize,
    fg: Color,
    bg: Option<Color>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmMsg {
    Confirmed,
    Cancelled,
}

impl Confirm {
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            title: None,
            message: message.into(),
            selected: true,
            yes_label: "Yes".to_string(),
            no_label: "No".to_string(),
            hint: None,
            max_width: 58,
            fg: Color::BrightWhite,
            bg: None,
        }
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    pub fn with_labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
        self.yes_label = yes.into();
        self.no_label = no.into();
        self
    }

    pub fn hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }

    pub fn selected(mut self, yes_selected: bool) -> Self {
        self.selected = yes_selected;
        self
    }

    pub fn max_width(mut self, width: usize) -> Self {
        self.max_width = width.max(8);
        self
    }

    pub fn colors(mut self, fg: Color, bg: Option<Color>) -> Self {
        self.fg = fg;
        self.bg = bg;
        self
    }

    pub fn danger(mut self) -> Self {
        self.fg = Color::BrightWhite;
        self.bg = Some(Color::Red);
        self
    }

    pub fn handle_key(&mut self, key: &KeyEvent) -> Option<ConfirmMsg> {
        match key.code {
            KeyCode::Left | KeyCode::Char('h') | KeyCode::Tab => {
                self.selected = !self.selected;
                None
            }
            KeyCode::Right | KeyCode::Char('l') => {
                self.selected = !self.selected;
                None
            }
            KeyCode::Char('y') | KeyCode::Char('Y') => Some(ConfirmMsg::Confirmed),
            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Some(ConfirmMsg::Cancelled),
            KeyCode::Enter => {
                if self.selected {
                    Some(ConfirmMsg::Confirmed)
                } else {
                    Some(ConfirmMsg::Cancelled)
                }
            }
            _ => None,
        }
    }

    pub fn handle_mouse(&mut self, mouse: &MouseEvent) -> Option<ConfirmMsg> {
        match mouse.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if self.selected {
                    Some(ConfirmMsg::Confirmed)
                } else {
                    Some(ConfirmMsg::Cancelled)
                }
            }
            _ => None,
        }
    }

    pub fn element<Msg>(&self) -> Element<Msg> {
        let mut children = Vec::new();
        if let Some(title) = self.title.as_deref().filter(|title| !title.is_empty()) {
            children.push(Element::Text(
                TextElement::new(title).bold().fg(Color::BrightWhite),
            ));
        }
        children.push(Element::Text(TextElement::new(&self.message).bold()));

        let yes_el = if self.selected {
            Element::Text(
                TextElement::new(format!(" [{}] ", self.yes_label))
                    .bold()
                    .fg(Color::White)
                    .bg(Color::Green),
            )
        } else {
            Element::Text(
                TextElement::new(format!("  {}  ", self.yes_label)).fg(Color::BrightBlack),
            )
        };

        let no_el = if !self.selected {
            Element::Text(
                TextElement::new(format!(" [{}] ", self.no_label))
                    .bold()
                    .fg(Color::White)
                    .bg(Color::Red),
            )
        } else {
            Element::Text(TextElement::new(format!("  {}  ", self.no_label)).fg(Color::BrightBlack))
        };

        children.push(Element::Box(
            BoxElement::new()
                .direction(FlexDirection::Row)
                .gap(2)
                .child(yes_el)
                .child(no_el),
        ));
        if let Some(hint) = self.hint.as_deref().filter(|hint| !hint.is_empty()) {
            children.push(Element::Text(TextElement::new(hint).fg(Color::BrightBlack)));
        }

        Element::Box(
            BoxElement::new()
                .direction(FlexDirection::Column)
                .border(BorderStyle::Rounded)
                .border_color(Color::BrightBlack)
                .padding(1)
                .gap(1)
                .children(children),
        )
    }

    /// Render a horizontally centered confirmation box.
    pub fn box_view(&self, screen_width: u16) -> String {
        let screen_width = screen_width as usize;
        if screen_width == 0 {
            return String::new();
        }

        let max_inner = screen_width.saturating_sub(2);
        if max_inner == 0 {
            return fit_visible("", screen_width);
        }
        let inner = self.max_width.min(max_inner).max(max_inner.min(8));
        let border = "".repeat(inner);
        let mut rows = vec![format!("{border}")];

        if let Some(title) = self.title.as_deref().filter(|title| !title.is_empty()) {
            rows.push(format!("{}", center_visible(title, inner)));
            rows.push(format!("{}", " ".repeat(inner)));
        }

        for line in wrap_words(&self.message, inner.saturating_sub(4).max(1)) {
            rows.push(format!("{}", center_visible(&line, inner)));
        }

        rows.push(format!("{}", " ".repeat(inner)));
        rows.push(format!("{}", center_visible(&self.prompt(), inner)));

        if let Some(hint) = self.hint.as_deref().filter(|hint| !hint.is_empty()) {
            rows.push(format!("{}", center_visible(hint, inner)));
        }

        rows.push(format!("{border}"));

        let left = screen_width.saturating_sub(inner + 2) / 2;
        rows.into_iter()
            .map(|row| format!("{}{}", " ".repeat(left), self.style_line(&row)))
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// Render a full-screen confirmation view centered in both axes.
    pub fn view(&self, screen_width: u16, screen_height: u16) -> String {
        let width = screen_width as usize;
        let height = screen_height as usize;
        if width == 0 || height == 0 {
            return String::new();
        }

        let box_view = self.box_view(screen_width);
        let box_lines = box_view.lines().collect::<Vec<_>>();
        let top = height.saturating_sub(box_lines.len()) / 2;
        let mut lines = Vec::with_capacity(height);
        for _ in 0..top {
            lines.push(String::new());
        }
        lines.extend(box_lines.into_iter().map(ToString::to_string));
        lines.truncate(height);
        while lines.len() < height {
            lines.push(String::new());
        }

        lines
            .into_iter()
            .map(|line| fit_visible(&line, width))
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn prompt(&self) -> String {
        if self.bg.is_some() {
            format!(
                "[ {} / Enter ] confirm     [ {} / Esc ] cancel",
                self.yes_label, self.no_label
            )
        } else {
            let yes = if self.selected {
                format!("[{}]", self.yes_label)
            } else {
                self.yes_label.clone()
            };
            let no = if !self.selected {
                format!("[{}]", self.no_label)
            } else {
                self.no_label.clone()
            };
            format!("{yes} confirm     {no} cancel")
        }
    }

    fn style_line(&self, line: &str) -> String {
        let mut style = Style::new().fg(self.fg).bold();
        if let Some(bg) = self.bg {
            style = style.bg(bg);
        }
        style.render(line)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::style::visible_len;
    use crossterm::event::KeyModifiers;

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent {
            code,
            modifiers: KeyModifiers::NONE,
        }
    }

    #[test]
    fn default_selects_yes() {
        let confirm = Confirm::new("Delete?");
        assert!(confirm.selected);
    }

    #[test]
    fn enter_confirms_when_yes_selected() {
        let mut confirm = Confirm::new("Delete?");
        let msg = confirm.handle_key(&key(KeyCode::Enter));
        assert_eq!(msg, Some(ConfirmMsg::Confirmed));
    }

    #[test]
    fn enter_cancels_when_no_selected() {
        let mut confirm = Confirm::new("Delete?");
        confirm.handle_key(&key(KeyCode::Right));
        let msg = confirm.handle_key(&key(KeyCode::Enter));
        assert_eq!(msg, Some(ConfirmMsg::Cancelled));
    }

    #[test]
    fn y_key_confirms() {
        let mut confirm = Confirm::new("Sure?");
        let msg = confirm.handle_key(&key(KeyCode::Char('y')));
        assert_eq!(msg, Some(ConfirmMsg::Confirmed));
    }

    #[test]
    fn n_key_cancels() {
        let mut confirm = Confirm::new("Sure?");
        let msg = confirm.handle_key(&key(KeyCode::Char('n')));
        assert_eq!(msg, Some(ConfirmMsg::Cancelled));
    }

    #[test]
    fn esc_cancels() {
        let mut confirm = Confirm::new("Sure?");
        let msg = confirm.handle_key(&key(KeyCode::Esc));
        assert_eq!(msg, Some(ConfirmMsg::Cancelled));
    }

    #[test]
    fn tab_toggles_selection() {
        let mut confirm = Confirm::new("Sure?");
        assert!(confirm.selected);
        confirm.handle_key(&key(KeyCode::Tab));
        assert!(!confirm.selected);
        confirm.handle_key(&key(KeyCode::Tab));
        assert!(confirm.selected);
    }

    #[test]
    fn custom_labels() {
        let confirm = Confirm::new("Proceed?").with_labels("Continue", "Abort");
        assert_eq!(confirm.yes_label, "Continue");
        assert_eq!(confirm.no_label, "Abort");
    }

    #[test]
    fn box_view_centers_and_truncates_to_screen_width() {
        let rendered = Confirm::new("Terminate process with a long label?")
            .title("Terminate process?")
            .max_width(24)
            .box_view(32);
        let plain = crate::style::strip_ansi(&rendered);

        assert!(plain.contains("Terminate process?"));
        for line in rendered.lines() {
            assert!(visible_len(line) <= 32, "{line:?}");
        }
    }

    #[test]
    fn danger_view_fills_requested_screen() {
        let rendered = Confirm::new("PID 42")
            .title("FORCE-KILL THIS PROCESS?")
            .with_labels("Y", "N")
            .danger()
            .view(48, 10);

        assert_eq!(rendered.lines().count(), 10);
        assert!(rendered.contains("\x1b["));
        for line in rendered.lines() {
            assert_eq!(visible_len(line), 48, "{line:?}");
        }
    }

    #[test]
    fn selected_no_is_visible_in_prompt() {
        let rendered = Confirm::new("Continue?").selected(false).box_view(40);
        let plain = crate::style::strip_ansi(&rendered);

        assert!(plain.contains("[No] cancel"));
    }
}