use anyhow::{Result, bail, ensure};
use ratatui::{
Terminal, TerminalOptions, Viewport,
backend::CrosstermBackend,
crossterm::{
cursor::{MoveTo, Show},
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode},
},
layout::Position,
widgets::Paragraph,
};
use std::io::{self, IsTerminal};
struct Restore {
origin: Option<Position>,
}
fn prompt_height(options: usize, input: bool, minimal: bool) -> u16 {
if input {
2
} else if minimal {
options.max(1) as u16
} else {
1 + options.max(1) as u16
}
}
impl Drop for Restore {
fn drop(&mut self) {
if let Some(origin) = self.origin {
let _ = execute!(
io::stderr(),
MoveTo(origin.x, origin.y),
Clear(ClearType::FromCursorDown),
Show
);
}
let _ = disable_raw_mode();
}
}
fn render_prompt(
title: &str,
options: &[String],
input: bool,
minimal: bool,
allow_empty: bool,
) -> Result<String> {
ensure!(
io::stdin().is_terminal() && io::stderr().is_terminal(),
"{title}: an interactive terminal is required"
);
let mut restore = Restore { origin: None };
enable_raw_mode()?;
let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stderr()),
TerminalOptions {
viewport: Viewport::Inline(prompt_height(options.len(), input, minimal)),
},
)?;
let mut selected = 0usize;
let mut text = String::new();
let result = loop {
terminal.draw(|frame| {
restore.origin = Some(frame.area().as_position());
let mut lines = if minimal {
vec![]
} else {
vec![title.to_owned()]
};
if input {
lines.push(format!("> {text}"));
} else {
let rows = if minimal {
usize::from(frame.area().height).max(1)
} else {
options.len().max(1)
};
for (i, option) in options.iter().enumerate().take(rows) {
lines.push(format!(
"{} {}",
if i == selected { ">" } else { " " },
option
));
}
}
frame.render_widget(Paragraph::new(lines.join("\n")), frame.area());
})?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Esc => break None,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break None,
KeyCode::Enter if input && (allow_empty || !text.trim().is_empty()) => {
break Some(text.trim().into());
}
KeyCode::Enter if !input => break Some(selected.to_string()),
KeyCode::Up | KeyCode::Char('k') if !input => {
selected = selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') if !input => {
selected = (selected + 1).min(options.len() - 1);
}
KeyCode::Backspace if input => {
text.pop();
}
KeyCode::Char(c) if input && !c.is_control() => text.push(c),
_ => {}
}
}
};
match result {
Some(result) => Ok(result),
None => bail!("user cancelled"),
}
}
#[cfg(test)]
mod tests {
use super::prompt_height;
#[test]
fn picker_height_includes_every_option() {
assert_eq!(prompt_height(0, false, true), 1);
assert_eq!(prompt_height(7, false, true), 7);
assert_eq!(prompt_height(7, false, false), 8);
assert_eq!(prompt_height(7, true, false), 2);
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PromptRequest {
title: String,
options: Vec<String>,
input: bool,
minimal: bool,
#[serde(default)]
allow_empty: bool,
}
fn prompt(
title: &str,
options: &[String],
input: bool,
minimal: bool,
allow_empty: bool,
) -> Result<String> {
ensure!(
io::stdin().is_terminal() && io::stderr().is_terminal(),
"{title}: an interactive terminal is required"
);
let request = tempfile::NamedTempFile::new()?;
let response = tempfile::NamedTempFile::new()?;
let data = PromptRequest {
title: title.into(),
options: options.to_vec(),
input,
minimal,
allow_empty,
};
std::fs::write(request.path(), serde_yaml_ng::to_string(&data)?)?;
let status = std::process::Command::new(std::env::current_exe()?)
.arg("__prompt")
.arg(request.path())
.arg(response.path())
.stdout(std::process::Stdio::from(io::stderr()))
.status()?;
ensure!(status.success(), "Terminal prompt failed");
let result: std::result::Result<String, String> =
serde_yaml_ng::from_str(&std::fs::read_to_string(response.path())?)?;
result.map_err(anyhow::Error::msg)
}
pub fn run_prompt(request: &std::path::Path, response: &std::path::Path) -> Result<()> {
let data: PromptRequest = serde_yaml_ng::from_str(&std::fs::read_to_string(request)?)?;
ensure!(
data.input || !data.options.is_empty(),
"No options available"
);
let result = render_prompt(
&data.title,
&data.options,
data.input,
data.minimal,
data.allow_empty,
)
.map_err(|error| error.to_string());
std::fs::write(response, serde_yaml_ng::to_string(&result)?)?;
Ok(())
}
pub fn select(title: &str, options: &[String]) -> Result<usize> {
ensure!(!options.is_empty(), "No options available");
Ok(prompt(title, options, false, false, false)?.parse()?)
}
pub fn input(title: &str) -> Result<String> {
prompt(title, &[], true, false, false)
}
pub fn input_optional(title: &str) -> Result<String> {
prompt(title, &[], true, false, true)
}
pub fn select_key(options: &[String]) -> Result<usize> {
ensure!(!options.is_empty(), "No keys available");
Ok(prompt("Resource selection", options, false, true, false)?.parse()?)
}