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::persistence;
|
||||
use crate::style::StyleProfile;
|
||||
use crate::theme::{self, Settings};
|
||||
use eframe::egui;
|
||||
use std::path::PathBuf;
|
||||
@@ -102,6 +103,12 @@ impl App {
|
||||
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();
|
||||
@@ -181,6 +188,10 @@ impl eframe::App for App {
|
||||
.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 use_kraft = self.settings.use_kraft_bg;
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let book = self.state.book.as_mut().unwrap();
|
||||
let action = crate::reader::reading_view(
|
||||
@@ -189,11 +200,11 @@ impl eframe::App for App {
|
||||
&mut self.state.current_section,
|
||||
&mut self.state.current_page,
|
||||
&mut self.state.sidebar_open,
|
||||
&mut self.settings.font_size,
|
||||
&self.settings.theme,
|
||||
&mut style,
|
||||
&theme_copy,
|
||||
&file_path,
|
||||
self.kraft_texture.as_ref(),
|
||||
self.settings.use_kraft_bg,
|
||||
use_kraft,
|
||||
);
|
||||
|
||||
if action.go_back {
|
||||
@@ -203,6 +214,14 @@ impl eframe::App for App {
|
||||
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 {
|
||||
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_settings();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod book;
|
||||
mod font;
|
||||
mod persistence;
|
||||
mod reader;
|
||||
mod style;
|
||||
mod texture;
|
||||
mod theme;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use eframe::egui;
|
||||
use crate::book::Book;
|
||||
use crate::style::{StyleProfile, TextAlignment};
|
||||
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 line_height = font_size * 1.5;
|
||||
let chars_per_line = if char_width > 0.0 {
|
||||
(panel_width / char_width).max(1.0) as usize
|
||||
} else {
|
||||
@@ -26,6 +26,7 @@ pub struct ReaderAction {
|
||||
pub toggle_theme: bool,
|
||||
pub toggle_bookmark: bool,
|
||||
pub toggle_kraft_bg: bool,
|
||||
pub cycle_profile: bool,
|
||||
pub page_next: bool,
|
||||
pub page_prev: bool,
|
||||
}
|
||||
@@ -36,7 +37,7 @@ pub fn reading_view(
|
||||
current_section: &mut usize,
|
||||
current_page: &mut usize,
|
||||
sidebar_open: &mut bool,
|
||||
font_size: &mut f32,
|
||||
style: &mut StyleProfile,
|
||||
theme: &Theme,
|
||||
_file_path: &str,
|
||||
kraft_texture: Option<&egui::TextureHandle>,
|
||||
@@ -47,12 +48,14 @@ pub fn reading_view(
|
||||
toggle_theme: false,
|
||||
toggle_bookmark: false,
|
||||
toggle_kraft_bg: false,
|
||||
cycle_profile: false,
|
||||
page_next: false,
|
||||
page_prev: false,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -90,10 +93,16 @@ pub fn reading_view(
|
||||
action.toggle_bookmark = true;
|
||||
}
|
||||
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() {
|
||||
*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 { "📋" };
|
||||
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) {
|
||||
let start = section.pages[*current_page];
|
||||
let end = section.pages[*current_page + 1];
|
||||
let text: String = section.content.chars().skip(start).take(end - start).collect();
|
||||
ui.put(text_rect, |ui: &mut egui::Ui| {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&text)
|
||||
.size(*font_size)
|
||||
.color(colors.text)
|
||||
).wrap()
|
||||
)
|
||||
let raw_text: String = section.content.chars().skip(start).take(end - start).collect();
|
||||
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(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&indented)
|
||||
.size(style.font_size)
|
||||
.line_height(Some(style.line_height()))
|
||||
.color(colors.text)
|
||||
).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 theme: Theme,
|
||||
pub use_kraft_bg: bool,
|
||||
pub active_profile: String,
|
||||
pub profiles: Vec<crate::style::StyleProfile>,
|
||||
pub recent_files: Vec<String>,
|
||||
pub reading_positions: std::collections::HashMap<String, ReadingPosition>,
|
||||
pub bookmarks: std::collections::HashMap<String, Vec<Bookmark>>,
|
||||
@@ -183,6 +185,8 @@ impl Default for Settings {
|
||||
font_size: 20.0,
|
||||
theme: Theme::Light,
|
||||
use_kraft_bg: false,
|
||||
active_profile: "Kindle 默认".into(),
|
||||
profiles: crate::style::StyleProfile::presets(),
|
||||
recent_files: Vec::new(),
|
||||
reading_positions: std::collections::HashMap::new(),
|
||||
bookmarks: std::collections::HashMap::new(),
|
||||
|
||||
Reference in New Issue
Block a user