use std::borrow::Cow;
use std::fmt::Write as _;
use std::io;
use std::io::Write;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use colored::Colorize;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{
Cmd, CompletionType, ConditionalEventHandler, Config, Editor, Event, EventContext,
EventHandler, Helper, KeyCode, KeyEvent, Modifiers, RepeatCount,
};
use crate::common::{CTP_BLUE, CTP_OVERLAY0, CTP_PRIMARY, current_directory_display, show_cursor};
use crate::config;
use crate::slash_commands;
static PREVIEW_LINE_COUNT: AtomicUsize = AtomicUsize::new(0);
static SHELL_MODE: AtomicBool = AtomicBool::new(false);
const PREVIEW_DESC_COL: usize = 16;
pub fn format_preview_row(cmd_name: &str, typed_len: usize, description: &str) -> String {
let split = typed_len.min(cmd_name.len());
let (typed, untyped) = cmd_name.split_at(split);
let pad = PREVIEW_DESC_COL.saturating_sub(cmd_name.len() + 3);
format!(
" {}{}{}{}",
typed.custom_color(CTP_PRIMARY).bold(),
untyped.custom_color(CTP_OVERLAY0),
" ".repeat(pad + 4),
description.custom_color(CTP_OVERLAY0),
)
}
pub fn clear_slash_preview() {
let n = PREVIEW_LINE_COUNT.swap(0, Ordering::Relaxed);
if n == 0 {
return;
}
let mut seq = String::new();
for _ in 0..n {
seq.push_str("\n\x1b[K");
}
let _ = write!(seq, "\x1b[{n}A\r");
print!("{seq}");
let _ = io::stdout().flush();
}
pub fn draw_slash_preview(line: &str) {
if line.contains(' ') {
draw_arg_preview(line);
return;
}
let matches = slash_commands::filter(line);
let prev_count = PREVIEW_LINE_COUNT.load(Ordering::Relaxed);
let new_count = matches.len();
let max_lines = prev_count.max(new_count);
if max_lines == 0 {
return;
}
let typed_len = line.len();
let mut seq = String::new();
for i in 0..max_lines {
seq.push_str("\n\x1b[K"); if let Some(cmd) = matches.get(i) {
let row = format_preview_row(&format!("/{}", cmd.name), typed_len, cmd.description);
seq.push('\r');
seq.push_str(&row);
}
}
let _ = write!(seq, "\x1b[{max_lines}A\r");
PREVIEW_LINE_COUNT.store(new_count, Ordering::Relaxed);
print!("{seq}");
let _ = io::stdout().flush();
}
fn draw_arg_preview(line: &str) {
let prev_count = PREVIEW_LINE_COUNT.load(Ordering::Relaxed);
let Some((start, choices)) = slash_commands::arg_completions(line) else {
clear_slash_preview();
return;
};
let partial_len = line.len() - start;
let new_count = choices.len();
let max_lines = prev_count.max(new_count);
if max_lines == 0 {
return;
}
let mut seq = String::new();
for i in 0..max_lines {
seq.push_str("\n\x1b[K");
if let Some(choice) = choices.get(i) {
seq.push('\r');
seq.push_str(&format_preview_row(
choice.value,
partial_len,
choice.description,
));
}
}
let _ = write!(seq, "\x1b[{max_lines}A\r");
PREVIEW_LINE_COUNT.store(new_count, Ordering::Relaxed);
print!("{seq}");
let _ = io::stdout().flush();
}
pub fn reserve_preview_space() {
let n = slash_commands::COMMANDS.len();
let mut seq = String::new();
for _ in 0..n {
seq.push('\n');
}
let _ = write!(seq, "\x1b[{n}A");
print!("{seq}");
let _ = io::stdout().flush();
}
pub struct NlshHelper;
impl Helper for NlshHelper {}
impl Completer for NlshHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
_pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
if !line.starts_with('/') {
return Ok((0, vec![]));
}
if line.contains(' ') {
if let Some((start, choices)) = slash_commands::arg_completions(line) {
let candidates = choices
.iter()
.map(|c| Pair {
display: c.value.to_string(),
replacement: format!("{} ", c.value),
})
.collect();
return Ok((start, candidates));
}
return Ok((0, vec![]));
}
let matches = slash_commands::filter(line);
let candidates: Vec<Pair> = matches
.iter()
.map(|cmd| {
let name = format!("/{}", cmd.name);
Pair {
display: name.clone(),
replacement: format!("{name} "),
}
})
.collect();
Ok((0, candidates))
}
}
impl Hinter for NlshHelper {
type Hint = String;
}
impl Validator for NlshHelper {}
impl Highlighter for NlshHelper {
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
&'s self,
prompt: &'p str,
_default: bool,
) -> Cow<'b, str> {
if !SHELL_MODE.load(Ordering::Relaxed) {
return Cow::Borrowed(prompt);
}
let cwd = current_directory_display();
Cow::Owned(format!(
"{}:{}{} ",
"larpshell".custom_color(CTP_BLUE).bold(),
cwd.custom_color(CTP_OVERLAY0),
"$".custom_color(CTP_PRIMARY).bold()
))
}
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
if SHELL_MODE.load(Ordering::Relaxed) {
clear_slash_preview();
if let Some(cmd) = line.strip_prefix("! ") {
return Cow::Owned(format!("{}{}", "! ".custom_color(CTP_OVERLAY0), cmd));
}
return Cow::Owned(line.custom_color(CTP_PRIMARY).to_string());
}
if !line.starts_with('/') {
clear_slash_preview();
return Cow::Borrowed(line);
}
draw_slash_preview(line);
if let Some((cmd, args)) = line.split_once(' ') {
Cow::Owned(format!("{} {}", cmd.custom_color(CTP_BLUE), args))
} else {
Cow::Owned(line.custom_color(CTP_BLUE).to_string())
}
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
let shell = line.starts_with("! ");
SHELL_MODE.store(shell, Ordering::Relaxed);
shell || line.starts_with('/')
}
}
struct SlashPreviewHandler;
impl ConditionalEventHandler for SlashPreviewHandler {
fn handle(
&self,
evt: &Event,
_n: RepeatCount,
_positive: bool,
ctx: &EventContext<'_>,
) -> Option<Cmd> {
let line = ctx.line();
let pos = ctx.pos();
if line.starts_with("! ") {
clear_slash_preview();
return None;
}
let effective = match evt {
Event::KeySeq(keys) => match keys.first() {
Some(KeyEvent(KeyCode::Char(c), Modifiers::NONE)) => {
let mut s = line.to_string();
s.insert(pos, *c);
s
}
Some(KeyEvent(KeyCode::Backspace, _)) if pos > 0 => {
let char_start = line[..pos].char_indices().next_back().map_or(0, |(i, _)| i);
let mut s = line.to_string();
s.replace_range(char_start..pos, "");
s
}
_ => line.to_string(),
},
_ => line.to_string(),
};
if !effective.starts_with('/') {
clear_slash_preview();
}
None
}
}
type NlshEditor = Editor<NlshHelper, DefaultHistory>;
static EDITOR: Mutex<Option<NlshEditor>> = Mutex::new(None);
fn with_editor<F>(readline_fn: F) -> Result<Option<String>, io::Error>
where
F: FnOnce(&mut NlshEditor, &str) -> rustyline::Result<String>,
{
SHELL_MODE.store(false, Ordering::Relaxed);
let mut editor_lock = EDITOR
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let editor = editor_lock.get_or_insert_with(|| {
let mut ed = Editor::<NlshHelper, DefaultHistory>::with_config(
Config::builder()
.completion_type(CompletionType::Circular)
.build(),
)
.expect("failed to initialize rustyline editor");
ed.set_helper(Some(NlshHelper));
ed.bind_sequence(
Event::Any,
EventHandler::Conditional(Box::new(SlashPreviewHandler)),
);
if config::history_enabled()
&& let Ok(path) = config::history_path()
{
let _ = ed.load_history(&path);
}
ed
});
let cwd = current_directory_display();
let prompt = format!(
"{}:{}{} ",
"larpshell".custom_color(CTP_BLUE).bold(),
cwd.custom_color(CTP_OVERLAY0),
"❯".custom_color(CTP_BLUE)
);
match readline_fn(editor, &prompt) {
Ok(line) => {
clear_slash_preview();
let trimmed = line.trim();
if trimmed.is_empty() {
Ok(None)
} else {
let _ = editor.add_history_entry(&line);
if config::history_enabled()
&& let Ok(path) = config::history_path()
{
let _ = editor.save_history(&path);
}
Ok(Some(trimmed.to_string()))
}
}
Err(ReadlineError::Interrupted) => {
clear_slash_preview();
show_cursor();
Err(io::Error::from(io::ErrorKind::Interrupted))
}
Err(ReadlineError::Eof) => {
clear_slash_preview();
show_cursor();
Err(io::Error::from(io::ErrorKind::UnexpectedEof))
}
Err(err) => {
clear_slash_preview();
Err(io::Error::other(err))
}
}
}
pub fn user_input_prefilled(initial: &str) -> Result<Option<String>, io::Error> {
with_editor(|editor, prompt| editor.readline_with_initial(prompt, (initial, "")))
}
pub fn user_input() -> Result<Option<String>, io::Error> {
with_editor(rustyline::Editor::readline)
}