use std::ops::{Deref, DerefMut};
use std::time::Instant;
use ansi_to_tui::IntoText;
use ratatui::{prelude::*, widgets::Widget};
use unicode_display_width::width as display_width;
use crate::helper::item::strip_ansi;
use crate::tui::BorderType;
use crate::tui::options::TuiLayout;
use crate::tui::statusline::InfoDisplay;
use crate::tui::util::style_line;
use crate::tui::widget::{SkimRender, SkimWidget};
use crate::{SkimOptions, theme::ColorTheme};
use std::sync::Arc;
const SPINNER_DURATION: u32 = 200;
const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
#[derive(Clone, Default)]
pub struct StatusInfo {
pub total: usize,
pub matched: usize,
pub processed: usize,
pub show_spinner: bool,
pub matcher_mode: String,
pub multi_selection: bool,
pub selected: usize,
pub current_item_idx: usize,
pub hscroll_offset: i64,
pub start: Option<Instant>,
}
impl StatusInfo {
pub fn left_title(&self) -> String {
let mut parts = String::new();
if self.show_spinner
&& let Some(start) = self.start
{
let spinner_elapsed_ms = start.elapsed().as_millis();
let index = ((spinner_elapsed_ms / (SPINNER_DURATION as u128)) % (SPINNERS_UNICODE.len() as u128)) as usize;
parts.push(SPINNERS_UNICODE[index]);
parts.push(' ');
} else {
parts.push_str(" ");
}
parts.push_str(&format!("{}/{}", self.matched, self.total));
if !self.matcher_mode.is_empty() {
parts.push_str(&format!("/{}", &self.matcher_mode));
}
if self.show_spinner && self.total > 0 && self.processed != self.total {
let pct = self.processed.saturating_mul(100) / self.total;
parts.push_str(&format!(" ({}%)", pct));
}
if self.multi_selection && self.selected > 0 {
parts.push_str(&format!(" [{}]", self.selected));
}
parts
}
pub fn inline_separator(&self) -> char {
if self.show_spinner
&& let Some(start) = self.start
{
let spinner_elapsed_ms = start.elapsed().as_millis();
let index = ((spinner_elapsed_ms / (SPINNER_DURATION as u128)) % (SPINNERS_UNICODE.len() as u128)) as usize;
SPINNERS_UNICODE[index]
} else {
'<'
}
}
pub fn inline_status(&self) -> String {
let mut parts = String::new();
parts.push_str(&format!("{}/{}", self.matched, self.total));
if !self.matcher_mode.is_empty() {
parts.push_str(&format!("/{}", &self.matcher_mode));
}
if self.show_spinner && self.total > 0 {
let pct = self.processed.saturating_mul(100) / self.total;
parts.push_str(&format!(" ({}%)", pct));
}
if self.multi_selection && self.selected > 0 {
parts.push_str(&format!(" [{}]", self.selected));
}
parts
}
pub fn right_title(&self) -> String {
format!("{}/{}", self.current_item_idx, self.hscroll_offset)
}
}
pub struct Input {
pub prompt: String,
alternate_prompt: String,
pub value: String,
alternate_value: String,
pub cursor_pos: u16,
pub alternate_cursor_pos: u16,
pub theme: Arc<ColorTheme>,
pub border: Option<BorderType>,
pub status_info: Option<StatusInfo>,
pub info_display: InfoDisplay,
pub reverse: bool,
}
impl Default for Input {
fn default() -> Self {
Self {
prompt: String::from(">"),
alternate_prompt: String::from("c>"),
value: String::default(),
alternate_value: String::default(),
cursor_pos: 0,
alternate_cursor_pos: 0,
theme: Arc::new(ColorTheme::default()),
border: None,
status_info: None,
info_display: InfoDisplay::Default,
reverse: false,
}
}
}
impl Input {
pub fn insert(&mut self, c: char) {
self.value.insert(self.cursor_pos.into(), c);
self.move_cursor(c.len_utf8().try_into().unwrap());
}
pub fn insert_str(&mut self, s: &str) {
self.value.insert_str(self.cursor_pos as usize, s);
self.move_cursor(
s.chars()
.count()
.try_into()
.expect("Failed to fit inserted str len into an i32"),
);
}
fn nchars(&self) -> usize {
self.value.chars().count()
}
pub fn delete(&mut self, offset: i32) -> Option<char> {
if self.value.is_empty() {
return None;
}
let new_pos = self.cursor_pos as i32 + offset;
if new_pos < 0 || new_pos as usize >= self.value.len() {
return None;
}
let pos = self.value.floor_char_boundary(new_pos as usize);
let ch = self.value.remove(pos);
if offset < 0 {
self.move_cursor(-1);
}
Some(ch)
}
pub fn move_cursor(&mut self, offset: i32) {
if offset == 0 {
return;
}
if offset < 0 {
self.move_cursor_to(
self.value
.floor_char_boundary((self.cursor_pos as i32 + offset) as usize) as u16,
);
} else {
self.move_cursor_to(
self.value
.ceil_char_boundary((self.cursor_pos as i32 + offset) as usize) as u16,
);
}
}
pub fn move_cursor_to(&mut self, pos: u16) {
if self.value.is_char_boundary(pos as usize) {
self.cursor_pos = u16::clamp(pos, 0, self.value.len() as u16);
} else {
warn!("Invalid cursor pos");
}
}
pub fn move_to_end(&mut self) {
self.move_cursor_to(
self.value
.len()
.try_into()
.expect("Failed to fit input len into an u16"),
)
}
fn is_word_char(ch: char) -> bool {
ch.is_alphanumeric()
}
fn find_next_word_end(&self, start_pos: usize) -> usize {
let mut pos = start_pos;
while pos < self.nchars() {
let ch = self.value.chars().nth(pos).unwrap();
if Self::is_word_char(ch) {
break;
}
pos += 1;
}
while pos < self.nchars() {
let ch = self.value.chars().nth(pos).unwrap();
if !Self::is_word_char(ch) {
break;
}
pos += 1;
}
pos
}
fn find_compound_word_end(&self, start_pos: usize) -> usize {
let mut pos = start_pos;
while pos < self.nchars() {
let ch = self.value.chars().nth(pos).unwrap();
if !ch.is_whitespace() {
break;
}
pos += 1;
}
while pos < self.nchars() {
let ch = self.value.chars().nth(pos).unwrap();
if ch.is_whitespace() {
break;
}
pos += 1;
}
pos
}
fn find_prev_word_start(&self, start_pos: usize) -> usize {
if start_pos == 0 {
return 0;
}
let mut pos = start_pos;
pos = pos.saturating_sub(1);
while pos > 0 && !Self::is_word_char(self.value.chars().nth(pos).unwrap()) {
pos -= 1;
}
while pos > 0 && Self::is_word_char(self.value.chars().nth(pos - 1).unwrap()) {
pos -= 1;
}
pos
}
fn find_delete_backward_pos(&self, start_pos: usize) -> usize {
if start_pos == 0 {
return 0;
}
let mut pos = start_pos;
while pos > 0 {
let ch = self.value.chars().nth(pos - 1).unwrap();
if Self::is_word_char(ch) {
break;
}
pos -= 1;
}
while pos > 0 {
let ch = self.value.chars().nth(pos - 1).unwrap();
if !Self::is_word_char(ch) {
break;
}
pos -= 1;
}
pos
}
pub fn delete_backward_word(&mut self) -> String {
if self.cursor_pos == 0 {
return String::new();
}
let start_pos = self.find_delete_backward_pos(self.cursor_pos as usize);
let deleted = self.value[start_pos..self.cursor_pos as usize].to_string();
self.value = format!(
"{}{}",
&self.value[..start_pos],
&self.value[self.cursor_pos as usize..]
);
self.cursor_pos = start_pos as u16;
deleted
}
pub fn delete_backward_to_whitespace(&mut self) -> String {
if self.cursor_pos == 0 {
return String::new();
}
let mut pos = self.cursor_pos as usize;
while pos > 0 && self.value.chars().nth(pos - 1).unwrap_or_default().is_whitespace() {
pos -= 1;
}
while pos > 0 && !self.value.chars().nth(pos - 1).unwrap_or_default().is_whitespace() {
pos -= 1;
}
let deleted = self.value[pos..self.cursor_pos as usize].to_string();
self.value = format!("{}{}", &self.value[..pos], &self.value[self.cursor_pos as usize..]);
self.cursor_pos = pos as u16;
deleted
}
pub fn delete_forward_word(&mut self) -> String {
if self.cursor_pos as usize >= self.value.len() {
return String::new();
}
let end_pos = self.find_next_word_end(self.cursor_pos as usize);
let deleted = self.value[self.cursor_pos as usize..end_pos].to_string();
self.value = format!("{}{}", &self.value[..self.cursor_pos as usize], &self.value[end_pos..]);
deleted
}
pub fn move_cursor_forward_word(&mut self) {
let new_pos = self.find_compound_word_end(self.cursor_pos as usize);
self.cursor_pos = new_pos as u16;
}
pub fn move_cursor_backward_word(&mut self) {
let new_pos = self.find_prev_word_start(self.cursor_pos as usize);
self.cursor_pos = new_pos as u16;
}
pub fn delete_to_beginning(&mut self) -> String {
let deleted = self.value[..self.cursor_pos as usize].to_string();
self.value = self.value[self.cursor_pos as usize..].to_string();
self.cursor_pos = 0;
deleted
}
pub fn cursor_pos(&self) -> u16 {
(display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
.try_into()
.expect("Failed to fit cursor char into an u16")
}
pub fn switch_mode(&mut self) {
std::mem::swap(&mut self.prompt, &mut self.alternate_prompt);
std::mem::swap(&mut self.value, &mut self.alternate_value);
std::mem::swap(&mut self.cursor_pos, &mut self.alternate_cursor_pos);
}
}
impl SkimWidget for Input {
fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
let mut res = Self {
theme,
border: options.border,
info_display: options.info.clone(),
reverse: options.layout == TuiLayout::Reverse,
..Default::default()
};
if options.interactive {
res.prompt = options.cmd_prompt.clone();
res.alternate_prompt = options.prompt.clone();
res.value = options.cmd_query.clone().unwrap_or_default();
res.alternate_value = options.query.clone().unwrap_or_default();
} else {
res.prompt = options.prompt.clone();
res.alternate_prompt = options.cmd_prompt.clone();
res.value = options.query.clone().unwrap_or_default();
res.alternate_value = options.cmd_query.clone().unwrap_or_default();
}
res.cursor_pos = res.value.len() as u16;
res.alternate_cursor_pos = res.alternate_value.len() as u16;
res
}
fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
use ratatui::layout::Alignment;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::widgets::{Block, Borders};
let mut line = self
.prompt
.into_text()
.map(|t| t.lines.into_iter().next().unwrap_or_default())
.unwrap_or_else(|_| Line::from(self.prompt.as_str()));
style_line(&mut line, self.theme.prompt);
line.push_span(Span::styled(&self.value, self.theme.query));
let mut block = Block::default();
if let Some(border_type) = self.border {
block = block
.borders(Borders::ALL)
.border_type(border_type.into())
.border_style(self.theme.border);
}
match self.info_display {
InfoDisplay::Inline => {
if let Some(ref status) = self.status_info {
let separator = status.inline_separator();
let inline_status = status.inline_status();
let right_status = status.right_title();
let prompt_width = display_width(&self.prompt);
let value_width = display_width(&self.value);
let separator_width = 4; let inline_status_width = display_width(&inline_status);
let right_status_width = display_width(&right_status);
let used_width =
prompt_width + value_width + separator_width + inline_status_width + right_status_width;
let available_width = area.width as u64;
let padding_width = available_width.saturating_sub(used_width);
line.push_span(Span::styled(format!(" {} ", separator), self.theme.info));
line.push_span(Span::styled(inline_status, self.theme.info));
line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
line.push_span(Span::styled(right_status, self.theme.info));
Paragraph::new(line)
.block(block)
.style(self.theme.normal)
.render(area, buf);
} else {
Paragraph::new(line)
.block(block)
.style(self.theme.normal)
.render(area, buf);
}
}
InfoDisplay::Default => {
if let Some(ref status) = self.status_info {
let left_title = status.left_title();
let right_title = status.right_title();
if self.reverse {
block = block
.title_bottom(Line::from(left_title).style(self.theme.info).alignment(Alignment::Left))
.title_bottom(
Line::from(right_title)
.style(self.theme.info)
.alignment(Alignment::Right),
);
} else {
block = block
.title_top(Line::from(left_title).style(self.theme.info).alignment(Alignment::Left))
.title_top(
Line::from(right_title)
.style(self.theme.info)
.alignment(Alignment::Right),
);
}
}
Paragraph::new(line)
.block(block)
.style(self.theme.normal)
.render(area, buf);
}
InfoDisplay::Hidden => {
Paragraph::new(line)
.block(block)
.style(self.theme.normal)
.render(area, buf);
}
}
SkimRender::default()
}
}
impl Deref for Input {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl DerefMut for Input {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}