feat(bg): multi-background support with kraft, manuscript, composition, and custom image

This commit is contained in:
Developer
2026-05-14 23:19:14 +08:00
parent a82fa6b7b6
commit 88f15d307a
6 changed files with 757 additions and 109 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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(),