479 lines
19 KiB
Rust
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,
|
|
¤t_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();
|
|
}
|
|
}
|