use std::sync::Arc;
use rich::{Renderable, Segment, Style};
use crate::component::{Component, Context, Flow, View};
use crate::components::{fit, plain, question, text, Theme};
use crate::event::{Event, Key, KeyCode};
use crate::keymap::{keys, Binding, Keymap};
use crate::policy::{LineIo, NotInteractive};
use crate::viewport::Viewport;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Choice {
pub id: String,
pub label: String,
pub key: char,
}
impl Choice {
pub fn new(id: impl Into<String>, label: impl Into<String>, key: char) -> Choice {
Choice {
id: id.into(),
label: label.into(),
key,
}
}
}
pub struct Confirm {
title: String,
body: Vec<Arc<dyn Renderable + Send + Sync>>,
warnings: Vec<String>,
choices: Vec<Choice>,
focus: usize,
default: Option<usize>,
viewport: Viewport,
body_height: Option<usize>,
theme: Theme,
mouse: bool,
answer: Option<Option<String>>,
}
impl Confirm {
pub fn new(title: impl Into<String>) -> Confirm {
Confirm {
title: title.into(),
body: Vec::new(),
warnings: Vec::new(),
choices: vec![Choice::new("yes", "Yes", 'y'), Choice::new("no", "No", 'n')],
focus: 0,
default: None,
viewport: Viewport::default(),
body_height: None,
theme: Theme::default(),
mouse: false,
answer: None,
}
}
pub fn body(mut self, renderable: impl Renderable + Send + Sync + 'static) -> Self {
self.body.push(Arc::new(renderable));
self
}
pub fn warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
pub fn choices(mut self, choices: impl IntoIterator<Item = Choice>) -> Self {
self.choices = choices.into_iter().collect();
self.focus = 0;
self.default = None;
self
}
pub fn default(mut self, id: &str) -> Self {
if let Some(index) = self.choices.iter().position(|choice| choice.id == id) {
self.focus = index;
self.default = Some(index);
}
self
}
pub fn body_height(mut self, rows: usize) -> Self {
self.body_height = Some(rows.max(1));
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn with_mouse(mut self, on: bool) -> Self {
self.mouse = on;
self
}
fn button_at(&self, column: usize) -> Option<usize> {
let mut at = 2;
for (index, choice) in self.choices.iter().enumerate() {
let width = rich::cells::cell_len(&choice.label) + 2;
if (at..at + width).contains(&column) {
return Some(index);
}
at += width + 1;
}
None
}
fn buttons_row(&self, context: &Context<'_>) -> usize {
let body = if self.viewport.is_empty() {
self.lines(context).len()
} else {
self.viewport.len().saturating_sub(self.viewport.offset())
};
1 + body.min(self.page(context)) + self.warnings.len()
}
fn page(&self, context: &Context<'_>) -> usize {
let fixed = 3 + self.warnings.len();
let room = context.height.saturating_sub(fixed).max(1);
self.body_height.unwrap_or(room).min(room)
}
fn lines(&self, context: &Context<'_>) -> Vec<Vec<Segment>> {
let width = context.width.saturating_sub(2).max(1);
self.body
.iter()
.flat_map(|renderable| context.lines_at(&**renderable, width))
.collect()
}
fn pick(&mut self, index: usize) -> Flow<String> {
let id = self.choices[index].id.clone();
self.answer = Some(Some(self.choices[index].label.clone()));
Flow::Done(id)
}
}
impl Component for Confirm {
type Output = String;
fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<String> {
if self.viewport.is_empty() && !self.body.is_empty() {
self.viewport.set_lines(self.lines(context));
}
if let Event::Resize { .. } = event {
self.viewport.set_lines(self.lines(context));
}
let page = self.page(context);
if let Some(mouse) = event.mouse().filter(|mouse| mouse.is_click()) {
if mouse.row as usize == self.buttons_row(context) {
if let Some(index) = self.button_at(mouse.column as usize) {
return self.pick(index);
}
}
return Flow::Continue;
}
let Some(key) = event.key() else {
self.viewport.handle_scroll(event, page);
return Flow::Continue;
};
if !key.modifiers.ctrl && !key.modifiers.alt {
if let KeyCode::Char(c) = key.code {
let lower = c.to_ascii_lowercase();
if let Some(index) = self
.choices
.iter()
.position(|choice| choice.key.to_ascii_lowercase() == lower)
{
return self.pick(index);
}
}
}
let count = self.choices.len();
match key.code {
KeyCode::Enter if count > 0 => return self.pick(self.focus),
KeyCode::Escape => {
self.answer = Some(None);
return Flow::Cancel;
}
KeyCode::Left | KeyCode::BackTab => {
self.focus = (self.focus + count - 1) % count.max(1)
}
KeyCode::Right | KeyCode::Tab => self.focus = (self.focus + 1) % count.max(1),
_ => {
if !self.viewport.handle_scroll(event, page) {
return Flow::Ignored;
}
}
}
Flow::Continue
}
fn keymap(&self) -> Keymap {
let mut keymap = Keymap::new("confirm")
.bind("pick", keys("enter"), "pick the focused choice")
.bind("cancel", keys("escape"), "cancel")
.bind("previous", keys("left shift+tab"), "previous choice")
.bind("next", keys("right tab"), "next choice");
for choice in &self.choices {
keymap.add(Binding::new(
"confirm",
format!("choose-{}", choice.id),
[Key::char(choice.key)],
choice.label.clone(),
));
}
if !self.body.is_empty() {
keymap.extend(crate::kit::ScrollState::keymap("confirm"));
}
keymap
}
fn render(&self, context: &Context<'_>) -> View {
let width = context.width;
let theme = &self.theme;
let mut title = question(theme, &self.title);
title.truncate(title.len() - 3);
if let Some(answer) = &self.answer {
title.push(plain(" "));
title.push(text("›", &theme.hint));
title.push(plain(" "));
title.push(match answer {
Some(answer) => text(answer.clone(), &theme.answer),
None => text("cancelled", &theme.hint),
});
return View::new(vec![fit(title, width)]);
}
let mut lines = vec![fit(title, width)];
let page = self.page(context);
let body = if self.viewport.is_empty() {
self.lines(context)
} else {
self.viewport.lines().to_vec()
};
let offset = if self.viewport.is_empty() {
0
} else {
self.viewport.offset()
};
for line in body.iter().skip(offset).take(page) {
let mut row = vec![plain(" ")];
row.extend(line.iter().cloned());
lines.push(fit(row, width));
}
for warning in &self.warnings {
let style = Style::parse("yellow").expect("style");
lines.push(fit(vec![text(format!(" ⚠ {warning}"), &style)], width));
}
let mut row = vec![plain(" ")];
for (index, choice) in self.choices.iter().enumerate() {
let label = format!(" {} ", choice.label);
row.push(if index == self.focus {
text(
label,
&theme
.focused
.combine(&Style::parse("reverse").expect("style")),
)
} else {
plain(label)
});
row.push(plain(" "));
}
lines.push(fit(row, width));
let keys: Vec<String> = self
.choices
.iter()
.map(|choice| choice.key.to_string())
.collect();
let mut hint = format!(" {} · ←→ move · enter choose · esc cancel", keys.join("/"));
if body.len() > page {
hint.push_str(&format!(" · ↑↓ scroll ({})", self.viewport.status(page)));
}
lines.push(fit(vec![text(hint, &theme.hint)], width));
View::new(lines)
}
fn mouse(&self) -> bool {
self.mouse
}
fn default_value(&self) -> Option<String> {
self.default.map(|index| self.choices[index].id.clone())
}
fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<String>, NotInteractive> {
for warning in &self.warnings {
io.write(&format!("warning: {warning}\n"));
}
let keys: Vec<String> = self
.choices
.iter()
.map(|choice| format!("{}={}", choice.key, choice.label))
.collect();
io.write(&format!("{} [{}]: ", self.title, keys.join(", ")));
let Some(line) = io.read_line() else {
return match self.default {
Some(index) => Ok(Some(self.choices[index].id.clone())),
None => Err(NotInteractive::Ended),
};
};
let answer = line.trim().to_lowercase();
if answer.is_empty() {
if let Some(index) = self.default {
return Ok(Some(self.choices[index].id.clone()));
}
}
self.choices
.iter()
.find(|choice| {
answer == choice.key.to_string()
|| answer == choice.id.to_lowercase()
|| answer == choice.label.to_lowercase()
})
.map(|choice| Some(choice.id.clone()))
.ok_or_else(|| NotInteractive::Invalid(format!("not one of the choices: {line:?}")))
}
}