use super::terminal_prompt::{PromptIo, StdinStdoutIo};
use crate::Error;
use async_trait::async_trait;
use parking_lot::Mutex;
use reedline::{
FileBackedHistory, Prompt, PromptEditMode, PromptHistorySearch,
PromptHistorySearchStatus, Reedline, Signal,
};
use std::borrow::Cow;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
struct ThalPrompt {
text: String,
}
impl Prompt for ThalPrompt {
fn render_prompt_left(&self) -> Cow<'_, str> {
Cow::Owned(self.text.clone())
}
fn render_prompt_right(&self) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
Cow::Borrowed("· ")
}
fn render_prompt_history_search_indicator(
&self,
history_search: PromptHistorySearch,
) -> Cow<'_, str> {
let prefix = match history_search.status {
PromptHistorySearchStatus::Passing => "",
PromptHistorySearchStatus::Failing => "failing ",
};
Cow::Owned(format!("({prefix}reverse-search: {}) ", history_search.term))
}
}
pub struct ReedlineIo {
inner: Option<Arc<Mutex<ReedlineState>>>,
fallback: StdinStdoutIo,
}
struct ReedlineState {
editor: Reedline,
}
impl ReedlineIo {
pub fn new() -> Self {
let inner = if std::io::stdin().is_terminal() {
let mut editor = Reedline::create();
if let Some(path) = history_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(history) = FileBackedHistory::with_file(1000, path) {
editor = editor.with_history(Box::new(history));
}
}
Some(Arc::new(Mutex::new(ReedlineState { editor })))
} else {
None
};
Self {
inner,
fallback: StdinStdoutIo,
}
}
}
impl Default for ReedlineIo {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl PromptIo for ReedlineIo {
async fn prompt(&self, question: &str) -> Result<String, Error> {
let inner = match &self.inner {
Some(i) => i.clone(),
None => return self.fallback.prompt(question).await,
};
let question = question.to_string();
let answer = tokio::task::spawn_blocking(move || -> Result<String, Error> {
loop {
let signal = {
let mut state = inner.lock();
let prompt = ThalPrompt {
text: question.clone(),
};
state
.editor
.read_line(&prompt)
.map_err(|e| Error::Runtime(format!("reedline: {e}")))?
};
match signal {
Signal::Success(line) => {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix('/') {
match handle_slash_command(rest) {
SlashOutcome::Continue => continue,
SlashOutcome::ReturnInput(s) => return Ok(s),
}
}
return Ok(line);
}
Signal::CtrlC => {
eprintln!("(use /exit or Ctrl+D to quit)");
continue;
}
Signal::CtrlD => {
eprintln!();
std::process::exit(0);
}
}
}
})
.await
.map_err(|e| Error::Runtime(format!("reedline task join: {e}")))??;
Ok(answer)
}
}
enum SlashOutcome {
Continue,
#[allow(dead_code)]
ReturnInput(String),
}
fn handle_slash_command(rest: &str) -> SlashOutcome {
let cmd = rest.split_whitespace().next().unwrap_or("");
match cmd {
"exit" | "quit" | "q" => {
eprintln!("bye");
std::process::exit(0);
}
"clear" | "cls" => {
print!("\x1b[2J\x1b[H");
use std::io::Write;
let _ = std::io::stdout().flush();
SlashOutcome::Continue
}
"help" | "h" | "?" => {
eprintln!("commands:");
eprintln!(" /exit, /quit, /q exit");
eprintln!(" /clear, /cls clear the screen");
eprintln!(" /help, /h, /? this help");
SlashOutcome::Continue
}
other => {
eprintln!("unknown command: /{other} (try /help)");
SlashOutcome::Continue
}
}
}
fn history_path() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(d).join("thal").join("history.txt"));
}
if let Some(home) = std::env::var_os("HOME") {
return Some(
PathBuf::from(home)
.join(".config")
.join("thal")
.join("history.txt"),
);
}
None
}