use {
crate::{
style::SortStyle,
util::CursorGuard,
validation::{Validate, run_validator},
},
crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
queue,
terminal::{self, Clear, ClearType},
},
miette::IntoDiagnostic,
owo_colors::OwoColorize,
std::io::{Write, stdout},
};
#[derive(Clone)]
pub struct Sort {
prompt: String,
items: Vec<String>,
prompt_prefix: String,
help_message: Option<String>,
page_size: usize,
show_hints: bool,
show_indices: bool,
allow_escape: bool,
vim_mode: bool,
style: SortStyle,
validation: Option<Box<dyn Validate<[String]>>>,
_cursor_guard: CursorGuard,
}
impl Sort {
pub fn new(prompt: impl Into<String>) -> Self {
let _cursor_guard = CursorGuard::new().expect("Failed to initialize cursor guard");
Self {
prompt: prompt.into(),
items: Vec::new(),
prompt_prefix: "↕".into(),
help_message: None,
page_size: 10,
show_hints: true,
show_indices: true,
allow_escape: true,
vim_mode: false,
style: SortStyle::default(),
validation: None,
_cursor_guard,
}
}
pub fn prompt(&self) -> &str {
&self.prompt
}
pub fn with_items(mut self, items: Vec<impl Into<String>>) -> Self {
self.items = items.into_iter().map(|s| s.into()).collect();
self
}
pub fn with_item(mut self, item: impl Into<String>) -> Self {
self.items.push(item.into());
self
}
pub fn with_prompt_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prompt_prefix = prefix.into();
self
}
pub fn with_help_message(mut self, message: impl Into<String>) -> Self {
self.help_message = Some(message.into());
self
}
pub fn with_page_size(mut self, size: usize) -> Self {
self.page_size = size.max(3);
self
}
pub fn with_hints(mut self, enabled: bool) -> Self {
self.show_hints = enabled;
self
}
pub fn with_indices(mut self, show: bool) -> Self {
self.show_indices = show;
self
}
pub fn with_escape(mut self, allow: bool) -> Self {
self.allow_escape = allow;
self
}
pub fn with_vim_mode(mut self, enabled: bool) -> Self {
self.vim_mode = enabled;
self
}
pub fn with_style(mut self, style: SortStyle) -> Self {
self.style = style;
self
}
pub fn with_validation(mut self, validation: impl Validate<[String]> + 'static) -> Self {
self.validation = Some(Box::new(validation));
self
}
pub fn ask(&self) -> miette::Result<Vec<String>> {
if self.items.is_empty() {
return Err(miette::miette!("No items provided"));
}
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = terminal::disable_raw_mode();
std::panic::take_hook()(panic_info);
}));
let result = self.ask_internal();
let _ = std::panic::take_hook();
std::panic::set_hook(original_hook);
result
}
fn ask_internal(&self) -> miette::Result<Vec<String>> {
let mut items = self.items.clone();
let mut cursor = 0usize;
let mut grabbed = false;
let mut scroll_offset = 0usize;
let mut error_message: Option<String> = None;
let mut buf = Vec::with_capacity(4096);
let mut out = stdout();
terminal::enable_raw_mode().into_diagnostic()?;
while event::poll(std::time::Duration::from_millis(0)).into_diagnostic()? {
event::read().into_diagnostic()?;
}
let mut last_render_lines = self.render(
&mut buf,
&items,
cursor,
grabbed,
scroll_offset,
error_message.as_deref(),
)?;
out.write_all(&buf).into_diagnostic()?;
out.flush().into_diagnostic()?;
loop {
if let Event::Key(key_event) = event::read().into_diagnostic()? {
if key_event.kind != KeyEventKind::Press {
continue;
}
error_message = None;
match self.handle_key(
key_event,
&mut items,
&mut cursor,
&mut grabbed,
&mut scroll_offset,
) {
Ok(Some(())) => {
terminal::disable_raw_mode().into_diagnostic()?;
buf.clear();
if last_render_lines > 0 {
queue!(buf, cursor::MoveUp(last_render_lines as u16))
.into_diagnostic()?;
}
queue!(buf, cursor::MoveToColumn(0)).into_diagnostic()?;
queue!(buf, Clear(ClearType::FromCursorDown)).into_diagnostic()?;
self.show_result(&mut buf, &items)?;
out.write_all(&buf).into_diagnostic()?;
out.flush().into_diagnostic()?;
return Ok(items);
}
Ok(None) => {}
Err(e) => {
if e == "Cancelled" {
terminal::disable_raw_mode().into_diagnostic()?;
buf.clear();
if last_render_lines > 0 {
queue!(buf, cursor::MoveUp(last_render_lines as u16))
.into_diagnostic()?;
}
queue!(buf, cursor::MoveToColumn(0)).into_diagnostic()?;
queue!(buf, Clear(ClearType::FromCursorDown)).into_diagnostic()?;
self.show_error(&mut buf, &e)?;
out.write_all(&buf).into_diagnostic()?;
out.flush().into_diagnostic()?;
return Err(miette::miette!(e));
}
error_message = Some(e);
}
}
buf.clear();
if last_render_lines > 0 {
queue!(buf, cursor::MoveUp(last_render_lines as u16)).into_diagnostic()?;
}
queue!(buf, cursor::MoveToColumn(0)).into_diagnostic()?;
queue!(buf, Clear(ClearType::FromCursorDown)).into_diagnostic()?;
last_render_lines = self.render(
&mut buf,
&items,
cursor,
grabbed,
scroll_offset,
error_message.as_deref(),
)?;
out.write_all(&buf).into_diagnostic()?;
out.flush().into_diagnostic()?;
}
}
}
fn handle_key(
&self,
key_event: KeyEvent,
items: &mut [String],
cursor: &mut usize,
grabbed: &mut bool,
scroll_offset: &mut usize,
) -> Result<Option<()>, String> {
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c'))
{
let _ = terminal::disable_raw_mode();
std::process::exit(130);
}
let total = items.len();
match key_event.code {
KeyCode::Up | KeyCode::Char('k') if self.vim_mode || key_event.code == KeyCode::Up => {
if *grabbed && *cursor > 0 {
items.swap(*cursor, *cursor - 1);
*cursor -= 1;
} else if !*grabbed && *cursor > 0 {
*cursor -= 1;
} else if !*grabbed {
*cursor = total - 1;
*scroll_offset = cursor.saturating_sub(self.page_size - 1);
}
if *cursor < *scroll_offset {
*scroll_offset = *cursor;
}
Ok(None)
}
KeyCode::Down | KeyCode::Char('j')
if self.vim_mode || key_event.code == KeyCode::Down =>
{
if *grabbed && *cursor < total - 1 {
items.swap(*cursor, *cursor + 1);
*cursor += 1;
} else if !*grabbed && *cursor < total - 1 {
*cursor += 1;
} else if !*grabbed {
*cursor = 0;
*scroll_offset = 0;
}
if *cursor >= *scroll_offset + self.page_size {
*scroll_offset = cursor.saturating_sub(self.page_size - 1);
}
Ok(None)
}
KeyCode::Char(' ') => {
*grabbed = !*grabbed;
Ok(None)
}
KeyCode::Enter => {
if *grabbed {
*grabbed = false;
return Ok(None);
}
if let Some(ref validator) = self.validation {
run_validator(validator.as_ref(), items)?;
}
Ok(Some(()))
}
KeyCode::Home | KeyCode::Char('g')
if !*grabbed && (self.vim_mode || key_event.code == KeyCode::Home) =>
{
*cursor = 0;
*scroll_offset = 0;
Ok(None)
}
KeyCode::End | KeyCode::Char('G')
if !*grabbed && (self.vim_mode || key_event.code == KeyCode::End) =>
{
*cursor = total - 1;
*scroll_offset = cursor.saturating_sub(self.page_size - 1);
Ok(None)
}
KeyCode::Esc if self.allow_escape => {
if *grabbed {
*grabbed = false;
Ok(None)
} else {
Err("Cancelled".into())
}
}
_ => Ok(None),
}
}
fn render(
&self,
out: &mut impl Write,
items: &[String],
cursor: usize,
grabbed: bool,
scroll_offset: usize,
error: Option<&str>,
) -> miette::Result<usize> {
let tw = crate::util::term_width();
let mut line_count = 0;
let line = format!(
"{} {}",
self.prompt_prefix.style(self.style.prompt_prefix),
self.prompt.style(self.style.prompt),
);
line_count += crate::util::writeln_physical(out, &line, tw)?;
if let Some(ref help) = self.help_message {
let line = format!(" {}", help.style(self.style.hint));
line_count += crate::util::writeln_physical(out, &line, tw)?;
}
let end_offset = (scroll_offset + self.page_size).min(items.len());
let visible_items = &items[scroll_offset..end_offset];
for (i, item) in visible_items.iter().enumerate() {
let absolute_index = scroll_offset + i;
let is_cursor = absolute_index == cursor;
let (marker, item_style) = if is_cursor && grabbed {
("≡", self.style.grabbed)
} else if is_cursor {
("▸", self.style.cursor)
} else {
(" ", self.style.item)
};
let line = if self.show_indices {
format!(
" {} {} {}",
marker.style(if is_cursor && grabbed {
self.style.grabbed
} else {
self.style.cursor
}),
format!("{}.", absolute_index + 1).style(self.style.index),
item.style(item_style)
)
} else {
format!(
" {} {}",
marker.style(if is_cursor && grabbed {
self.style.grabbed
} else {
self.style.cursor
}),
item.style(item_style)
)
};
line_count += crate::util::writeln_physical(out, &line, tw)?;
}
if scroll_offset > 0 {
let line = format!(
" {}",
format!("(↑ {} more above)", scroll_offset).style(self.style.hint)
);
line_count += crate::util::writeln_physical(out, &line, tw)?;
}
if end_offset < items.len() {
let line = format!(
" {}",
format!("(↓ {} more below)", items.len() - end_offset).style(self.style.hint)
);
line_count += crate::util::writeln_physical(out, &line, tw)?;
}
if let Some(err) = error {
let line = format!(
" {} {}",
"✗".style(self.style.error),
err.style(self.style.error_hint)
);
line_count += crate::util::writeln_physical(out, &line, tw)?;
}
if self.show_hints {
let mut hints = vec![];
if self.vim_mode {
hints.push("j/k or ↑↓ to navigate");
} else {
hints.push("↑↓ to navigate");
}
hints.push("Space to grab/release");
hints.push("Enter to submit");
if self.allow_escape {
hints.push("Esc to cancel/release");
}
let line = format!(" {}", hints.join(", ").style(self.style.hint));
line_count += crate::util::writeln_physical(out, &line, tw)?;
}
Ok(line_count)
}
fn show_error(&self, out: &mut impl Write, error: &str) -> miette::Result<()> {
writeln!(
out,
"{} {}",
"✗".style(self.style.error),
error.style(self.style.error_hint),
)
.into_diagnostic()?;
Ok(())
}
fn show_result(&self, out: &mut impl Write, items: &[String]) -> miette::Result<()> {
let result_text = items
.iter()
.enumerate()
.map(|(i, item)| format!("{}. {}", i + 1, item))
.collect::<Vec<_>>()
.join(", ");
writeln!(
out,
"{} {} {}",
self.prompt_prefix.style(self.style.prompt_prefix),
self.prompt.style(self.style.prompt),
result_text.style(self.style.cursor).bold(),
)
.into_diagnostic()?;
Ok(())
}
}