use std::{collections::HashSet, io};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, ListState, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
use super::{
chrome,
input::{self, TextCursor},
layout::{centered_rect, fit},
nav,
overlay::{self, PopupFlow, popup},
scroll, shortcut_hints, style,
terminal::Tui,
};
use crate::theme::{Palette, Skin};
const HINT_BLOCK_ROWS: u16 = 2;
pub enum ModalSignal<T> {
Value(T),
Cancelled,
Quit,
}
pub fn confirm(
tui: &mut Tui,
skin: &Skin,
prompt: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<bool>> {
confirm_default(tui, skin, &Question::new(prompt), render_bg)
}
#[derive(Debug, Clone, Copy)]
pub struct Question<'a> {
pub prompt: &'a str,
pub default_yes: bool,
}
impl<'a> Question<'a> {
#[must_use]
pub fn new(prompt: &'a str) -> Self {
Self {
prompt,
default_yes: true,
}
}
#[must_use]
pub fn declining(prompt: &'a str) -> Self {
Self {
prompt,
default_yes: false,
}
}
fn hints(&self) -> [(&'static str, &'static str); 2] {
if self.default_yes {
[("enter/y", "yes"), ("n", "no")]
} else {
[("y", "yes"), ("enter/n", "no")]
}
}
}
pub fn confirm_default(
tui: &mut Tui,
skin: &Skin,
question: &Question<'_>,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<bool>> {
let prompt = question.prompt;
let default_yes = question.default_yes;
let mut state = ();
popup(
tui,
&mut state,
|area, (): &()| {
let width = fit(prompt.width() as u16 + 6, 28, area.width);
centered_rect(width, hinted_box_height(), area)
},
|frame, (): &()| render_bg(frame),
|frame, rect, (): &()| render_confirm(frame, skin, question, rect),
|(): &mut (), key| match key.code {
KeyCode::Char('y' | 'Y') => PopupFlow::Done(true),
KeyCode::Char('n' | 'N') | KeyCode::Esc => PopupFlow::Done(false),
KeyCode::Enter => PopupFlow::Done(default_yes),
_ => PopupFlow::Continue,
},
)
}
pub fn input(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<String>> {
input_impl(tui, skin, title, initial, input_area, render_bg)
}
pub fn input_wide(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<String>> {
input_impl(tui, skin, title, initial, input_area_wide, render_bg)
}
fn input_impl(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: &str,
area: impl Fn(Rect) -> Rect,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<String>> {
let mut state = TextField {
cursor: TextCursor::at_end(initial),
text: initial.to_string(),
};
popup(
tui,
&mut state,
|rect, _| area(rect),
|frame, _| render_bg(frame),
|frame, rect, field: &TextField| {
render_input(frame, skin, title, &field.text, &field.cursor, rect);
},
|field, key| match key.code {
KeyCode::Enter => PopupFlow::Done(field.text.clone()),
KeyCode::Esc => PopupFlow::Cancelled,
_ => {
input::apply_edit_key(
&mut field.text,
&mut field.cursor,
key,
input::EditMode::SingleLine,
None,
);
PopupFlow::Continue
}
},
)
}
pub fn select(
tui: &mut Tui,
skin: &Skin,
title: &str,
items: &[String],
initial: usize,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<usize>> {
if items.is_empty() {
return Ok(ModalSignal::Cancelled);
}
let mut cursor = initial.min(items.len() - 1);
popup(
tui,
&mut cursor,
|area, _| picker_area(area, items.len()),
|frame, _| render_bg(frame),
|frame, rect, cursor: &usize| {
render_picker(frame, skin, title, items, *cursor, None, rect);
},
|cursor, key| match key.code {
KeyCode::Up | KeyCode::Char('k') => {
*cursor = nav::cycle(*cursor, items.len(), -1);
PopupFlow::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
*cursor = nav::cycle(*cursor, items.len(), 1);
PopupFlow::Continue
}
KeyCode::Enter => PopupFlow::Done(*cursor),
KeyCode::Esc => PopupFlow::Cancelled,
_ => PopupFlow::Continue,
},
)
}
pub fn multi_select(
tui: &mut Tui,
skin: &Skin,
title: &str,
items: &[String],
initial: &[usize],
check: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<Vec<usize>>> {
if items.is_empty() {
return Ok(ModalSignal::Cancelled);
}
let mut state = MultiSelect::new(initial);
popup(
tui,
&mut state,
|area, _| picker_area(area, items.len()),
|frame, _| render_bg(frame),
|frame, rect, state: &MultiSelect| {
let checked = Some((&state.checked, check));
render_picker(
frame,
skin,
title,
items,
state.cursor,
checked,
rect,
);
},
|state, key| state.handle_key(key, items.len()),
)
}
pub enum ListAction {
Pick(usize),
Move {
index: usize,
delta: i32,
},
}
pub fn select_reorderable(
tui: &mut Tui,
skin: &Skin,
title: &str,
items: &[String],
initial: usize,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<ListAction>> {
if items.is_empty() {
return Ok(ModalSignal::Cancelled);
}
let mut cursor = initial.min(items.len() - 1);
popup(
tui,
&mut cursor,
|area, _| picker_area(area, items.len()),
|frame, _| render_bg(frame),
|frame, rect, cursor: &usize| {
render_picker(frame, skin, title, items, *cursor, None, rect);
},
|cursor, key| {
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Up if alt => PopupFlow::Done(ListAction::Move {
index: *cursor,
delta: -1,
}),
KeyCode::Down if alt => PopupFlow::Done(ListAction::Move {
index: *cursor,
delta: 1,
}),
KeyCode::Up | KeyCode::Char('k') => {
*cursor = nav::cycle(*cursor, items.len(), -1);
PopupFlow::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
*cursor = nav::cycle(*cursor, items.len(), 1);
PopupFlow::Continue
}
KeyCode::Enter => PopupFlow::Done(ListAction::Pick(*cursor)),
KeyCode::Esc => PopupFlow::Cancelled,
_ => PopupFlow::Continue,
}
},
)
}
pub fn select_styled(
tui: &mut Tui,
skin: &Skin,
title: &str,
items: &[(String, Style)],
initial: usize,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<usize>> {
if items.is_empty() {
return Ok(ModalSignal::Cancelled);
}
let mut cursor = initial.min(items.len() - 1);
popup(
tui,
&mut cursor,
|area, _| picker_area(area, items.len()),
|frame, _| render_bg(frame),
|frame, rect, cursor: &usize| {
render_styled_picker(
frame, skin, title, items, *cursor, None, rect,
);
},
|cursor, key| match key.code {
KeyCode::Up | KeyCode::Char('k') => {
*cursor = nav::cycle(*cursor, items.len(), -1);
PopupFlow::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
*cursor = nav::cycle(*cursor, items.len(), 1);
PopupFlow::Continue
}
KeyCode::Enter => PopupFlow::Done(*cursor),
KeyCode::Esc => PopupFlow::Cancelled,
_ => PopupFlow::Continue,
},
)
}
pub fn multi_select_styled(
tui: &mut Tui,
skin: &Skin,
title: &str,
items: &[(String, Style)],
initial: &[usize],
check: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<Vec<usize>>> {
if items.is_empty() {
return Ok(ModalSignal::Cancelled);
}
let mut state = MultiSelect::new(initial);
popup(
tui,
&mut state,
|area, _| picker_area(area, items.len()),
|frame, _| render_bg(frame),
|frame, rect, state: &MultiSelect| {
let checked = Some((&state.checked, check));
let cursor = state.cursor;
render_styled_picker(
frame, skin, title, items, cursor, checked, rect,
);
},
|state, key| state.handle_key(key, items.len()),
)
}
pub fn number_input(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: i64,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<i64>> {
number_impl(tui, skin, title, initial, None, render_bg)
}
pub fn number_input_bounded(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: i64,
min: i64,
max: i64,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<i64>> {
number_impl(tui, skin, title, initial, Some((min, max)), render_bg)
}
fn number_impl(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: i64,
bounds: Option<(i64, i64)>,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<i64>> {
let mut text = initial.to_string();
popup(
tui,
&mut text,
|area, _| input_area(area),
|frame, _| render_bg(frame),
|frame, rect, text: &String| {
let cursor = TextCursor::at_end(text);
render_input(frame, skin, title, text, &cursor, rect);
},
|text, key| match key.code {
KeyCode::Enter => {
let value = text.parse::<i64>().unwrap_or(initial);
let value =
bounds.map_or(value, |(min, max)| value.clamp(min, max));
PopupFlow::Done(value)
}
KeyCode::Esc => PopupFlow::Cancelled,
KeyCode::Backspace => {
text.pop();
PopupFlow::Continue
}
KeyCode::Char(ch)
if ch.is_ascii_digit() || (ch == '-' && text.is_empty()) =>
{
text.push(ch);
PopupFlow::Continue
}
_ => PopupFlow::Continue,
},
)
}
pub fn message(
tui: &mut Tui,
skin: &Skin,
title: &str,
body: &str,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<()>> {
let mut state = ();
popup(
tui,
&mut state,
|area, (): &()| {
let width = fit(body.width() as u16 + 6, 28, area.width);
centered_rect(width, 5, area)
},
|frame, (): &()| render_bg(frame),
|frame, rect, (): &()| render_message(frame, skin, title, body, rect),
|(): &mut (), _| PopupFlow::Done(()),
)
}
struct TextField {
text: String,
cursor: TextCursor,
}
struct MultiSelect {
cursor: usize,
checked: HashSet<usize>,
}
impl MultiSelect {
fn new(initial: &[usize]) -> Self {
Self {
cursor: 0,
checked: initial.iter().copied().collect(),
}
}
fn handle_key(
&mut self,
key: KeyEvent,
len: usize,
) -> PopupFlow<Vec<usize>> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.cursor = nav::cycle(self.cursor, len, -1);
PopupFlow::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
self.cursor = nav::cycle(self.cursor, len, 1);
PopupFlow::Continue
}
KeyCode::Char(' ') => {
if !self.checked.insert(self.cursor) {
self.checked.remove(&self.cursor);
}
PopupFlow::Continue
}
KeyCode::Enter => {
let mut chosen: Vec<usize> =
self.checked.iter().copied().collect();
chosen.sort_unstable();
PopupFlow::Done(chosen)
}
KeyCode::Esc => PopupFlow::Cancelled,
_ => PopupFlow::Continue,
}
}
}
fn render_confirm(
frame: &mut Frame,
skin: &Skin,
question: &Question<'_>,
rect: Rect,
) {
let inner = overlay::framed(frame, rect, skin, " Confirm ");
let width = inner.width as usize;
let mut lines = vec![Line::from(question.prompt.to_string())];
lines.extend(hint_block(&question.hints(), &skin.palette, width));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
frame.render_widget(paragraph, inner);
}
fn render_input(
frame: &mut Frame,
skin: &Skin,
title: &str,
text: &str,
cursor: &TextCursor,
rect: Rect,
) {
let inner = overlay::framed(frame, rect, skin, title);
let width = inner.width as usize;
let line = input::render_line(text, cursor, &skin.palette, width, true);
let mut lines = vec![line];
lines.extend(hint_block(
&[("enter", "ok"), ("esc", "cancel")],
&skin.palette,
width,
));
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_message(
frame: &mut Frame,
skin: &Skin,
title: &str,
body: &str,
rect: Rect,
) {
let inner = overlay::framed(frame, rect, skin, title);
let paragraph = Paragraph::new(body.to_string()).wrap(Wrap { trim: true });
frame.render_widget(paragraph, inner);
}
fn render_picker(
frame: &mut Frame,
skin: &Skin,
title: &str,
items: &[String],
cursor: usize,
checked: Option<(&HashSet<usize>, &str)>,
rect: Rect,
) {
let inner = overlay::framed(frame, rect, skin, title);
let entries: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(index, label)| {
let prefix = check_prefix(checked, index);
ListItem::new(Line::from(format!("{prefix}{label}")))
})
.collect();
render_picker_list(frame, inner, entries, items.len(), cursor, skin);
render_picker_badge(frame, rect, skin, items.len(), cursor);
}
fn render_styled_picker(
frame: &mut Frame,
skin: &Skin,
title: &str,
items: &[(String, Style)],
cursor: usize,
checked: Option<(&HashSet<usize>, &str)>,
rect: Rect,
) {
let inner = overlay::framed(frame, rect, skin, title);
let entries: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(index, (label, item_style))| {
let prefix = check_prefix(checked, index);
ListItem::new(Line::from(vec![
Span::raw(prefix),
Span::styled(label.clone(), *item_style),
]))
})
.collect();
render_picker_list(frame, inner, entries, items.len(), cursor, skin);
render_picker_badge(frame, rect, skin, items.len(), cursor);
}
fn render_picker_badge(
frame: &mut Frame,
rect: Rect,
skin: &Skin,
total: usize,
cursor: usize,
) {
let badge = chrome::position_badge(cursor, total);
chrome::render_badge(frame, rect, skin, &badge);
}
fn render_picker_list(
frame: &mut Frame,
inner: Rect,
entries: Vec<ListItem<'_>>,
total: usize,
cursor: usize,
skin: &Skin,
) {
let mut state = picker_state(cursor);
frame.render_stateful_widget(picker_list(entries, skin), inner, &mut state);
scroll::render_scrollbar(
frame,
inner,
skin,
nav::ScrollView {
total,
offset: state.offset(),
viewport: inner.height as usize,
},
);
}
fn check_prefix(
checked: Option<(&HashSet<usize>, &str)>,
index: usize,
) -> String {
match checked {
Some((set, glyph)) if set.contains(&index) => format!("{glyph} "),
Some(_) => " ".to_string(),
None => String::new(),
}
}
fn picker_list<'a>(entries: Vec<ListItem<'a>>, skin: &Skin) -> List<'a> {
List::new(entries).highlight_style(
style::bg(skin.palette.selection).add_modifier(Modifier::BOLD),
)
}
fn picker_state(cursor: usize) -> ListState {
let mut state = ListState::default();
state.select(Some(cursor));
state
}
fn picker_area(area: Rect, item_count: usize) -> Rect {
let height = fit(item_count as u16 + 2, 5, area.height.saturating_sub(2));
let width = fit(area.width / 2, 30, area.width.saturating_sub(4));
centered_rect(width, height, area)
}
fn input_area(area: Rect) -> Rect {
let width = area.width.saturating_sub(8).clamp(20, 60);
centered_rect(width, hinted_box_height(), area)
}
fn input_area_wide(area: Rect) -> Rect {
let width = fit(area.width * 9 / 10, 20, area.width);
centered_rect(width, hinted_box_height(), area)
}
fn hint_block(
items: &[(&str, &str)],
palette: &Palette,
width: usize,
) -> Vec<Line<'static>> {
shortcut_hints::lines(items, palette.accent, width)
.into_iter()
.take(1)
.flat_map(|hint| [Line::from(""), hint])
.collect()
}
fn hinted_box_height() -> u16 {
3 + shortcut_hints::footer_height(HINT_BLOCK_ROWS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popup_geometry_survives_a_terminal_below_its_minimum() {
for (width, height) in [(1, 1), (4, 2), (20, 6), (27, 10)] {
let area = Rect::new(0, 0, width, height);
for rect in [
picker_area(area, 40),
input_area(area),
input_area_wide(area),
] {
assert!(rect.width <= area.width, "{rect:?} in {area:?}");
assert!(rect.height <= area.height, "{rect:?} in {area:?}");
}
}
}
#[test]
fn a_roomy_terminal_still_gets_the_preferred_size() {
let area = Rect::new(0, 0, 100, 40);
let picker = picker_area(area, 4);
assert_eq!(picker.width, 50); assert_eq!(picker.height, 6); }
#[test]
fn a_plain_question_lets_enter_confirm() {
let question = Question::new("Save the file?");
assert!(question.default_yes);
assert_eq!(question.hints(), [("enter/y", "yes"), ("n", "no")]);
}
#[test]
fn a_declining_question_lets_enter_decline() {
let question = Question::declining("Delete everything?");
assert!(!question.default_yes);
assert_eq!(question.hints(), [("y", "yes"), ("enter/n", "no")]);
}
}