Files
epub-read/src/app.rs

479 lines
19 KiB
Rust

use crate::book::Book;
use crate::persistence;
use crate::style::StyleProfile;
use crate::theme::{self, BgType, Settings};
use eframe::egui;
use std::path::PathBuf;
pub struct App {
pub state: AppState,
settings: Settings,
settings_dir: std::path::PathBuf,
kraft_texture: Option<egui::TextureHandle>,
manuscript_texture: Option<egui::TextureHandle>,
composition_texture: Option<egui::TextureHandle>,
custom_texture: Option<egui::TextureHandle>,
}
pub struct AppState {
pub book: Option<Book>,
pub current_section: usize,
pub current_page: usize,
pub sidebar_open: bool,
pub file_path: Option<PathBuf>,
pub error_message: Option<String>,
pub pending_anchor: Option<theme::ReadingPosition>,
}
impl AppState {
pub fn prev_page(&mut self) {
if let Some(ref book) = self.book {
if self.current_page > 0 {
self.current_page -= 1;
} else if self.current_section > 0 {
self.current_section -= 1;
self.current_page = book.sections[self.current_section]
.page_block_ranges.len().saturating_sub(1);
}
}
}
pub fn next_page(&mut self) {
if let Some(ref book) = self.book {
if self.current_page + 1 < book.sections[self.current_section]
.page_block_ranges.len()
{
self.current_page += 1;
} else if self.current_section + 1 < book.sections.len() {
self.current_section += 1;
self.current_page = 0;
}
}
}
}
impl App {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
let settings_dir = persistence::settings_dir();
let settings = persistence::load_settings(&settings_dir).unwrap_or_default();
cc.egui_ctx.set_style(theme::create_style(&settings.theme));
crate::font::setup_fonts(&cc.egui_ctx, &settings.font_name);
Self {
state: AppState {
book: None,
current_section: 0,
current_page: 0,
sidebar_open: false,
file_path: None,
error_message: None,
pending_anchor: None,
},
settings,
settings_dir,
kraft_texture: Some(crate::texture::generate_kraft_paper(&cc.egui_ctx)),
manuscript_texture: Some(crate::texture::generate_manuscript(&cc.egui_ctx)),
composition_texture: Some(crate::texture::generate_composition(&cc.egui_ctx)),
custom_texture: None,
}
}
pub fn open_file(&mut self, path: PathBuf) {
match crate::book::load_epub(&path) {
Ok(book) => {
let path_str = path.to_string_lossy().to_string();
let pos = self.settings.reading_positions.get(&path_str).cloned();
let mut recent = Vec::new();
recent.push(path_str.clone());
for f in &self.settings.recent_files {
if *f != path_str {
recent.push(f.clone());
if recent.len() >= 10 {
break;
}
}
}
self.settings.recent_files = recent;
self.state.book = Some(book);
self.state.current_section = pos.as_ref().map(|p| p.section).unwrap_or(0);
self.state.current_page = pos.as_ref().map(|p| p.page).unwrap_or(0);
self.state.pending_anchor = pos;
self.state.sidebar_open = false;
self.state.file_path = Some(path);
self.state.error_message = None;
self.save_settings();
}
Err(e) => {
self.state.error_message = Some(e);
}
}
}
fn save_settings(&self) {
let _ = persistence::save_settings(&self.settings_dir, &self.settings);
}
fn active_profile(&self) -> &StyleProfile {
let idx = self.settings.profiles.iter().position(|p| p.name == self.settings.active_profile);
let idx = idx.unwrap_or(0);
&self.settings.profiles[idx]
}
fn save_reading_position(&mut self) {
if let Some(ref path) = self.state.file_path {
let path_str = path.to_string_lossy().to_string();
let (block_index, text_snippet) = if let Some(ref book) = self.state.book {
let section = &book.sections[self.state.current_section];
if let Some(&(start, _end)) = section.page_block_ranges.get(self.state.current_page) {
let first_block = start;
let snippet = section.blocks.get(first_block)
.map(|b| b.text.chars().take(30).collect())
.unwrap_or_default();
(Some(first_block), Some(snippet))
} else {
(None, None)
}
} else {
(None, None)
};
self.settings.reading_positions.insert(
path_str,
theme::ReadingPosition {
section: self.state.current_section,
page: self.state.current_page,
block_index,
text_snippet,
},
);
}
}
fn welcome_view(&mut self, ctx: &egui::Context) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(150.0);
ui.heading("ePub Reader");
ui.add_space(20.0);
if ui
.add(egui::Button::new("📂 打开 ePub 文件").min_size(egui::vec2(200.0, 40.0)))
.clicked()
{
if let Some(path) = rfd::FileDialog::new()
.add_filter("ePub", &["epub"])
.pick_file()
{
self.open_file(path);
}
}
ui.add_space(30.0);
if !self.settings.recent_files.is_empty() {
ui.label("最近阅读:");
ui.separator();
let mut to_open: Option<PathBuf> = None;
let mut to_remove: Option<usize> = None;
for (i, path) in self.settings.recent_files.iter().enumerate() {
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
if ui.button(&name).clicked() {
let p = std::path::PathBuf::from(path);
if p.exists() {
to_open = Some(p);
} else {
to_remove = Some(i);
}
}
}
if let Some(path) = to_open {
self.open_file(path);
}
if let Some(i) = to_remove {
self.settings.recent_files.remove(i);
self.save_settings();
}
}
if let Some(ref msg) = self.state.error_message {
ui.add_space(20.0);
ui.colored_label(egui::Color32::RED, msg);
}
});
});
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let bg_type = self.settings.bg_type.clone();
let has_bg = bg_type.has_background();
// 有背景时面板设为透明
if has_bg {
ctx.style_mut(|s| {
s.visuals.panel_fill = egui::Color32::TRANSPARENT;
s.visuals.window_fill = egui::Color32::TRANSPARENT;
});
}
// 全窗口背景纹理
let viewport_rect = ctx.input(|i| i.screen_rect());
match bg_type {
BgType::Kraft => {
if let Some(tex) = &self.kraft_texture {
crate::texture::draw_tiled_bg(
&ctx.layer_painter(egui::LayerId::background()),
viewport_rect,
tex,
);
}
}
BgType::Manuscript => {
if let Some(tex) = &self.manuscript_texture {
crate::texture::draw_tiled_bg(
&ctx.layer_painter(egui::LayerId::background()),
viewport_rect,
tex,
);
}
}
BgType::Composition => {
if let Some(tex) = &self.composition_texture {
crate::texture::draw_tiled_bg(
&ctx.layer_painter(egui::LayerId::background()),
viewport_rect,
tex,
);
}
}
BgType::Custom(ref path) if !path.is_empty() => {
if self.custom_texture.is_none() {
match image::ImageReader::open(path) {
Ok(reader) => match reader.decode() {
Ok(img) => {
let size = [img.width() as usize, img.height() as usize];
let pixels: Vec<egui::Color32> = img
.to_rgba8()
.pixels()
.map(|p| {
egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3])
})
.collect();
let image = egui::ColorImage { size, pixels };
self.custom_texture = Some(
ctx.load_texture("custom_bg", image, Default::default()),
);
}
Err(_) => {}
},
Err(_) => {}
}
}
if let Some(tex) = &self.custom_texture {
let painter = &ctx.layer_painter(egui::LayerId::background());
painter.image(
tex.id(),
viewport_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
}
_ => {}
}
if self.state.book.is_none() {
self.welcome_view(ctx);
return;
}
let file_path = self.state.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut style = self.active_profile().clone();
let theme_copy = self.settings.theme;
let profile_names: Vec<String> = self.settings.profiles.iter().map(|p| p.name.clone()).collect();
let bookmarks = self.settings.bookmarks.get(&file_path).cloned().unwrap_or_default();
let mut jump_to_bookmark: Option<usize> = None;
egui::CentralPanel::default().show(ctx, |ui| {
let book = self.state.book.as_mut().unwrap();
let current_font = self.settings.font_name.clone();
let (action, jump) = crate::reader::reading_view(
ui,
book,
&mut self.state.current_section,
&mut self.state.current_page,
&mut self.state.sidebar_open,
&mut style,
&theme_copy,
bg_type.clone(),
&file_path,
&profile_names,
&bookmarks,
&current_font,
);
jump_to_bookmark = jump;
if action.go_back {
self.save_reading_position();
self.state.book = None;
self.state.current_section = 0;
self.state.current_page = 0;
}
// Switch profile immediately
if let Some(ref target) = action.switch_to_profile {
if let Some(profile) = self.settings.profiles.iter()
.find(|p| p.name == *target)
{
style.alignment = profile.alignment;
style.line_spacing = profile.line_spacing;
style.paragraph_spacing = profile.paragraph_spacing;
style.first_line_indent = profile.first_line_indent;
style.font_size = profile.font_size;
style.name = profile.name.clone();
self.settings.active_profile = profile.name.clone();
}
}
if let Some(ref new_font) = action.switch_font {
if new_font != &self.settings.font_name {
let actual = crate::font::setup_fonts(ui.ctx(), new_font);
self.settings.font_name = actual;
}
}
if let Some(new_bg) = action.switch_bg {
self.settings.bg_type = new_bg;
// 切换背景时,如果选自定义图片则弹出文件选择器
if let BgType::Custom(_) = &self.settings.bg_type {
if let Some(p) = rfd::FileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "bmp", "webp"])
.pick_file()
{
self.settings.bg_type = BgType::Custom(p.to_string_lossy().to_string());
self.custom_texture = None;
} else {
self.settings.bg_type = BgType::None;
}
}
// 切换背景时,如果从自定义图片切走,清除纹理缓存
if !self.settings.bg_type.has_background() || !matches!(self.settings.bg_type, BgType::Custom(_)) {
self.custom_texture = None;
}
}
if action.toggle_theme {
self.settings.theme = match self.settings.theme {
theme::Theme::Light => theme::Theme::Dark,
theme::Theme::Dark => theme::Theme::Sepia,
theme::Theme::Sepia => theme::Theme::Light,
};
ctx.set_style(theme::create_style(&self.settings.theme));
}
if action.toggle_sidebar {
self.state.sidebar_open = !self.state.sidebar_open;
}
if let Some(section) = action.jump_to_section {
self.state.current_section = section;
self.state.current_page = 0;
self.state.pending_anchor = None;
if let Some(ref anchor) = action.jump_to_anchor {
if let Some(ref book) = self.state.book {
if section < book.sections.len() {
if let Some(page) = crate::reader::find_page_for_anchor(&book.sections[section], anchor) {
self.state.current_page = page;
}
}
}
}
}
if action.page_prev {
self.state.prev_page();
}
if action.page_next {
self.state.next_page();
}
if action.toggle_bookmark {
let file_path = file_path.clone();
let section = self.state.current_section;
let page = self.state.current_page;
let book = self.state.book.as_ref().unwrap();
let section_title = book.sections.get(section)
.map(|s| s.title.clone())
.unwrap_or_else(|| format!("{}", section + 1));
let bookmarks = self.settings.bookmarks.entry(file_path)
.or_insert_with(Vec::new);
let existing_idx = bookmarks.iter()
.position(|b: &theme::Bookmark| b.section == section && b.page == page);
if let Some(idx) = existing_idx {
bookmarks.remove(idx);
} else {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
bookmarks.push(theme::Bookmark {
section,
page,
label: format!("{} - 第{}页", section_title, page + 1),
timestamp,
});
}
}
});
if let Some(idx) = jump_to_bookmark {
if let Some(bookmarks) = self.settings.bookmarks.get(&file_path) {
if let Some(bm) = bookmarks.get(idx) {
self.state.current_section = bm.section;
self.state.current_page = bm.page;
}
}
}
if let Some(anchor) = self.state.pending_anchor.take() {
if let Some(ref book) = self.state.book {
if anchor.section < book.sections.len() {
let section = &book.sections[anchor.section];
let restored = if let Some(block_idx) = anchor.block_index {
crate::reader::find_page_for_block(section, block_idx)
} else if let Some(ref snippet) = anchor.text_snippet {
crate::reader::find_page_by_snippet(section, snippet)
} else {
None
};
if let Some(page) = restored {
self.state.current_page = page;
}
}
}
}
// Sync style changes back to active profile (outside closure)
if let Some(p) = self.settings.profiles.iter_mut()
.find(|p| p.name == self.settings.active_profile)
{
p.font_size = style.font_size;
p.alignment = style.alignment;
p.line_spacing = style.line_spacing;
p.paragraph_spacing = style.paragraph_spacing;
p.first_line_indent = style.first_line_indent;
}
self.settings.font_size = style.font_size;
self.save_reading_position();
self.save_settings();
}
}