use crate::config::Config;
use crate::core::{EpubReader, Reader};
use crate::progress::{BookProgress, Bookmark, ProgressStore};
use crate::ui::count_lines;
use ratatui::widgets::ListState;
use ratatui_image::FilterType;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use ratatui_image::sliced::SlicedProtocol;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(PartialEq, Clone, Copy)]
pub enum SortCriteria {
Recent,
Title,
Author,
}
#[derive(PartialEq, Clone, Copy)]
pub enum ViewMode {
Reading,
Cover,
ChapterBrowser,
BookmarkBrowser,
BookmarkRenaming,
Library,
}
#[derive(PartialEq, Clone, Copy, Debug)]
pub enum SortOrder {
Ascending,
Descending,
}
pub struct App {
pub reader: EpubReader,
pub book_id: String,
pub book_path: String,
pub current_chapter: usize,
pub scroll: usize,
pub scroll_to_start: bool,
pub scroll_to_end: bool,
pub chapter_scrolls: HashMap<usize, usize>,
pub image_protocols: HashMap<String, StatefulProtocol>,
pub sliced_protocols: HashMap<String, SlicedProtocol>,
pub mode: ViewMode,
pub cover_protocol: Option<StatefulProtocol>,
pub chapter_list_state: ListState,
pub bookmark_list_state: ListState,
pub bookmarks: Vec<Bookmark>,
pub bookmark_rename_input: String,
pub bookmark_rename_cursor_position: usize,
pub chapter_titles: Vec<String>,
pub chapter_levels: Vec<usize>,
pub expanded_chapters: HashMap<usize, bool>,
pub library_state: ListState,
pub library_books: Vec<BookProgress>,
pub library_cover_protocol: Option<StatefulProtocol>,
pub sort_criteria: SortCriteria,
pub sort_order: SortOrder,
pub total_height: usize,
pub viewport_height: usize,
pub config: Config,
pub chapter_heights: Vec<usize>,
pub last_layout_width: usize,
pub profile_dir: Option<PathBuf>,
}
impl App {
pub fn new(
reader: EpubReader,
book_id: String,
book_path: String,
picker: &mut Picker,
progress: BookProgress,
config: Config,
profile_dir: Option<PathBuf>,
) -> Self {
let cover_protocol = if let Ok(Some(data)) = reader.cover_image() {
if let Ok(img) = image::load_from_memory(&data) {
Some(picker.new_resize_protocol(img))
} else {
None
}
} else {
None
};
let current_chapter = progress.current_chapter;
let scroll = progress
.chapter_scrolls
.get(¤t_chapter)
.cloned()
.unwrap_or(0);
let (scroll_to_end, scroll_to_start) = (false, false);
let mut chapter_titles = Vec::new();
let mut chapter_levels = Vec::new();
let chapters_info = reader.get_chapters_info();
let chapter_count = reader.chapter_count();
if !chapters_info.is_empty() {
for (i, info) in chapters_info.into_iter().enumerate() {
let title = if !info.title.is_empty() {
info.title.clone()
} else if let Ok(chapter) = reader.get_chapter(i) {
chapter.title.clone()
} else {
format!("Chapter {}", i + 1)
};
chapter_titles.push(title);
chapter_levels.push(info.level);
}
} else {
for i in 0..chapter_count {
if let Ok(chapter) = reader.get_chapter(i) {
chapter_titles.push(chapter.title.to_string());
} else {
chapter_titles.push(format!("Chapter {}", i + 1));
}
chapter_levels.push(0);
}
}
let mut list_state = ListState::default();
list_state.select(Some(current_chapter));
let mut bookmark_list_state = ListState::default();
if !progress.bookmarks.is_empty() {
bookmark_list_state.select(Some(0));
}
let progress_path = profile_dir.as_ref().map(|p| {
let mut p = p.clone();
p.push("progress.json");
p
});
let store = ProgressStore::load(progress_path);
let mut library_books: Vec<BookProgress> = store.books.values().cloned().collect();
library_books.sort_by_key(|b| Reverse(b.last_read));
let library_state = ListState::default();
App {
reader,
book_id,
book_path,
current_chapter,
scroll,
scroll_to_end,
scroll_to_start,
chapter_scrolls: progress.chapter_scrolls,
image_protocols: HashMap::new(),
sliced_protocols: HashMap::new(),
mode: if cover_protocol.is_some() && current_chapter == 0 && scroll == 0 {
ViewMode::Cover
} else {
ViewMode::Reading
},
cover_protocol,
chapter_list_state: list_state,
bookmark_list_state,
bookmarks: progress.bookmarks,
bookmark_rename_input: String::new(),
bookmark_rename_cursor_position: 0,
chapter_titles,
chapter_levels,
expanded_chapters: HashMap::new(),
library_state,
library_books,
library_cover_protocol: None,
sort_criteria: SortCriteria::Recent,
sort_order: SortOrder::Ascending,
total_height: 0,
viewport_height: 0,
config,
chapter_heights: vec![0; chapter_count],
last_layout_width: 0,
profile_dir,
}
}
fn get_progress_path(&self) -> Option<std::path::PathBuf> {
self.profile_dir.as_ref().map(|p| {
let mut p = p.clone();
p.push("progress.json");
p
})
}
pub fn save_progress(&self) {
let mut store = ProgressStore::load(self.get_progress_path());
let mut chapter_scrolls = self.chapter_scrolls.clone();
chapter_scrolls.insert(self.current_chapter, self.scroll);
let last_read = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
store.last_book_path = Some(self.book_path.clone());
store.set_book(
self.book_id.clone(),
BookProgress {
current_chapter: self.current_chapter,
chapter_scrolls,
title: self.reader.title().to_string(),
author: self.reader.author().to_string(),
path: self.book_path.clone(),
last_read,
view_type: self.config.view_type.clone(),
bookmarks: self.bookmarks.clone(),
},
);
let _ = store.save();
}
pub fn update_layout(&mut self, width: usize) {
if self.last_layout_width == width {
if self.current_chapter < self.chapter_heights.len() {
self.total_height = self.chapter_heights[self.current_chapter];
}
return;
}
for i in 0..self.reader.chapter_count() {
if let Ok(chapter) = self.reader.get_chapter(i) {
let mut heights = Vec::new();
for element in &chapter.elements {
heights.push(count_lines(element, width));
}
self.chapter_heights[i] = heights.iter().sum();
}
}
self.last_layout_width = width;
if self.current_chapter < self.chapter_heights.len() {
self.total_height = self.chapter_heights[self.current_chapter];
}
}
pub fn get_global_scroll(&self) -> usize {
self.chapter_heights[..self.current_chapter]
.iter()
.sum::<usize>()
+ self.scroll
}
pub fn get_total_book_height(&self) -> usize {
self.chapter_heights.iter().sum()
}
pub fn set_global_scroll(&mut self, global: usize) {
let mut accumulated = 0;
for (i, &h) in self.chapter_heights.iter().enumerate() {
if global < accumulated + h {
self.current_chapter = i;
self.scroll = global - accumulated;
self.save_progress();
return;
}
accumulated += h;
}
self.current_chapter = self.chapter_heights.len().saturating_sub(1);
self.scroll = self.chapter_heights.last().cloned().unwrap_or(0);
self.save_progress();
}
pub fn jump_to_chapter(&mut self, index: usize) {
if index < self.reader.chapter_count() {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter = index;
self.scroll = 0;
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
self.save_progress();
}
}
pub fn next_page(&mut self, i: usize) {
if self.config.view_type == "continuous" {
let jump = self.viewport_height;
let current = self.get_global_scroll();
self.set_global_scroll(current + jump);
return;
}
let jump = if self.config.view_type == "book" && i == 2 {
2 * self.viewport_height
} else {
self.viewport_height
};
if self.scroll + jump < self.total_height {
self.scroll += jump;
self.save_progress();
} else {
self.next_chapter();
}
}
pub fn prev_page(&mut self, i: usize) {
if self.config.view_type == "continuous" {
let jump = self.viewport_height;
let current = self.get_global_scroll();
self.set_global_scroll(current.saturating_sub(jump));
return;
}
let jump = if self.config.view_type == "book" && i == 2 {
2 * self.viewport_height
} else {
self.viewport_height
};
if self.scroll >= jump {
self.scroll -= jump;
self.save_progress();
} else if self.scroll > 0 {
self.scroll = 0;
self.save_progress();
} else {
self.previous_chapter_end();
}
}
pub fn previous_chapter_end(&mut self) {
if self.current_chapter > 0 {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter -= 1;
if self.config.view_type == "continuous" {
if self.current_chapter < self.chapter_heights.len() {
self.scroll = self.chapter_heights[self.current_chapter]
.saturating_sub(self.viewport_height);
} else {
self.scroll = 0;
self.scroll_to_end = true;
}
} else {
self.scroll = 0;
self.scroll_to_end = true;
}
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
self.save_progress();
}
}
pub fn previous_chapter(&mut self) {
if self.current_chapter > 0 {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter -= 1;
if self.config.view_type == "continuous" {
self.scroll = 0;
} else {
self.scroll = self
.chapter_scrolls
.get(&self.current_chapter)
.cloned()
.unwrap_or(0);
}
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
self.save_progress();
}
}
pub fn next_chapter(&mut self) {
if self.current_chapter + 1 < self.reader.chapter_count() {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter += 1;
if self.config.view_type == "continuous" {
self.scroll = 0;
} else {
self.scroll = self
.chapter_scrolls
.get(&self.current_chapter)
.cloned()
.unwrap_or(0);
}
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
self.save_progress();
}
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
ViewMode::Reading => ViewMode::Cover,
ViewMode::Cover => ViewMode::Reading,
ViewMode::ChapterBrowser => ViewMode::Reading,
ViewMode::BookmarkBrowser => ViewMode::Reading,
ViewMode::BookmarkRenaming => ViewMode::Reading,
ViewMode::Library => ViewMode::Reading,
};
}
pub fn open_chapter_browser(&mut self) {
self.chapter_list_state.select(Some(self.current_chapter));
self.mode = ViewMode::ChapterBrowser;
}
pub fn add_bookmark(&mut self) {
let chapter_title = self
.chapter_titles
.get(self.current_chapter)
.cloned()
.unwrap_or_default();
let text_preview = if let Ok(chapter) = self.reader.get_chapter(self.current_chapter) {
let full_text = chapter.content_as_text();
full_text
.chars()
.take(100)
.collect::<String>()
.replace('\n', " ")
+ "..."
} else {
"No preview available".to_string()
};
let bookmark = Bookmark {
name: chapter_title.clone(),
chapter_index: self.current_chapter,
scroll_position: self.scroll,
chapter_title,
text_preview,
created_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
self.bookmarks.push(bookmark);
self.save_progress();
}
pub fn remove_bookmark(&mut self) {
if let Some(selected) = self.bookmark_list_state.selected()
&& selected < self.bookmarks.len()
{
self.bookmarks.remove(selected);
if self.bookmarks.is_empty() {
self.bookmark_list_state.select(None);
} else {
let new_selection = selected.min(self.bookmarks.len() - 1);
self.bookmark_list_state.select(Some(new_selection));
}
self.save_progress();
}
}
pub fn jump_to_bookmark(&mut self) {
if let Some(selected) = self.bookmark_list_state.selected()
&& let Some(bookmark) = self.bookmarks.get(selected)
{
let chapter_index = bookmark.chapter_index;
let scroll = bookmark.scroll_position;
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter = chapter_index;
self.scroll = scroll;
self.mode = ViewMode::Reading;
self.chapter_list_state.select(Some(self.current_chapter));
self.save_progress();
}
}
pub fn open_bookmark_browser(&mut self) {
if !self.bookmarks.is_empty() && self.bookmark_list_state.selected().is_none() {
self.bookmark_list_state.select(Some(0));
}
self.mode = ViewMode::BookmarkBrowser;
}
pub fn start_renaming_bookmark(&mut self) {
if let Some(selected) = self.bookmark_list_state.selected()
&& let Some(bookmark) = self.bookmarks.get(selected)
{
self.bookmark_rename_input = bookmark.name.clone();
self.bookmark_rename_cursor_position = self.bookmark_rename_input.len();
self.mode = ViewMode::BookmarkRenaming;
}
}
pub fn finish_renaming_bookmark(&mut self) {
if let Some(selected) = self.bookmark_list_state.selected()
&& selected < self.bookmarks.len()
{
self.bookmarks[selected].name = self.bookmark_rename_input.clone();
self.save_progress();
}
self.mode = ViewMode::BookmarkBrowser;
}
pub fn expand_chapter(&mut self) {
if let Some(selected) = self.chapter_list_state.selected() {
let visible_indices = self.get_visible_chapter_indices();
if let Some(&real_index) = visible_indices.get(selected) {
if real_index + 1 < self.chapter_levels.len()
&& self.chapter_levels[real_index + 1] > self.chapter_levels[real_index]
{
self.expanded_chapters.insert(real_index, true);
}
}
}
}
pub fn collapse_chapter(&mut self) {
if let Some(selected) = self.chapter_list_state.selected() {
let visible_indices = self.get_visible_chapter_indices();
if let Some(&real_index) = visible_indices.get(selected) {
if self
.expanded_chapters
.get(&real_index)
.cloned()
.unwrap_or(false)
{
self.expanded_chapters.insert(real_index, false);
} else {
let current_level = self.chapter_levels[real_index];
if current_level > 0 {
for i in (0..real_index).rev() {
if self.chapter_levels[i] < current_level {
self.expanded_chapters.insert(i, false);
let new_visible = self.get_visible_chapter_indices();
if let Some(new_pos) = new_visible.iter().position(|&idx| idx == i)
{
self.chapter_list_state.select(Some(new_pos));
}
break;
}
}
}
}
}
}
}
pub fn get_visible_chapter_indices(&self) -> Vec<usize> {
let mut visible = Vec::new();
let mut i = 0;
while i < self.chapter_titles.len() {
visible.push(i);
let level = self.chapter_levels[i];
let is_expanded = self.expanded_chapters.get(&i).cloned().unwrap_or(false);
if !is_expanded {
let mut j = i + 1;
while j < self.chapter_titles.len() && self.chapter_levels[j] > level {
j += 1;
}
i = j;
} else {
i += 1;
}
}
visible
}
pub fn open_library(&mut self, picker: &mut Picker) {
let store = ProgressStore::load(self.get_progress_path());
self.library_books = store
.books
.values()
.filter(|b| Path::new(&b.path).exists())
.cloned()
.collect();
if self.sort_order == SortOrder::Ascending {
self.sort_library_asc();
} else {
self.sort_library_desc();
}
let current_index = self
.library_books
.iter()
.position(|b| b.path == self.book_path);
if self.library_state.selected().is_none() {
self.library_state
.select(current_index.or(if self.library_books.is_empty() {
None
} else {
Some(0)
}));
}
self.mode = ViewMode::Library;
self.update_library_cover(picker);
}
pub fn sort_library_asc(&mut self) {
match self.sort_criteria {
SortCriteria::Recent => self.library_books.sort_by_key(|b| Reverse(b.last_read)),
SortCriteria::Title => self.library_books.sort_by(|a, b| a.title.cmp(&b.title)),
SortCriteria::Author => self.library_books.sort_by(|a, b| a.author.cmp(&b.author)),
}
}
pub fn sort_library_desc(&mut self) {
match self.sort_criteria {
SortCriteria::Recent => self.library_books.sort_by_key(|b| b.last_read),
SortCriteria::Title => self.library_books.sort_by(|b, a| a.title.cmp(&b.title)),
SortCriteria::Author => self.library_books.sort_by(|b, a| a.author.cmp(&b.author)),
}
}
pub fn toggle_sort(&mut self, picker: &mut Picker) {
if self.sort_order == SortOrder::Ascending {
self.sort_order = SortOrder::Descending;
self.sort_library_desc();
} else {
self.sort_order = SortOrder::Ascending;
self.sort_library_asc();
}
self.library_state.select(Some(0));
self.update_library_cover(picker);
}
pub fn delete_book(&mut self, i: usize, store: &mut ProgressStore) {
if i < self.library_books.len() {
let book = &self.library_books[i];
store.remove_book(&book.title, &book.author);
self.library_books.remove(i);
let _ = store.save();
}
}
pub fn next_sort_criteria(&mut self, picker: &mut Picker) {
self.sort_criteria = match self.sort_criteria {
SortCriteria::Recent => SortCriteria::Title,
SortCriteria::Title => SortCriteria::Author,
SortCriteria::Author => SortCriteria::Recent,
};
if self.sort_order == SortOrder::Ascending {
self.sort_library_asc();
} else {
self.sort_library_desc();
}
self.library_state.select(Some(0));
self.update_library_cover(picker);
}
pub fn update_library_cover(&mut self, picker: &mut Picker) {
if let Some(selected) = self.library_state.selected()
&& let Some(book) = self.library_books.get(selected)
&& let Ok(reader) = EpubReader::new(&book.path)
&& let Ok(Some(data)) = reader.cover_image()
&& let Ok(img) = image::load_from_memory(&data)
{
self.library_cover_protocol = Some(picker.new_resize_protocol(img));
return;
}
self.library_cover_protocol = None;
}
pub fn select_book(&mut self) -> Option<String> {
if let Some(selected) = self.library_state.selected()
&& let Some(book) = self.library_books.get(selected)
{
if book.path != self.book_path {
return Some(book.path.clone());
} else {
self.mode = ViewMode::Reading;
}
}
None
}
pub fn go_to_end(&mut self) {
self.scroll_to_end = true;
}
pub fn go_to_start(&mut self) {
self.scroll_to_start = true;
}
pub fn scroll_down(&mut self, i: usize) {
if self.config.view_type == "continuous" {
let current = self.get_global_scroll();
self.set_global_scroll(current + i);
return;
}
if self.scroll + i < self.total_height {
self.scroll += i;
} else if self.current_chapter + 1 < self.reader.chapter_count() {
self.next_chapter();
self.scroll = 0;
} else {
let max_scroll = self.total_height.saturating_sub(self.viewport_height);
self.scroll = self.scroll.saturating_add(i).min(max_scroll);
}
self.save_progress();
}
pub fn scroll_up(&mut self, i: usize) {
if self.config.view_type == "continuous" {
let current = self.get_global_scroll();
self.set_global_scroll(current.saturating_sub(i));
return;
}
if self.scroll >= i {
self.scroll -= i;
} else if self.current_chapter > 0 {
self.previous_chapter_end();
} else {
self.scroll = 0;
}
self.save_progress();
}
pub fn get_image_protocol(
&mut self,
path: &str,
picker: &mut Picker,
) -> Option<&mut StatefulProtocol> {
if !self.image_protocols.contains_key(path)
&& let Ok(data) = self.reader.get_image_by_path(path)
&& let Ok(img) = image::load_from_memory(&data)
{
self.image_protocols
.insert(path.to_string(), picker.new_resize_protocol(img));
}
self.image_protocols.get_mut(path)
}
pub fn get_sliced_protocol(
&mut self,
path: &str,
width: u16,
height: u16,
picker: &mut Picker,
) -> Option<&SlicedProtocol> {
let key = format!("{}:{}:{}", path, width, height);
if !self.sliced_protocols.contains_key(&key)
&& let Ok(data) = self.reader.get_image_by_path(path)
&& let Ok(img) = image::load_from_memory(&data)
{
let font_size = picker.font_size();
let font_width = if font_size.width == 0 {
10
} else {
font_size.width
};
let font_height = if font_size.height == 0 {
20
} else {
font_size.height
};
let target_px_width = width as u32 * font_width as u32;
let target_px_height = height as u32 * font_height as u32;
let img = if (img.width() > target_px_width || img.height() > target_px_height)
&& target_px_width > 0
&& target_px_height > 0
{
img.resize(target_px_width, target_px_height, FilterType::Lanczos3)
} else {
img
};
if let Ok(protocol) = SlicedProtocol::new(picker, img, None) {
self.sliced_protocols.insert(key.clone(), protocol);
}
}
self.sliced_protocols.get(&key)
}
}