use crossterm::event::KeyCode;
use event::KeyModifiers;
use ratatui::widgets::ListState;
use super::*;
#[derive(Debug, PartialEq)]
pub enum FocusedPane {
List,
Details,
}
#[derive(Debug)]
pub enum DialogType {
None,
ExitConfirm,
PDFNotFound,
CommandInput,
PDFConfirm {
paper: Paper,
},
RemoveConfirm {
papers: Vec<Paper>,
args: RemoveArgs,
},
SearchResults {
query: String,
papers: Vec<Paper>,
selected: ListState,
},
Success {
message: String,
},
}
pub struct UIState {
pub papers: Vec<Paper>,
pub selected: ListState,
pub dialog: DialogType,
pub focused_pane: FocusedPane,
pub scroll_position: usize,
pub max_scroll: Option<usize>,
pub needs_redraw: bool,
pub status_message: Option<String>,
pub command_buffer: CommandBuffer,
pub pending_command: Option<Commands>,
}
impl UIState {
pub fn new(papers: Vec<Paper>) -> Self {
let mut selected = ListState::default();
selected.select(Some(0));
Self {
papers,
selected,
dialog: DialogType::None,
focused_pane: FocusedPane::List,
scroll_position: 0,
max_scroll: None,
needs_redraw: true,
status_message: None,
command_buffer: CommandBuffer::new(),
pending_command: None,
}
}
pub fn set_status_message(&mut self, message: String) {
self.status_message = Some(message);
self.needs_redraw = true;
}
pub fn selected_paper(&self) -> Option<&Paper> {
self.selected.selected().map(|i| &self.papers[i])
}
pub fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) -> bool {
match &self.dialog {
DialogType::ExitConfirm => self.handle_exit_dialog(key),
DialogType::PDFNotFound => self.handle_pdf_not_found_dialog(key),
DialogType::CommandInput { .. } => self.handle_command_input(key, modifiers),
DialogType::RemoveConfirm { .. } => self.handle_remove_confirm(key),
DialogType::SearchResults { .. } => self.handle_search_results(key),
DialogType::PDFConfirm { .. } => self.handle_pdf_confirm(key),
DialogType::Success { .. } => {
if matches!(key, KeyCode::Enter | KeyCode::Esc) {
self.dialog = DialogType::None;
self.needs_redraw = true;
}
false
},
DialogType::None => self.handle_normal_input(key),
}
}
fn handle_search_results(&mut self, key: KeyCode) -> bool {
if let DialogType::SearchResults { papers, selected, .. } = &mut self.dialog {
match key {
KeyCode::Up | KeyCode::Char('k') => {
if let Some(i) = selected.selected() {
if i > 0 {
selected.select(Some(i - 1));
self.needs_redraw = true;
}
}
},
KeyCode::Down | KeyCode::Char('j') => {
if let Some(i) = selected.selected() {
if i < papers.len() - 1 {
selected.select(Some(i + 1));
self.needs_redraw = true;
}
}
},
KeyCode::Enter => {
if let Some(selected_idx) = selected.selected() {
let selected_paper = &papers[selected_idx];
if let Some(main_idx) = self.papers.iter().position(|p| {
p.source == selected_paper.source
&& p.source_identifier == selected_paper.source_identifier
}) {
self.selected.select(Some(main_idx));
}
}
self.dialog = DialogType::None;
self.needs_redraw = true;
},
KeyCode::Esc => {
self.dialog = DialogType::None;
self.needs_redraw = true;
},
_ => {},
}
}
false
}
fn handle_remove_confirm(&mut self, key: KeyCode) -> bool {
if let DialogType::RemoveConfirm { args, .. } = &self.dialog {
match key {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let mut confirmed_args = args.clone();
confirmed_args.force = true;
self.pending_command = Some(Commands::Remove(confirmed_args));
self.dialog = DialogType::None;
self.needs_redraw = true;
},
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.dialog = DialogType::None;
self.needs_redraw = true;
},
_ => {},
}
}
false
}
fn handle_command_input(&mut self, key: KeyCode, modifiers: KeyModifiers) -> bool {
match (key, modifiers) {
(key, KeyModifiers::NONE) => match key {
KeyCode::Esc => {
self.command_buffer.reset();
self.dialog = DialogType::None;
self.needs_redraw = true;
},
KeyCode::Enter => {
if let Some(cmd) = self.command_buffer.try_execute() {
self.pending_command = Some(cmd);
self.command_buffer.reset();
self.dialog = DialogType::None;
}
self.needs_redraw = true;
},
KeyCode::Char(c) => {
self.command_buffer.insert_char(c);
self.needs_redraw = true;
},
KeyCode::Backspace => {
self.command_buffer.backspace();
self.needs_redraw = true;
},
KeyCode::Left => {
self.command_buffer.move_cursor_left();
self.needs_redraw = true;
},
KeyCode::Right => {
self.command_buffer.move_cursor_right();
self.needs_redraw = true;
},
KeyCode::Up => {
self.command_buffer.previous_history();
self.needs_redraw = true;
},
KeyCode::Down => {
self.command_buffer.next_history();
self.needs_redraw = true;
},
KeyCode::Tab => {
let completions = self.command_buffer.get_completions();
if completions.len() == 1 {
let parts: Vec<&str> = self.command_buffer.input.split_whitespace().collect();
let new_input = if parts.len() <= 1 {
format!("{} ", completions[0])
} else {
let base =
&self.command_buffer.input[..self.command_buffer.input.rfind(' ').unwrap() + 1];
format!("{}{} ", base, completions[0])
};
self.command_buffer.input = new_input;
self.command_buffer.cursor_position = self.command_buffer.input.len();
}
self.needs_redraw = true;
},
_ => {},
},
(KeyCode::Char('w'), KeyModifiers::CONTROL) => {
self.command_buffer.delete_word();
self.needs_redraw = true;
},
_ => {},
}
false
}
fn handle_pdf_confirm(&mut self, key: KeyCode) -> bool {
if let DialogType::PDFConfirm { paper } = &self.dialog {
match key {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.pending_command = Some(Commands::Add(AddArgs {
identifier: paper.source_identifier.clone(),
pdf: true,
no_pdf: false,
}));
self.dialog = DialogType::None;
self.needs_redraw = true;
},
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.dialog = DialogType::None;
self.needs_redraw = true;
},
_ => {},
}
}
false
}
fn handle_exit_dialog(&mut self, key: KeyCode) -> bool {
match key {
KeyCode::Char('y') => true,
KeyCode::Char('n') | KeyCode::Esc => {
self.dialog = DialogType::None;
self.needs_redraw = true;
false
},
_ => false,
}
}
fn handle_pdf_not_found_dialog(&mut self, key: KeyCode) -> bool {
if key == KeyCode::Enter {
self.dialog = DialogType::None;
self.needs_redraw = true;
}
false
}
fn handle_normal_input(&mut self, key: KeyCode) -> bool {
match key {
KeyCode::Char('q') => {
self.dialog = DialogType::ExitConfirm;
self.needs_redraw = true;
false
},
KeyCode::Left | KeyCode::Char('h') => {
if self.focused_pane == FocusedPane::Details {
self.focused_pane = FocusedPane::List;
self.needs_redraw = true;
}
false
},
KeyCode::Right | KeyCode::Char('l') => {
if self.focused_pane == FocusedPane::List {
self.focused_pane = FocusedPane::Details;
self.needs_redraw = true;
}
false
},
KeyCode::Up | KeyCode::Char('k') => {
self.handle_up_navigation();
false
},
KeyCode::Down | KeyCode::Char('j') => {
self.handle_down_navigation();
false
},
KeyCode::Char('o') => {
self.handle_open_pdf();
false
},
KeyCode::Char(':') => {
self.dialog = DialogType::CommandInput;
self.needs_redraw = true;
false
},
_ => false,
}
}
fn handle_up_navigation(&mut self) {
match self.focused_pane {
FocusedPane::List => {
let i = self.selected.selected().unwrap_or(0);
if i > 0 {
self.selected.select(Some(i - 1));
self.needs_redraw = true;
}
},
FocusedPane::Details =>
if self.scroll_position > 0 {
self.scroll_position -= 1;
self.needs_redraw = true;
},
}
}
fn handle_down_navigation(&mut self) {
match self.focused_pane {
FocusedPane::List => {
let i = self.selected.selected().unwrap_or(0);
if i < self.papers.len().saturating_sub(1) {
self.selected.select(Some(i + 1));
self.needs_redraw = true;
}
},
FocusedPane::Details =>
if let Some(max) = self.max_scroll {
if self.scroll_position < max {
self.scroll_position += 1;
self.needs_redraw = true;
}
},
}
}
fn handle_open_pdf(&mut self) {
if let Some(paper) = self.selected_paper() {
let pdf_path = format!(
"{}/{}.pdf",
Database::default_storage_path().display(),
format_title(&paper.title, Some(50))
);
if std::path::Path::new(&pdf_path).exists() {
self.open_pdf_with_system_viewer(&pdf_path);
} else {
self.dialog = DialogType::PDFNotFound;
self.needs_redraw = true;
}
}
}
pub fn update_max_scroll(&mut self, available_lines: usize, visible_lines: usize) {
self.max_scroll = Some(available_lines.saturating_sub(visible_lines));
}
#[cfg(target_os = "windows")]
fn open_pdf_with_system_viewer(&self, path: &str) {
let _ = std::process::Command::new("cmd").args(["/C", "start", "", path]).spawn();
}
#[cfg(target_os = "macos")]
fn open_pdf_with_system_viewer(&self, path: &str) {
let _ = std::process::Command::new("open").arg(path).spawn();
}
#[cfg(target_os = "linux")]
fn open_pdf_with_system_viewer(&self, path: &str) {
let _ = std::process::Command::new("xdg-open").arg(path).spawn();
}
}
#[derive(Default, Debug)]
pub struct CommandBuffer {
pub input: String,
pub cursor_position: usize,
pub history: Vec<String>,
pub history_position: isize,
pub current_input: String,
pub error: Option<String>,
}
impl CommandBuffer {
pub fn new() -> Self {
Self {
input: String::new(),
cursor_position: 0,
history: Vec::new(),
history_position: -1,
current_input: String::new(),
error: None,
}
}
pub fn try_execute(&mut self) -> Option<Commands> {
self.error = None;
let input = self.input.trim();
if input.is_empty() {
return None;
}
match Commands::from_str(input) {
Ok(cmd) => {
if !input.is_empty() {
self.history.push(input.to_string());
}
self.reset();
Some(cmd)
},
Err(e) => {
self.error = Some(e);
None
},
}
}
pub fn get_completions(&self) -> Vec<String> {
let input = self.input.trim();
if input.is_empty() {
return Commands::command_list().iter().map(|&s| s.to_string()).collect();
}
let parts: Vec<&str> = input.split_whitespace().collect();
match parts.first() {
Some(&cmd) if parts.len() == 1 => Commands::command_list()
.iter()
.filter(|c| c.starts_with(cmd))
.map(|&s| s.to_string())
.collect(),
Some(&cmd) if parts.last().unwrap().starts_with("--") => {
let current = parts.last().unwrap();
Commands::flags_for_command(cmd)
.iter()
.filter(|f| f.starts_with(current))
.map(|&s| s.to_string())
.collect()
},
_ => Vec::new(),
}
}
pub fn insert_char(&mut self, c: char) {
self.error = None;
self.input.insert(self.cursor_position, c);
self.cursor_position += 1;
}
pub fn backspace(&mut self) {
self.error = None;
if self.cursor_position > 0 {
self.cursor_position -= 1;
self.input.remove(self.cursor_position);
}
}
pub fn move_cursor_left(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
pub fn move_cursor_right(&mut self) {
if self.cursor_position < self.input.len() {
self.cursor_position += 1;
}
}
pub fn delete_word(&mut self) {
self.error = None;
let mut word_start = self.cursor_position;
while word_start > 0 && !self.input[..word_start].chars().last().unwrap().is_whitespace() {
word_start -= 1;
}
self.input.replace_range(word_start..self.cursor_position, "");
self.cursor_position = word_start;
}
pub fn previous_history(&mut self) {
if self.history.is_empty() {
return;
}
if self.history_position == -1 {
self.current_input = self.input.clone();
}
if self.history_position < (self.history.len() as isize - 1) {
self.history_position += 1;
self.input = self.history[self.history.len() - 1 - self.history_position as usize].clone();
self.cursor_position = self.input.len();
}
}
pub fn next_history(&mut self) {
if self.history_position >= 0 {
self.history_position -= 1;
if self.history_position == -1 {
self.input = self.current_input.clone();
} else {
self.input = self.history[self.history.len() - 1 - self.history_position as usize].clone();
}
self.cursor_position = self.input.len();
}
}
pub fn reset(&mut self) {
self.input.clear();
self.cursor_position = 0;
self.history_position = -1;
self.current_input.clear();
self.error = None;
}
}