use crate::{
core::what_is,
filter::{Filter, FilterParseError},
open,
query::TagTable,
};
use std::{fmt::Debug, path::PathBuf};
pub enum State {
Default,
Autocomplete,
ListsUpdated,
Exit,
}
enum Command {
Exit,
Reset,
Filter(Filter),
WhatIs(PathBuf),
Open(PathBuf),
}
enum Error {
InvalidCommand(String),
InvalidFilter(FilterParseError),
}
impl Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCommand(message) => write!(f, "Invalid command: {message}"),
Self::InvalidFilter(err) => write!(f, "Invalid filter: {err:?}"),
}
}
}
pub struct InteractiveSession {
table: TagTable,
command: String,
echo: String,
state: State,
tag_active: Vec<bool>,
filtered_indices: Vec<usize>,
filter_str: String,
taglist: Vec<String>,
filelist: Vec<String>,
command_completions: Box<[String]>,
suggestions: Vec<String>,
suggestion_index: usize,
}
impl InteractiveSession {
pub fn init(table: TagTable) -> InteractiveSession {
let taglist = table.tags().to_vec();
let ntags = table.tags().len();
let nfiles = table.files().len();
let mut app = InteractiveSession {
table,
command: String::new(),
echo: String::new(),
state: State::Default,
tag_active: vec![true; ntags],
taglist,
filelist: Vec::with_capacity(nfiles),
filtered_indices: (0..nfiles).collect(),
filter_str: String::new(),
command_completions: ["exit", "quit", "reset", "whatis", "open"]
.iter()
.map(|s| s.to_string())
.collect(),
suggestions: Vec::new(),
suggestion_index: 0,
};
InteractiveSession::update_file_list(
&app.filtered_indices,
app.table.files(),
&mut app.filelist,
);
app
}
fn reset(&mut self) {
self.filter_str.clear();
self.filtered_indices.clear();
self.filtered_indices.extend(0..self.num_files());
self.update_lists();
self.echo.clear();
self.state = State::Default;
self.tag_active.fill(true);
self.state = State::ListsUpdated;
}
fn parse_index_to_filepath(&self, numstr: &str) -> Result<PathBuf, Error> {
let index = match numstr.parse::<usize>() {
Ok(num) if num < self.filtered_indices.len() => Ok(num),
Ok(num) => Err(Error::InvalidCommand(format!(
"{num} is not a valid choice. Please choose an index between 0 and {}",
self.filtered_indices.len().saturating_sub(1)
))),
Err(_) => Err(Error::InvalidCommand(format!(
"Unable to parse '{numstr}' to an index."
))),
}?;
let mut path = self.table.path().to_path_buf();
path.push(&self.table.files()[self.filtered_indices[index]]);
Ok(path)
}
fn parse_command(&mut self) -> Result<Command, Error> {
let cmd = self.command.trim();
match cmd.strip_prefix('/') {
Some("exit") => Ok(Command::Exit),
Some("quit") => Ok(Command::Exit),
Some("reset") => Ok(Command::Reset),
Some(cmd) => match cmd.split_once(char::is_whitespace) {
Some(("whatis", numstr)) => {
Ok(Command::WhatIs(self.parse_index_to_filepath(numstr)?))
}
Some(("open", numstr)) => Ok(Command::Open(self.parse_index_to_filepath(numstr)?)),
_ => Err(Error::InvalidCommand(cmd.to_string())),
},
None => Ok(Command::Filter(
Filter::parse(
&format!("{} {cmd}", self.filter_str),
self.table.tag_parse_fn(),
)
.map_err(Error::InvalidFilter)?,
)),
}
}
fn num_files(&self) -> usize {
self.table.files().len()
}
fn update_file_list(indices: &[usize], files: &[String], dst: &mut Vec<String>) {
dst.clear();
dst.reserve(indices.len());
dst.extend(indices.iter().map(|i| files[*i].clone()));
}
fn update_tag_list(
indices: &[usize],
tags: &[String],
table: &TagTable,
active: &mut [bool],
dst: &mut Vec<String>,
) {
active.fill(false);
for flags in indices.iter().map(|i| table.flags(*i)) {
active
.iter_mut()
.zip(flags.iter())
.for_each(|(dst, src)| *dst = *dst || *src);
}
dst.clear();
dst.extend(
tags.iter()
.zip(0..table.tags().len())
.filter_map(|(t, i)| if active[i] { Some(t.clone()) } else { None }),
);
}
fn update_lists(&mut self) {
Self::update_file_list(
&self.filtered_indices,
self.table.files(),
&mut self.filelist,
);
Self::update_tag_list(
&self.filtered_indices,
self.table.tags(),
&self.table,
&mut self.tag_active,
&mut self.taglist,
);
}
fn last_word_start(&self) -> usize {
const DELIMS: &str = " ()&|!/";
DELIMS
.chars()
.map(|ch| match self.command.rfind(ch) {
Some(val) => val + 1,
None => 0,
})
.max()
.unwrap_or(0)
}
pub fn table(&self) -> &TagTable {
&self.table
}
pub fn taglist(&self) -> &[String] {
&self.taglist
}
pub fn command_mut(&mut self) -> &mut String {
&mut self.command
}
pub fn command(&self) -> &str {
&self.command
}
pub fn state(&self) -> &State {
&self.state
}
pub fn set_state(&mut self, state: State) {
self.state = state;
}
pub fn filelist(&self) -> &[String] {
&self.filelist
}
pub fn echo(&self) -> &str {
&self.echo
}
pub fn set_echo(&mut self, message: &str) {
self.echo = message.to_string();
}
pub fn filter_str(&self) -> &str {
&self.filter_str
}
pub fn process_input(&mut self) {
match self.state {
State::ListsUpdated | State::Default => {
match self.parse_command() {
Ok(cmd) => match cmd {
Command::Exit => self.state = State::Exit,
Command::WhatIs(path) => {
self.echo = what_is(&path)
.unwrap_or(String::from(
"Unable to fetch the description of this file.",
))
.to_string();
}
Command::Filter(filter) => {
self.filtered_indices.clear();
self.filtered_indices.extend(
(0..self.num_files())
.filter(|fi| filter.eval(|ti| self.table.flags(*fi)[ti])),
);
self.update_lists();
self.filter_str = filter.text(self.table.tags());
self.state = State::ListsUpdated;
}
Command::Reset => self.reset(),
Command::Open(path) => match open::open(path) {
Ok(_) => {} Err(_) => self.echo = String::from("Unable to open the file."),
},
},
Err(e) => self.echo = format!("{e:?}"),
}
self.command.clear();
}
State::Autocomplete => match self.suggestions.get(self.suggestion_index) {
Some(accepted) => {
self.command.truncate(self.last_word_start());
self.command.push_str(accepted);
self.state = State::Default;
self.echo.clear();
}
None => {
self.state = State::Default;
self.echo.clear();
}
},
State::Exit => {} }
}
fn show_suggestions(&mut self) {
self.echo.clear();
for (i, suggestion) in self.suggestions.iter().enumerate() {
if i == self.suggestion_index {
self.echo.push_str(&format!("[{suggestion}]"));
} else {
self.echo.push_str(&format!(" {suggestion} "));
}
}
}
pub fn autocomplete(&mut self) {
let next_state = match self.state {
State::ListsUpdated | State::Default => {
self.suggestions.clear();
let start = self.last_word_start();
let word = &self.command[start..];
if self.command.starts_with('/') {
self.suggestions
.extend(self.command_completions.iter().filter_map(|c| {
if c.starts_with(word) {
Some(c.to_string())
} else {
None
}
}));
} else {
self.suggestions
.extend(self.table.tags().iter().filter_map(|t| {
if t.starts_with(word) {
Some(t.to_string())
} else {
None
}
}));
}
if self.suggestions.is_empty() {
State::Default
} else {
self.suggestion_index = 0;
self.show_suggestions();
State::Autocomplete
}
}
State::Autocomplete => {
if self.suggestions.is_empty() {
self.suggestion_index = 0;
State::Default
} else {
self.suggestion_index = (self.suggestion_index + 1) % self.suggestions.len();
self.show_suggestions();
State::Autocomplete
}
}
State::Exit => State::Exit, };
self.state = next_state;
}
pub fn stop_autocomplete(&mut self) {
match &self.state {
State::ListsUpdated | State::Default => {}
State::Autocomplete => {
self.suggestions.clear();
self.suggestion_index = 0;
self.echo.clear();
self.state = State::Default;
}
State::Exit => {} }
}
}