feat(bg): multi-background support with kraft, manuscript, composition, and custom image
This commit is contained in:
259
src/app.rs
259
src/app.rs
@@ -1,7 +1,7 @@
|
||||
use crate::book::Book;
|
||||
use crate::persistence;
|
||||
use crate::style::StyleProfile;
|
||||
use crate::theme::{self, Settings};
|
||||
use crate::theme::{self, BgType, Settings};
|
||||
use eframe::egui;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -10,6 +10,9 @@ pub struct App {
|
||||
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 {
|
||||
@@ -53,7 +56,6 @@ impl App {
|
||||
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));
|
||||
let kraft_texture = Some(crate::texture::generate_kraft_paper(&cc.egui_ctx));
|
||||
Self {
|
||||
state: AppState {
|
||||
book: None,
|
||||
@@ -65,7 +67,10 @@ impl App {
|
||||
},
|
||||
settings,
|
||||
settings_dir,
|
||||
kraft_texture,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,44 +182,102 @@ impl App {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let use_kraft = self.settings.use_kraft_bg;
|
||||
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 use_kraft {
|
||||
if let Some(texture) = &self.kraft_texture {
|
||||
ctx.style_mut(|s| {
|
||||
s.visuals.panel_fill = egui::Color32::TRANSPARENT;
|
||||
s.visuals.window_fill = egui::Color32::TRANSPARENT;
|
||||
s.visuals.faint_bg_color = egui::Color32::TRANSPARENT;
|
||||
});
|
||||
let viewport_rect =
|
||||
ctx.input(|i| i.screen_rect());
|
||||
crate::texture::draw_tiled_bg(
|
||||
&ctx.layer_painter(egui::LayerId::background()),
|
||||
viewport_rect,
|
||||
texture,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 有背景时面板设为透明
|
||||
if has_bg {
|
||||
ctx.style_mut(|s| {
|
||||
s.visuals.panel_fill = egui::Color32::TRANSPARENT;
|
||||
s.visuals.window_fill = egui::Color32::TRANSPARENT;
|
||||
s.visuals.faint_bg_color = egui::Color32::TRANSPARENT;
|
||||
});
|
||||
}
|
||||
|
||||
if self.state.book.is_none() {
|
||||
self.welcome_view(ctx);
|
||||
return;
|
||||
}
|
||||
// 全窗口背景纹理
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let file_path = self.state.file_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if self.state.book.is_none() {
|
||||
self.welcome_view(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
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 file_path = self.state.file_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let book = self.state.book.as_mut().unwrap();
|
||||
let action = crate::reader::reading_view(
|
||||
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();
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let book = self.state.book.as_mut().unwrap();
|
||||
let action = crate::reader::reading_view(
|
||||
ui,
|
||||
book,
|
||||
&mut self.state.current_section,
|
||||
@@ -222,67 +285,83 @@ let action = crate::reader::reading_view(
|
||||
&mut self.state.sidebar_open,
|
||||
&mut style,
|
||||
&theme_copy,
|
||||
use_kraft,
|
||||
bg_type.clone(),
|
||||
&file_path,
|
||||
&profile_names,
|
||||
);
|
||||
|
||||
if action.go_back {
|
||||
self.save_reading_position();
|
||||
self.state.book = None;
|
||||
self.state.current_section = 0;
|
||||
self.state.current_page = 0;
|
||||
}
|
||||
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: copy all settings from the selected profile
|
||||
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();
|
||||
}
|
||||
}
|
||||
// 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 action.toggle_kraft_bg {
|
||||
self.settings.use_kraft_bg = !self.settings.use_kraft_bg;
|
||||
}
|
||||
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_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.page_prev {
|
||||
self.state.prev_page();
|
||||
}
|
||||
if action.page_next {
|
||||
self.state.next_page();
|
||||
}
|
||||
});
|
||||
if action.page_prev {
|
||||
self.state.prev_page();
|
||||
}
|
||||
if action.page_next {
|
||||
self.state.next_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;
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
self.save_reading_position();
|
||||
self.save_settings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use eframe::egui;
|
||||
use crate::book::Book;
|
||||
use crate::style::{StyleProfile, TextAlignment};
|
||||
use crate::theme::{self, Theme};
|
||||
use crate::theme::{self, BgType, Theme};
|
||||
|
||||
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;
|
||||
@@ -25,7 +25,7 @@ pub struct ReaderAction {
|
||||
pub go_back: bool,
|
||||
pub toggle_theme: bool,
|
||||
pub toggle_bookmark: bool,
|
||||
pub toggle_kraft_bg: bool,
|
||||
pub switch_bg: Option<BgType>,
|
||||
pub switch_to_profile: Option<String>,
|
||||
pub page_next: bool,
|
||||
pub page_prev: bool,
|
||||
@@ -39,7 +39,7 @@ pub fn reading_view(
|
||||
sidebar_open: &mut bool,
|
||||
style: &mut StyleProfile,
|
||||
theme: &Theme,
|
||||
use_kraft_bg: bool,
|
||||
bg_type: BgType,
|
||||
_file_path: &str,
|
||||
profile_names: &[String],
|
||||
) -> ReaderAction {
|
||||
@@ -47,7 +47,7 @@ pub fn reading_view(
|
||||
go_back: false,
|
||||
toggle_theme: false,
|
||||
toggle_bookmark: false,
|
||||
toggle_kraft_bg: false,
|
||||
switch_bg: None,
|
||||
switch_to_profile: None,
|
||||
page_next: false,
|
||||
page_prev: false,
|
||||
@@ -108,10 +108,17 @@ pub fn reading_view(
|
||||
}
|
||||
}
|
||||
});
|
||||
let kraft_icon = if use_kraft_bg { "📄" } else { "📋" };
|
||||
if ui.button(kraft_icon).on_hover_text("牛皮纸背景").clicked() {
|
||||
action.toggle_kraft_bg = true;
|
||||
}
|
||||
egui::ComboBox::from_id_salt("bg_type_selector")
|
||||
.width(100.0)
|
||||
.selected_text(bg_type.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for &label in BgType::ALL.iter() {
|
||||
let selected = bg_type.label() == label;
|
||||
if ui.selectable_label(selected, label).clicked() {
|
||||
action.switch_bg = Some(theme::BgType::from_label(label));
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button("☰").on_hover_text("打开/关闭目录").clicked() {
|
||||
*sidebar_open = !*sidebar_open;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ fn hash(x: u32, y: u32) -> u32 {
|
||||
}
|
||||
|
||||
fn paper_noise(x: u32, y: u32) -> u8 {
|
||||
// Combine multiple hash values for more natural-looking noise
|
||||
let n1 = hash(x, y);
|
||||
let n2 = hash(x.wrapping_add(137), y.wrapping_add(251));
|
||||
let n3 = hash(x.wrapping_mul(7), y.wrapping_mul(13));
|
||||
@@ -17,31 +16,73 @@ fn paper_noise(x: u32, y: u32) -> u8 {
|
||||
pub fn generate_kraft_paper(ctx: &egui::Context) -> egui::TextureHandle {
|
||||
let size = 128usize;
|
||||
let mut pixels = Vec::with_capacity(size * size);
|
||||
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let n = paper_noise(x as u32, y as u32);
|
||||
// Warm brown-beige base with slight variation
|
||||
let r = 212u8.wrapping_add(n);
|
||||
let g = 194u8.wrapping_add(n);
|
||||
let b = 168u8.wrapping_add(n);
|
||||
pixels.push(egui::Color32::from_rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
|
||||
let image = egui::ColorImage {
|
||||
size: [size, size],
|
||||
pixels,
|
||||
};
|
||||
let image = egui::ColorImage { size: [size, size], pixels };
|
||||
ctx.load_texture("kraft_paper", image, Default::default())
|
||||
}
|
||||
|
||||
/// 稿纸:浅黄色 + 蓝色横线 + 红色竖线
|
||||
pub fn generate_manuscript(ctx: &egui::Context) -> egui::TextureHandle {
|
||||
let size = 256usize;
|
||||
let mut pixels = Vec::with_capacity(size * size);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
// 浅黄色基底
|
||||
let (r, g, b) = (245u8, 230u8, 195u8);
|
||||
// 横线:每 16 像素一条蓝色虚线
|
||||
let is_hline = y % 16 < 2 && x > 8 && x < size - 8;
|
||||
// 竖线:居中的红色引导线
|
||||
let is_vline = x == size / 2 && y > 16 && y < size - 16;
|
||||
let pixel = if is_vline {
|
||||
egui::Color32::from_rgb(220, 50, 50)
|
||||
} else if is_hline {
|
||||
egui::Color32::from_rgb(100, 100, 200)
|
||||
} else {
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
};
|
||||
pixels.push(pixel);
|
||||
}
|
||||
}
|
||||
let image = egui::ColorImage { size: [size, size], pixels };
|
||||
ctx.load_texture("manuscript", image, Default::default())
|
||||
}
|
||||
|
||||
/// 作文纸:浅绿色 + 横线网格
|
||||
pub fn generate_composition(ctx: &egui::Context) -> egui::TextureHandle {
|
||||
let size = 256usize;
|
||||
let mut pixels = Vec::with_capacity(size * size);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
// 浅绿色基底
|
||||
let (r, g, b) = (220u8, 235u8, 210u8);
|
||||
// 横线网格:每 16 像素一条灰色线
|
||||
let is_grid = (y % 16 < 1) || (x % 64 < 1);
|
||||
let pixel = if is_grid {
|
||||
egui::Color32::from_rgb(180, 180, 180)
|
||||
} else {
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
};
|
||||
pixels.push(pixel);
|
||||
}
|
||||
}
|
||||
let image = egui::ColorImage { size: [size, size], pixels };
|
||||
ctx.load_texture("composition", image, Default::default())
|
||||
}
|
||||
|
||||
pub fn draw_tiled_bg(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
texture: &egui::TextureHandle,
|
||||
) {
|
||||
let tile_size = 128.0f32;
|
||||
let tile_size = 256.0f32;
|
||||
let mut ty = rect.min.y;
|
||||
while ty < rect.max.y {
|
||||
let mut tx = rect.min.x;
|
||||
|
||||
48
src/theme.rs
48
src/theme.rs
@@ -2,6 +2,50 @@ use eframe::egui;
|
||||
use eframe::egui::{Color32, CornerRadius, Stroke, Style, Visuals};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 背景类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum BgType {
|
||||
None,
|
||||
Kraft,
|
||||
Manuscript,
|
||||
Composition,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl Default for BgType {
|
||||
fn default() -> Self {
|
||||
BgType::None
|
||||
}
|
||||
}
|
||||
|
||||
impl BgType {
|
||||
pub const ALL: [&'static str; 5] = ["无", "牛皮纸", "稿纸", "作文纸", "自定义图片"];
|
||||
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
BgType::None => "无",
|
||||
BgType::Kraft => "牛皮纸",
|
||||
BgType::Manuscript => "稿纸",
|
||||
BgType::Composition => "作文纸",
|
||||
BgType::Custom(_) => "自定义图片",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_label(label: &str) -> Self {
|
||||
match label {
|
||||
"牛皮纸" => BgType::Kraft,
|
||||
"稿纸" => BgType::Manuscript,
|
||||
"作文纸" => BgType::Composition,
|
||||
"自定义图片" => BgType::Custom(String::new()),
|
||||
_ => BgType::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_background(&self) -> bool {
|
||||
!matches!(self, BgType::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Theme {
|
||||
Light,
|
||||
@@ -170,7 +214,7 @@ pub fn create_style(theme: &Theme) -> Style {
|
||||
pub struct Settings {
|
||||
pub font_size: f32,
|
||||
pub theme: Theme,
|
||||
pub use_kraft_bg: bool,
|
||||
pub bg_type: BgType,
|
||||
pub active_profile: String,
|
||||
pub profiles: Vec<crate::style::StyleProfile>,
|
||||
pub recent_files: Vec<String>,
|
||||
@@ -184,7 +228,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
font_size: 20.0,
|
||||
theme: Theme::Light,
|
||||
use_kraft_bg: false,
|
||||
bg_type: BgType::None,
|
||||
active_profile: "Kindle 默认".into(),
|
||||
profiles: crate::style::StyleProfile::presets(),
|
||||
recent_files: Vec::new(),
|
||||
|
||||
Reference in New Issue
Block a user