feat: add typography style system with profiles, alignment, line spacing, first-line indent
This commit is contained in:
37
src/app.rs
37
src/app.rs
@@ -1,5 +1,6 @@
|
|||||||
use crate::book::Book;
|
use crate::book::Book;
|
||||||
use crate::persistence;
|
use crate::persistence;
|
||||||
|
use crate::style::StyleProfile;
|
||||||
use crate::theme::{self, Settings};
|
use crate::theme::{self, Settings};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -102,6 +103,12 @@ impl App {
|
|||||||
let _ = persistence::save_settings(&self.settings_dir, &self.settings);
|
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) {
|
fn save_reading_position(&mut self) {
|
||||||
if let Some(ref path) = self.state.file_path {
|
if let Some(ref path) = self.state.file_path {
|
||||||
let path_str = path.to_string_lossy().to_string();
|
let path_str = path.to_string_lossy().to_string();
|
||||||
@@ -181,6 +188,10 @@ impl eframe::App for App {
|
|||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut style = self.active_profile().clone();
|
||||||
|
let theme_copy = self.settings.theme;
|
||||||
|
let use_kraft = self.settings.use_kraft_bg;
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
let book = self.state.book.as_mut().unwrap();
|
let book = self.state.book.as_mut().unwrap();
|
||||||
let action = crate::reader::reading_view(
|
let action = crate::reader::reading_view(
|
||||||
@@ -189,11 +200,11 @@ impl eframe::App for App {
|
|||||||
&mut self.state.current_section,
|
&mut self.state.current_section,
|
||||||
&mut self.state.current_page,
|
&mut self.state.current_page,
|
||||||
&mut self.state.sidebar_open,
|
&mut self.state.sidebar_open,
|
||||||
&mut self.settings.font_size,
|
&mut style,
|
||||||
&self.settings.theme,
|
&theme_copy,
|
||||||
&file_path,
|
&file_path,
|
||||||
self.kraft_texture.as_ref(),
|
self.kraft_texture.as_ref(),
|
||||||
self.settings.use_kraft_bg,
|
use_kraft,
|
||||||
);
|
);
|
||||||
|
|
||||||
if action.go_back {
|
if action.go_back {
|
||||||
@@ -203,6 +214,14 @@ impl eframe::App for App {
|
|||||||
self.state.current_page = 0;
|
self.state.current_page = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if action.cycle_profile {
|
||||||
|
let idx = self.settings.profiles.iter()
|
||||||
|
.position(|p| p.name == self.settings.active_profile)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next = (idx + 1) % self.settings.profiles.len();
|
||||||
|
self.settings.active_profile = self.settings.profiles[next].name.clone();
|
||||||
|
}
|
||||||
|
|
||||||
if action.toggle_kraft_bg {
|
if action.toggle_kraft_bg {
|
||||||
self.settings.use_kraft_bg = !self.settings.use_kraft_bg;
|
self.settings.use_kraft_bg = !self.settings.use_kraft_bg;
|
||||||
}
|
}
|
||||||
@@ -224,6 +243,18 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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_reading_position();
|
||||||
self.save_settings();
|
self.save_settings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod book;
|
|||||||
mod font;
|
mod font;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod reader;
|
mod reader;
|
||||||
|
mod style;
|
||||||
mod texture;
|
mod texture;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use crate::book::Book;
|
use crate::book::Book;
|
||||||
|
use crate::style::{StyleProfile, TextAlignment};
|
||||||
use crate::theme::{self, Theme};
|
use crate::theme::{self, Theme};
|
||||||
|
|
||||||
pub fn recalculate_pages(book: &mut Book, font_size: f32, panel_width: f32, panel_height: f32) {
|
pub fn recalculate_pages(book: &mut Book, font_size: f32, line_height: f32, panel_width: f32, panel_height: f32) {
|
||||||
let char_width = font_size * 0.6;
|
let char_width = font_size * 0.6;
|
||||||
let line_height = font_size * 1.5;
|
|
||||||
let chars_per_line = if char_width > 0.0 {
|
let chars_per_line = if char_width > 0.0 {
|
||||||
(panel_width / char_width).max(1.0) as usize
|
(panel_width / char_width).max(1.0) as usize
|
||||||
} else {
|
} else {
|
||||||
@@ -26,6 +26,7 @@ pub struct ReaderAction {
|
|||||||
pub toggle_theme: bool,
|
pub toggle_theme: bool,
|
||||||
pub toggle_bookmark: bool,
|
pub toggle_bookmark: bool,
|
||||||
pub toggle_kraft_bg: bool,
|
pub toggle_kraft_bg: bool,
|
||||||
|
pub cycle_profile: bool,
|
||||||
pub page_next: bool,
|
pub page_next: bool,
|
||||||
pub page_prev: bool,
|
pub page_prev: bool,
|
||||||
}
|
}
|
||||||
@@ -36,7 +37,7 @@ pub fn reading_view(
|
|||||||
current_section: &mut usize,
|
current_section: &mut usize,
|
||||||
current_page: &mut usize,
|
current_page: &mut usize,
|
||||||
sidebar_open: &mut bool,
|
sidebar_open: &mut bool,
|
||||||
font_size: &mut f32,
|
style: &mut StyleProfile,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
_file_path: &str,
|
_file_path: &str,
|
||||||
kraft_texture: Option<&egui::TextureHandle>,
|
kraft_texture: Option<&egui::TextureHandle>,
|
||||||
@@ -47,12 +48,14 @@ pub fn reading_view(
|
|||||||
toggle_theme: false,
|
toggle_theme: false,
|
||||||
toggle_bookmark: false,
|
toggle_bookmark: false,
|
||||||
toggle_kraft_bg: false,
|
toggle_kraft_bg: false,
|
||||||
|
cycle_profile: false,
|
||||||
page_next: false,
|
page_next: false,
|
||||||
page_prev: false,
|
page_prev: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let panel_size = ui.available_size();
|
let panel_size = ui.available_size();
|
||||||
recalculate_pages(book, *font_size, panel_size.x, panel_size.y);
|
let line_h = style.line_height();
|
||||||
|
recalculate_pages(book, style.font_size, line_h, panel_size.x, panel_size.y);
|
||||||
|
|
||||||
let colors = theme::reader_colors(theme);
|
let colors = theme::reader_colors(theme);
|
||||||
|
|
||||||
@@ -90,10 +93,16 @@ pub fn reading_view(
|
|||||||
action.toggle_bookmark = true;
|
action.toggle_bookmark = true;
|
||||||
}
|
}
|
||||||
if ui.button("A⁻").on_hover_text("缩小字体").clicked() {
|
if ui.button("A⁻").on_hover_text("缩小字体").clicked() {
|
||||||
*font_size = (*font_size - 2.0).max(10.0);
|
style.font_size = (style.font_size - 2.0).max(10.0);
|
||||||
}
|
}
|
||||||
if ui.button("A⁺").on_hover_text("放大字体").clicked() {
|
if ui.button("A⁺").on_hover_text("放大字体").clicked() {
|
||||||
*font_size = (*font_size + 2.0).min(48.0);
|
style.font_size = (style.font_size + 2.0).min(48.0);
|
||||||
|
}
|
||||||
|
if ui.button(&style.name)
|
||||||
|
.on_hover_text(format!("样式: {} (点击切换)", style.name))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action.cycle_profile = true;
|
||||||
}
|
}
|
||||||
let kraft_icon = if use_kraft_bg { "📄" } else { "📋" };
|
let kraft_icon = if use_kraft_bg { "📄" } else { "📋" };
|
||||||
if ui.button(kraft_icon).on_hover_text("牛皮纸背景").clicked() {
|
if ui.button(kraft_icon).on_hover_text("牛皮纸背景").clicked() {
|
||||||
@@ -189,15 +198,28 @@ pub fn reading_view(
|
|||||||
if *current_page < section.pages.len().saturating_sub(1) {
|
if *current_page < section.pages.len().saturating_sub(1) {
|
||||||
let start = section.pages[*current_page];
|
let start = section.pages[*current_page];
|
||||||
let end = section.pages[*current_page + 1];
|
let end = section.pages[*current_page + 1];
|
||||||
let text: String = section.content.chars().skip(start).take(end - start).collect();
|
let raw_text: String = section.content.chars().skip(start).take(end - start).collect();
|
||||||
ui.put(text_rect, |ui: &mut egui::Ui| {
|
let indented = crate::style::apply_indent(&raw_text, style.first_line_indent);
|
||||||
|
|
||||||
|
let align = match style.alignment {
|
||||||
|
TextAlignment::Left => egui::Align::LEFT,
|
||||||
|
TextAlignment::Center => egui::Align::Center,
|
||||||
|
TextAlignment::Right => egui::Align::RIGHT,
|
||||||
|
};
|
||||||
|
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| {
|
||||||
|
ui.with_layout(
|
||||||
|
egui::Layout::top_down(align).with_main_justify(false),
|
||||||
|
|ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
egui::RichText::new(&text)
|
egui::RichText::new(&indented)
|
||||||
.size(*font_size)
|
.size(style.font_size)
|
||||||
|
.line_height(Some(style.line_height()))
|
||||||
.color(colors.text)
|
.color(colors.text)
|
||||||
).wrap()
|
).wrap()
|
||||||
)
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/style.rs
Normal file
84
src/style.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum TextAlignment {
|
||||||
|
Left,
|
||||||
|
Center,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StyleProfile {
|
||||||
|
pub name: String,
|
||||||
|
pub alignment: TextAlignment,
|
||||||
|
pub line_spacing: f32,
|
||||||
|
pub paragraph_spacing: f32,
|
||||||
|
pub first_line_indent: f32,
|
||||||
|
pub font_size: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StyleProfile {
|
||||||
|
pub fn presets() -> Vec<StyleProfile> {
|
||||||
|
vec![
|
||||||
|
StyleProfile {
|
||||||
|
name: "Kindle 默认".into(),
|
||||||
|
alignment: TextAlignment::Left,
|
||||||
|
line_spacing: 1.8,
|
||||||
|
paragraph_spacing: 1.0,
|
||||||
|
first_line_indent: 2.0,
|
||||||
|
font_size: 20.0,
|
||||||
|
},
|
||||||
|
StyleProfile {
|
||||||
|
name: "紧凑".into(),
|
||||||
|
alignment: TextAlignment::Left,
|
||||||
|
line_spacing: 1.4,
|
||||||
|
paragraph_spacing: 0.5,
|
||||||
|
first_line_indent: 2.0,
|
||||||
|
font_size: 18.0,
|
||||||
|
},
|
||||||
|
StyleProfile {
|
||||||
|
name: "宽松".into(),
|
||||||
|
alignment: TextAlignment::Left,
|
||||||
|
line_spacing: 2.2,
|
||||||
|
paragraph_spacing: 1.5,
|
||||||
|
first_line_indent: 2.0,
|
||||||
|
font_size: 22.0,
|
||||||
|
},
|
||||||
|
StyleProfile {
|
||||||
|
name: "居中".into(),
|
||||||
|
alignment: TextAlignment::Center,
|
||||||
|
line_spacing: 1.6,
|
||||||
|
paragraph_spacing: 1.0,
|
||||||
|
first_line_indent: 0.0,
|
||||||
|
font_size: 20.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line_height(&self) -> f32 {
|
||||||
|
self.font_size * self.line_spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_indent(text: &str, indent_chars: f32) -> String {
|
||||||
|
if indent_chars <= 0.0 {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let indent = "\u{3000}".repeat(indent_chars as usize);
|
||||||
|
let mut result = String::with_capacity(text.len());
|
||||||
|
let mut para_start = true;
|
||||||
|
for ch in text.chars() {
|
||||||
|
if ch == '\n' {
|
||||||
|
result.push('\n');
|
||||||
|
para_start = true;
|
||||||
|
} else if para_start {
|
||||||
|
result.push_str(&indent);
|
||||||
|
result.push(ch);
|
||||||
|
para_start = false;
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.trim().to_string()
|
||||||
|
}
|
||||||
@@ -171,6 +171,8 @@ pub struct Settings {
|
|||||||
pub font_size: f32,
|
pub font_size: f32,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub use_kraft_bg: bool,
|
pub use_kraft_bg: bool,
|
||||||
|
pub active_profile: String,
|
||||||
|
pub profiles: Vec<crate::style::StyleProfile>,
|
||||||
pub recent_files: Vec<String>,
|
pub recent_files: Vec<String>,
|
||||||
pub reading_positions: std::collections::HashMap<String, ReadingPosition>,
|
pub reading_positions: std::collections::HashMap<String, ReadingPosition>,
|
||||||
pub bookmarks: std::collections::HashMap<String, Vec<Bookmark>>,
|
pub bookmarks: std::collections::HashMap<String, Vec<Bookmark>>,
|
||||||
@@ -183,6 +185,8 @@ impl Default for Settings {
|
|||||||
font_size: 20.0,
|
font_size: 20.0,
|
||||||
theme: Theme::Light,
|
theme: Theme::Light,
|
||||||
use_kraft_bg: false,
|
use_kraft_bg: false,
|
||||||
|
active_profile: "Kindle 默认".into(),
|
||||||
|
profiles: crate::style::StyleProfile::presets(),
|
||||||
recent_files: Vec::new(),
|
recent_files: Vec::new(),
|
||||||
reading_positions: std::collections::HashMap::new(),
|
reading_positions: std::collections::HashMap::new(),
|
||||||
bookmarks: std::collections::HashMap::new(),
|
bookmarks: std::collections::HashMap::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user