mod error;
mod models;
mod progress;
use crate::models::{ContentElement, EpubReader};
use crate::progress::{BookProgress, ProgressStore};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::text::Text;
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Margin, Rect},
widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
};
use ratatui_image::{StatefulImage, picker::Picker, protocol::StatefulProtocol};
use std::{collections::HashMap, error::Error, io};
enum ViewMode {
Reading,
Cover,
ChapterBrowser,
}
struct App {
reader: EpubReader,
book_id: String,
book_path: String,
current_chapter: usize,
scroll: usize,
chapter_scrolls: HashMap<usize, usize>,
image_protocols: HashMap<String, StatefulProtocol>,
mode: ViewMode,
cover_protocol: Option<StatefulProtocol>,
chapter_list_state: ListState,
chapter_titles: Vec<String>,
}
impl App {
fn new(
reader: EpubReader,
book_id: String,
book_path: String,
picker: &mut Picker,
progress: BookProgress,
) -> 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 mut chapter_titles = Vec::new();
for i in 0..reader.chapter_count() {
if let Ok(chapter) = reader.get_chapter(i) {
chapter_titles.push(format!("Chapter {}: {}", i + 1, chapter.title));
} else {
chapter_titles.push(format!("Chapter {}", i + 1));
}
}
let mut list_state = ListState::default();
list_state.select(Some(current_chapter));
App {
reader,
book_id,
book_path,
current_chapter,
scroll,
chapter_scrolls: progress.chapter_scrolls,
image_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,
chapter_titles,
}
}
fn save_progress(&self) {
let mut store = ProgressStore::load();
let mut chapter_scrolls = self.chapter_scrolls.clone();
chapter_scrolls.insert(self.current_chapter, self.scroll);
store.last_book_path = Some(self.book_path.clone());
store.set_book(
self.book_id.clone(),
BookProgress {
current_chapter: self.current_chapter,
chapter_scrolls,
},
);
let _ = store.save();
}
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 = self
.chapter_scrolls
.get(&self.current_chapter)
.cloned()
.unwrap_or(0);
self.mode = ViewMode::Reading;
}
}
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;
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));
}
}
fn previous_chapter(&mut self) {
if self.current_chapter > 0 {
self.chapter_scrolls
.insert(self.current_chapter, self.scroll);
self.current_chapter -= 1;
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));
}
}
fn toggle_mode(&mut self) {
self.mode = match self.mode {
ViewMode::Reading => ViewMode::Cover,
ViewMode::Cover => ViewMode::Reading,
ViewMode::ChapterBrowser => ViewMode::Reading,
};
}
fn open_chapter_browser(&mut self) {
self.chapter_list_state.select(Some(self.current_chapter));
self.mode = ViewMode::ChapterBrowser;
}
fn scroll_down(&mut self) {
self.scroll = self.scroll.saturating_add(1);
}
fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
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)
}
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let progress_store = ProgressStore::load();
let file_path = if args.len() >= 2 {
args[1].clone()
} else if let Some(ref last_path) = progress_store.last_book_path {
last_path.clone()
} else {
eprintln!("Usage: {} <epub_file>", args[0]);
std::process::exit(1);
};
let absolute_path = std::fs::canonicalize(&file_path)
.map_err(|e| format!("Failed to resolve path '{}': {}", file_path, e))?;
let file_path_str = absolute_path.to_string_lossy().to_string();
let reader = EpubReader::new(&file_path_str)?;
let book_id = std::path::Path::new(&file_path_str)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| file_path_str.clone());
let book_progress = progress_store.get_book(&book_id);
let mut picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let mut app = App::new(reader, book_id, file_path_str, &mut picker, book_progress);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut picker_clone = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
let res = run_app(&mut terminal, &mut app, &mut picker_clone);
app.save_progress();
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
}
Ok(())
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
picker: &mut Picker,
) -> Result<(), Box<dyn Error>>
where
B::Error: 'static,
{
loop {
terminal.draw(|f| ui(f, app, picker))?;
if let Event::Key(key) = event::read()? {
match app.mode {
ViewMode::ChapterBrowser => match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Esc => app.toggle_mode(),
KeyCode::Enter => {
if let Some(selected) = app.chapter_list_state.selected() {
app.jump_to_chapter(selected);
}
}
KeyCode::Down | KeyCode::Char('j') => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i + 1 < app.chapter_titles.len() {
app.chapter_list_state.select(Some(i + 1));
}
}
KeyCode::PageDown => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i + 5 < app.chapter_titles.len() {
app.chapter_list_state.select(Some(i + 5));
}
}
KeyCode::Up | KeyCode::Char('k') => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i > 0 {
app.chapter_list_state.select(Some(i - 1));
}
}
KeyCode::PageUp => {
let i = app.chapter_list_state.selected().unwrap_or(0);
if i > 5 {
app.chapter_list_state.select(Some(i - 5));
}
}
_ => {}
},
_ => match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Right | KeyCode::Char('n') => app.next_chapter(),
KeyCode::Left | KeyCode::Char('p') => app.previous_chapter(),
KeyCode::Down | KeyCode::Char('j') => app.scroll_down(),
KeyCode::Up | KeyCode::Char('k') => app.scroll_up(),
KeyCode::Char('c') => app.toggle_mode(),
KeyCode::Tab => app.open_chapter_browser(),
_ => {}
},
}
}
}
}
fn ui(f: &mut Frame, app: &mut App, picker: &mut Picker) {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(size);
let title = format!(" {} - {} ", app.reader.title(), app.reader.author());
let header = Paragraph::new(title).block(
Block::default()
.borders(Borders::ALL)
.title("Changxi - EPUB Reader"),
);
f.render_widget(header, chunks[0]);
match app.mode {
ViewMode::ChapterBrowser => {
let area = centered_rect(70, 90, chunks[1]);
let mut list_items = Vec::new();
for i in 0..app.chapter_titles.len() {
let title = &app.chapter_titles[i];
let is_current = i == app.current_chapter;
let prefix = if is_current { "▶ " } else { " " };
let content = format!("{}{}", prefix, title);
let mut text = Text::from(content);
if is_current {
text = text
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Green));
}
list_items.push(ListItem::new(text));
}
let list = List::new(list_items)
.block(Block::default().borders(Borders::ALL).title(" Chapters "))
.highlight_style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.chapter_list_state);
let footer_text = format!(
" Chapter Browser | ↑/↓ or j/k: navigate, Enter: select, Esc: back, q: quit | Chapter {}/{} ",
app.chapter_list_state.selected().unwrap_or(0) + 1,
app.chapter_titles.len()
);
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
ViewMode::Reading => {
if let Ok(chapter) = app.reader.get_chapter(app.current_chapter) {
let chapter_title =
format!(" Chapter {}: {} ", app.current_chapter + 1, chapter.title);
let inner_area = chunks[1];
let width = inner_area.width.saturating_sub(2) as usize;
let viewport_height = inner_area.height.saturating_sub(2) as usize;
let mut heights = Vec::new();
for element in &chapter.elements {
match element {
ContentElement::Text(t) => heights.push(count_lines(t, width)),
ContentElement::Image(_) => heights.push(30),
}
}
let total_height: usize = heights.iter().sum();
if app.scroll + viewport_height > total_height && total_height > viewport_height {
app.scroll = total_height.saturating_sub(viewport_height);
}
let mut current_y_offset = 0;
let content_rect = inner_area.inner(Margin {
vertical: 1,
horizontal: 1,
});
for (i, element) in chapter.elements.iter().enumerate() {
let h = heights[i];
if current_y_offset + h > app.scroll
&& current_y_offset < app.scroll + viewport_height
{
let rel_y = current_y_offset as i32 - app.scroll as i32;
match element {
ContentElement::Text(t) => {
let start_line = if rel_y < 0 { (-rel_y) as u16 } else { 0 };
let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
let draw_h = (h as u16)
.saturating_sub(start_line)
.min((viewport_height as u16).saturating_sub(draw_y));
if draw_h > 0 {
let area = Rect {
x: content_rect.x,
y: content_rect.y + draw_y,
width: content_rect.width,
height: draw_h,
};
let p = Paragraph::new(t.as_str())
.wrap(Wrap { trim: true })
.scroll((start_line, 0));
f.render_widget(p, area);
}
}
ContentElement::Image(path) => {
let draw_y = if rel_y < 0 { 0 } else { rel_y as u16 };
let visible_h = if rel_y < 0 {
(h as i32 + rel_y).max(0) as u16
} else {
(h as u16).min((viewport_height as u16).saturating_sub(draw_y))
};
if visible_h > 0
&& let Some(protocol) = app.get_image_protocol(path, picker)
{
let area = Rect {
x: content_rect.x,
y: content_rect.y + draw_y,
width: content_rect.width,
height: visible_h,
};
f.render_stateful_widget(
StatefulImage::default(),
area,
protocol,
);
}
}
}
}
current_y_offset += h;
}
f.render_widget(
Block::default().borders(Borders::ALL).title(chapter_title),
chunks[1],
);
let max_scroll = total_height.saturating_sub(viewport_height);
let scroll_position = app.scroll.min(max_scroll);
let mut scrollbar_state = ScrollbarState::new(total_height)
.position(scroll_position)
.viewport_content_length(viewport_height);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
chunks[1].inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
let progress = if total_height > viewport_height {
(app.scroll as f64 / (total_height - viewport_height) as f64 * 100.0).min(100.0)
} else {
100.0
};
let footer_text = format!(
" Chapter {}/{} | {:>3.0}% | Tab: chapters, q: quit, c: cover, ←/→: prev/next chapter, ↑/↓: scroll ",
app.current_chapter + 1,
app.reader.chapter_count(),
progress
);
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
}
ViewMode::Cover => {
if let Some(ref mut protocol) = app.cover_protocol {
let area = centered_rect(80, 100, chunks[1]);
f.render_stateful_widget(StatefulImage::default(), area, protocol);
} else {
f.render_widget(
Paragraph::new("No cover image available")
.block(Block::default().borders(Borders::ALL)),
chunks[1],
);
}
let footer_text = format!(
" Cover View | Chapter {}/{} | Tab: chapters, q: quit, c: text, ←/→: prev/next chapter ",
app.current_chapter + 1,
app.reader.chapter_count()
);
f.render_widget(
Paragraph::new(footer_text).block(Block::default().borders(Borders::ALL)),
chunks[2],
);
}
}
}
fn count_lines(text: &str, width: usize) -> usize {
let mut count = 0;
for line in text.lines() {
if line.is_empty() {
count += 1;
continue;
}
let wrapped = (line.len() as f64 / width as f64).ceil() as usize;
count += wrapped.max(1);
}
count
}
fn centered_rect(
percent_x: u16,
percent_y: u16,
r: ratatui::layout::Rect,
) -> ratatui::layout::Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}