use crate::core::render::Rendered;
use crate::core::style::Style;
use crate::core::terminal::is_input_tty;
use crate::core::text::{Line, Span};
use crate::core::theme::{Theme, theme};
use crate::error::{Result, SparcliError};
use crate::input::Outcome;
use crate::input::event::{CrosstermSource, EventSource, InputEvent, KeyCode};
use crate::input::guard::TerminalGuard;
use crate::input::prompt::{Flow, run_prompt};
use crate::input::shortcut::{self, Shortcut};
struct State {
yes: bool,
help: bool,
}
pub struct Confirm {
question: String,
default_yes: bool,
yes_label: String,
no_label: String,
shortcuts: Vec<Shortcut>,
}
impl Confirm {
pub fn new(question: impl Into<String>) -> Self {
Self {
question: question.into(),
default_yes: false,
yes_label: "Yes".to_string(),
no_label: "No".to_string(),
shortcuts: Vec::new(),
}
}
#[must_use]
pub fn shortcuts<I>(mut self, shortcuts: I) -> Self
where
I: IntoIterator<Item = Shortcut>,
{
self.shortcuts = shortcuts.into_iter().collect();
self
}
#[must_use]
pub fn default_yes(mut self) -> Self {
self.default_yes = true;
self
}
#[must_use]
pub fn 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 run(self) -> Result<Outcome<bool>> {
if !is_input_tty() {
return Err(SparcliError::NoTerminal);
}
let _guard = TerminalGuard::new()?;
let mut source = CrosstermSource;
self.run_with(&mut source)
}
fn run_with(&self, source: &mut impl EventSource) -> Result<Outcome<bool>> {
let mut state = State {
yes: self.default_yes,
help: false,
};
run_prompt(
source,
&mut state,
|state, _| self.render(state),
|state, event| self.handle(state, event),
)
}
pub fn frame(&self) -> Rendered {
let state = State {
yes: self.default_yes,
help: false,
};
self.render(&state)
}
fn render(&self, state: &State) -> Rendered {
let theme = theme();
if state.help {
return Rendered::new(shortcut::help_overlay(&self.shortcuts));
}
let mut spans = vec![Span::raw(format!("{} ", self.question))];
spans.push(option_span(&self.yes_label, state.yes, &theme));
spans.push(Span::raw(" "));
spans.push(option_span(&self.no_label, !state.yes, &theme));
let mut lines = vec![Line::new(spans)];
if !self.shortcuts.is_empty() {
lines.push(shortcut::hint_line(&self.shortcuts));
}
Rendered::new(lines)
}
fn handle(&self, state: &mut State, event: InputEvent) -> Flow<bool> {
let InputEvent::Key(key) = event else {
return Flow::Continue;
};
if state.help {
state.help = false;
return Flow::Continue;
}
if key.is_ctrl('c') {
return Flow::Cancel;
}
if key.code == KeyCode::Char('?') && !self.shortcuts.is_empty() {
state.help = true;
return Flow::Continue;
}
if let Some(id) = shortcut::find(key, &self.shortcuts) {
return Flow::Shortcut(id);
}
match key.code {
KeyCode::Esc => Flow::Cancel,
KeyCode::Enter => Flow::Submit(state.yes),
KeyCode::Char('y') | KeyCode::Char('Y') => Flow::Submit(true),
KeyCode::Char('n') | KeyCode::Char('N') => Flow::Submit(false),
KeyCode::Left
| KeyCode::Right
| KeyCode::Tab
| KeyCode::Char('h')
| KeyCode::Char('l') => {
state.yes = !state.yes;
Flow::Continue
}
_ => Flow::Continue,
}
}
}
fn option_span(label: &str, selected: bool, theme: &Theme) -> Span {
if selected {
Span::styled(format!("[{label}]"), theme.selection)
} else {
Span::styled(format!(" {label} "), Style::new().dim())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::event::ScriptedSource;
#[test]
fn y_key_submits_true() {
let outcome = Confirm::new("ok?")
.run_with(&mut ScriptedSource::keys([KeyCode::Char('y')]))
.unwrap();
assert_eq!(outcome, Outcome::Submitted(true));
}
#[test]
fn enter_uses_default() {
let outcome = Confirm::new("ok?")
.default_yes()
.run_with(&mut ScriptedSource::keys([KeyCode::Enter]))
.unwrap();
assert_eq!(outcome, Outcome::Submitted(true));
}
#[test]
fn arrow_toggles_selection() {
let outcome = Confirm::new("ok?")
.default_yes()
.run_with(&mut ScriptedSource::keys([
KeyCode::Left,
KeyCode::Enter,
]))
.unwrap();
assert_eq!(outcome, Outcome::Submitted(false));
}
#[test]
fn esc_cancels() {
let outcome = Confirm::new("ok?")
.run_with(&mut ScriptedSource::keys([KeyCode::Esc]))
.unwrap();
assert_eq!(outcome, Outcome::Cancelled);
}
#[test]
fn shortcut_ends_with_its_id() {
use crate::input::event::{InputEvent, KeyPress};
use crate::input::shortcut::Shortcut;
let outcome = Confirm::new("ok?")
.shortcuts([Shortcut::new(KeyPress::ctrl('r'), 3, "reset")])
.run_with(&mut ScriptedSource::events([InputEvent::Key(
KeyPress::ctrl('r'),
)]))
.unwrap();
assert_eq!(outcome, Outcome::Shortcut(3));
}
}