D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate, Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。 - 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`), workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、 iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。 - `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、 prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、 mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等), 内部实现零改动。 - 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS / SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport / system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。 - crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge| toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*` (64 处),backend.rs 的 config_path/build_backend 提为 pub。 - crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。 - prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
371 lines
14 KiB
Rust
371 lines
14 KiB
Rust
//! 背景图与玻璃拟态模糊层的准备(09 §101/§103 的「预生成」手法,纯 Rust、无新依赖)。
|
||
//!
|
||
//! 为什么要自己算:Slint 没有 `backdrop-filter`,也无法在渲染管线里做高斯模糊。可用的只有
|
||
//! `Image::load_from_path` + 像素缓冲(`to_rgba8` / `from_rgba8`)。于是:
|
||
//!
|
||
//! - **模糊层** = 把原图按面积平均降到极小的尺寸(默认宽 48px),渲染时被平滑放大 → 天然模糊
|
||
//! (面积平均本身就是低通滤波,比在满分辨率上跑 box blur 快几个数量级,且一次只在设置变更时算);
|
||
//! - **tile 模式** = Slint 的 `image-fit` 没有 `repeat`,所以在 Rust 侧按窗口尺寸平铺成一张画布,
|
||
//! 交给 `image-fit: cover`(画布与窗口同尺寸,等价于 1:1 铺设)。
|
||
//!
|
||
//! 解码格式:Slint 默认只编译 PNG 解码器(`image-default-formats` 特性未开),因此这里只保证 PNG;
|
||
//! 其它格式 `load_from_path` 直接失败,UI 侧给出提示而不是静默无效果。
|
||
|
||
use slint::{Image, Rgba8Pixel, SharedPixelBuffer};
|
||
|
||
use unlucky_presenter::prefs::BgFill;
|
||
|
||
/// 模糊层目标宽度(像素)。越小越糊;96 在 1180px 宽窗口上 ≈ 24px 半径高斯的效果。
|
||
const BLUR_WIDTH: u32 = 96;
|
||
/// 小图上再跑几轮 box blur(3×3):把降采样的块状边缘抹平。
|
||
const BLUR_PASSES: usize = 3;
|
||
/// tile 画布上限(防超大窗口吃爆内存:4096×2560×4 ≈ 40MB)。
|
||
const TILE_MAX_WIDTH: u32 = 4096;
|
||
const TILE_MAX_HEIGHT: u32 = 2560;
|
||
|
||
/// 一次准备出来的两张图:原图(按填充模式处理)+ 模糊层。
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct BackgroundLayers {
|
||
/// 铺在窗口底的图(tile 模式下已平铺成整窗画布)
|
||
pub image: Image,
|
||
/// 玻璃拟态用的模糊层
|
||
pub blur: Image,
|
||
}
|
||
|
||
/// 载入并加工背景图。
|
||
///
|
||
/// `viewport` 是窗口的**物理像素**尺寸(tile 模式平铺用;拿不到时传 `(0, 0)` 退回只载入)。
|
||
/// 返回 `None` 表示路径无效 / 解码失败 / 读了像素但为空——调用方据此提示用户。
|
||
#[must_use]
|
||
pub fn prepare(path: &str, fill: BgFill, viewport: (u32, u32)) -> Option<BackgroundLayers> {
|
||
let path = path.trim();
|
||
if path.is_empty() {
|
||
return None;
|
||
}
|
||
let loaded = Image::load_from_path(std::path::Path::new(path)).ok()?;
|
||
let source = loaded.to_rgba8()?;
|
||
if source.width() == 0 || source.height() == 0 {
|
||
return None;
|
||
}
|
||
|
||
let image = match fill {
|
||
BgFill::Tile => {
|
||
let (width, height) = tile_canvas_size(source.width(), source.height(), viewport);
|
||
Image::from_rgba8(tile(&source, width, height))
|
||
}
|
||
// cover / contain 交给 Slint 的 image-fit,这里保留原图
|
||
BgFill::Cover | BgFill::Contain => loaded,
|
||
};
|
||
|
||
let blurred = box_blur(&downscale(&source, BLUR_WIDTH), BLUR_PASSES);
|
||
// 放大回窗口物理尺寸再做 1:1 贴图:软件渲染器对图片缩放不做平滑插值(最近邻),
|
||
// 直接把 96px 的小图丢给它会出现明显方块;这里自己双线性放大,观感才是「磨砂」。
|
||
let blur = match viewport {
|
||
(0, 0) => Image::from_rgba8(blurred),
|
||
(width, height) => Image::from_rgba8(upscale(&blurred, width, height)),
|
||
};
|
||
|
||
Some(BackgroundLayers { image, blur })
|
||
}
|
||
|
||
/// 3×3 均值模糊,`passes` 轮(小图上跑,代价可忽略)。
|
||
fn box_blur(
|
||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||
passes: usize,
|
||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||
let width = source.width() as usize;
|
||
let height = source.height() as usize;
|
||
if width < 3 || height < 3 || passes == 0 {
|
||
return source.clone();
|
||
}
|
||
let mut current: Vec<Rgba8Pixel> = source.as_slice().to_vec();
|
||
let mut next = current.clone();
|
||
for _ in 0..passes {
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
let mut sum = [0u32; 4];
|
||
let mut count = 0u32;
|
||
for dy in -1i32..=1 {
|
||
for dx in -1i32..=1 {
|
||
let sy = (y as i32 + dy).clamp(0, height as i32 - 1) as usize;
|
||
let sx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize;
|
||
let pixel = current[sy * width + sx];
|
||
sum[0] += u32::from(pixel.r);
|
||
sum[1] += u32::from(pixel.g);
|
||
sum[2] += u32::from(pixel.b);
|
||
sum[3] += u32::from(pixel.a);
|
||
count += 1;
|
||
}
|
||
}
|
||
next[y * width + x] = Rgba8Pixel {
|
||
r: (sum[0] / count) as u8,
|
||
g: (sum[1] / count) as u8,
|
||
b: (sum[2] / count) as u8,
|
||
a: (sum[3] / count) as u8,
|
||
};
|
||
}
|
||
}
|
||
std::mem::swap(&mut current, &mut next);
|
||
}
|
||
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(source.width(), source.height());
|
||
out.make_mut_slice().copy_from_slice(¤t);
|
||
out
|
||
}
|
||
|
||
/// 双线性放大到目标尺寸。
|
||
fn upscale(
|
||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||
width: u32,
|
||
height: u32,
|
||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||
let src_w = source.width();
|
||
let src_h = source.height();
|
||
if src_w == 0 || src_h == 0 || width == 0 || height == 0 {
|
||
return SharedPixelBuffer::new(width.max(1), height.max(1));
|
||
}
|
||
let pixels = source.as_slice();
|
||
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
|
||
let dst = out.make_mut_slice();
|
||
let sample = |fx: f32, fy: f32| -> Rgba8Pixel {
|
||
let x = (fx * (src_w - 1) as f32).clamp(0.0, (src_w - 1) as f32);
|
||
let y = (fy * (src_h - 1) as f32).clamp(0.0, (src_h - 1) as f32);
|
||
let x0 = x.floor() as usize;
|
||
let y0 = y.floor() as usize;
|
||
let x1 = (x0 + 1).min(src_w as usize - 1);
|
||
let y1 = (y0 + 1).min(src_h as usize - 1);
|
||
let tx = x - x0 as f32;
|
||
let ty = y - y0 as f32;
|
||
let at = |px: usize, py: usize| pixels[py * src_w as usize + px];
|
||
let blend = |a: u8, b: u8, c: u8, d: u8| -> u8 {
|
||
let top = f32::from(a) * (1.0 - tx) + f32::from(b) * tx;
|
||
let bottom = f32::from(c) * (1.0 - tx) + f32::from(d) * tx;
|
||
(top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8
|
||
};
|
||
let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1));
|
||
Rgba8Pixel {
|
||
r: blend(p00.r, p10.r, p01.r, p11.r),
|
||
g: blend(p00.g, p10.g, p01.g, p11.g),
|
||
b: blend(p00.b, p10.b, p01.b, p11.b),
|
||
a: blend(p00.a, p10.a, p01.a, p11.a),
|
||
}
|
||
};
|
||
for y in 0..height as usize {
|
||
let fy = y as f32 / height as f32;
|
||
for x in 0..width as usize {
|
||
dst[y * width as usize + x] = sample(x as f32 / width as f32, fy);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 平铺画布尺寸:覆盖整个视口、且是原图尺寸的整数倍(超上限就截断)。
|
||
fn tile_canvas_size(source_width: u32, source_height: u32, viewport: (u32, u32)) -> (u32, u32) {
|
||
if viewport.0 == 0 || viewport.1 == 0 {
|
||
return (source_width, source_height);
|
||
}
|
||
let repeat_x = viewport.0.div_ceil(source_width).max(1);
|
||
let repeat_y = viewport.1.div_ceil(source_height).max(1);
|
||
let width = (repeat_x * source_width).min(TILE_MAX_WIDTH);
|
||
let height = (repeat_y * source_height).min(TILE_MAX_HEIGHT);
|
||
(width.max(source_width), height.max(source_height))
|
||
}
|
||
|
||
/// 按整数格平铺(源图超出画布的部分裁掉)。
|
||
fn tile(
|
||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||
width: u32,
|
||
height: u32,
|
||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||
let mut canvas = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
|
||
let src_w = source.width() as usize;
|
||
let src_h = source.height() as usize;
|
||
let src = source.as_slice();
|
||
let dst = canvas.make_mut_slice();
|
||
for y in 0..height as usize {
|
||
let src_row = (y % src_h) * src_w;
|
||
let dst_row = y * width as usize;
|
||
for x in 0..width as usize {
|
||
dst[dst_row + x] = src[src_row + (x % src_w)];
|
||
}
|
||
}
|
||
canvas
|
||
}
|
||
|
||
/// 面积平均降采样(保留宽高比,最窄 1px)。
|
||
fn downscale(
|
||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||
target_width: u32,
|
||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||
let src_w = source.width();
|
||
let src_h = source.height();
|
||
let target_width = target_width.clamp(1, src_w);
|
||
let target_height = ((src_h as u64 * target_width as u64) / src_w as u64).max(1) as u32;
|
||
|
||
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(target_width, target_height);
|
||
let src = source.as_slice();
|
||
let dst = out.make_mut_slice();
|
||
|
||
for ty in 0..target_height as usize {
|
||
// 每个目标像素覆盖的源矩形(按比例分块,块内取平均)
|
||
let y0 = ty * src_h as usize / target_height as usize;
|
||
let y1 = ((ty + 1) * src_h as usize / target_height as usize).max(y0 + 1);
|
||
for tx in 0..target_width as usize {
|
||
let x0 = tx * src_w as usize / target_width as usize;
|
||
let x1 = ((tx + 1) * src_w as usize / target_width as usize).max(x0 + 1);
|
||
let mut sum = [0u32; 4];
|
||
let mut count = 0u32;
|
||
for y in y0..y1.min(src_h as usize) {
|
||
for x in x0..x1.min(src_w as usize) {
|
||
let pixel = src[y * src_w as usize + x];
|
||
sum[0] += u32::from(pixel.r);
|
||
sum[1] += u32::from(pixel.g);
|
||
sum[2] += u32::from(pixel.b);
|
||
sum[3] += u32::from(pixel.a);
|
||
count += 1;
|
||
}
|
||
}
|
||
let count = count.max(1);
|
||
dst[ty * target_width as usize + tx] = Rgba8Pixel {
|
||
r: (sum[0] / count) as u8,
|
||
g: (sum[1] / count) as u8,
|
||
b: (sum[2] / count) as u8,
|
||
a: (sum[3] / count) as u8,
|
||
};
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn solid(width: u32, height: u32, pixel: Rgba8Pixel) -> SharedPixelBuffer<Rgba8Pixel> {
|
||
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
|
||
buffer.make_mut_slice().fill(pixel);
|
||
buffer
|
||
}
|
||
|
||
#[test]
|
||
fn downscale_keeps_aspect_and_averages_area() {
|
||
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(96, 48);
|
||
// 左半黑、右半白:降采样后左右两侧应各自保持纯色
|
||
for y in 0..48 {
|
||
for x in 0..96 {
|
||
let value = if x < 48 { 0 } else { 255 };
|
||
source.make_mut_slice()[y * 96 + x] = Rgba8Pixel {
|
||
r: value,
|
||
g: value,
|
||
b: value,
|
||
a: 255,
|
||
};
|
||
}
|
||
}
|
||
let small = downscale(&source, 24);
|
||
assert_eq!(small.width(), 24);
|
||
assert_eq!(small.height(), 12, "宽高比保持 2:1");
|
||
let pixels = small.as_slice();
|
||
assert_eq!(pixels[0].r, 0, "左侧仍是黑");
|
||
assert_eq!(pixels[23].r, 255, "右侧仍是白");
|
||
}
|
||
|
||
#[test]
|
||
fn downscale_never_produces_zero_height() {
|
||
let source = solid(
|
||
400,
|
||
3,
|
||
Rgba8Pixel {
|
||
r: 1,
|
||
g: 2,
|
||
b: 3,
|
||
a: 255,
|
||
},
|
||
);
|
||
let small = downscale(&source, 2);
|
||
assert_eq!(small.width(), 2);
|
||
assert!(small.height() >= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn tile_canvas_covers_viewport_in_whole_repeats() {
|
||
assert_eq!(tile_canvas_size(120, 100, (1180, 760)), (1200, 800));
|
||
// 视口未知(取不到窗口尺寸)时退回单块
|
||
assert_eq!(tile_canvas_size(120, 100, (0, 0)), (120, 100));
|
||
// 超大视口被上限截断
|
||
let (width, height) = tile_canvas_size(64, 64, (9000, 9000));
|
||
assert!(width <= TILE_MAX_WIDTH && height <= TILE_MAX_HEIGHT);
|
||
}
|
||
|
||
#[test]
|
||
fn tile_repeats_source_pixels() {
|
||
let source = solid(
|
||
2,
|
||
2,
|
||
Rgba8Pixel {
|
||
r: 9,
|
||
g: 9,
|
||
b: 9,
|
||
a: 255,
|
||
},
|
||
);
|
||
let canvas = tile(&source, 5, 3);
|
||
assert_eq!(canvas.width(), 5);
|
||
assert_eq!(canvas.height(), 3);
|
||
assert!(canvas.as_slice().iter().all(|pixel| pixel.r == 9));
|
||
}
|
||
|
||
#[test]
|
||
fn prepare_rejects_bad_input() {
|
||
assert!(prepare("", BgFill::Cover, (100, 100)).is_none());
|
||
assert!(prepare("D:/definitely/not/here.png", BgFill::Cover, (100, 100)).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn box_blur_smooths_a_hard_edge() {
|
||
// 左黑右白:模糊后交界处应出现中间灰(硬边被抹平)
|
||
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(9, 3);
|
||
for y in 0..3 {
|
||
for x in 0..9 {
|
||
let value = if x < 4 { 0 } else { 255 };
|
||
source.make_mut_slice()[y * 9 + x] = Rgba8Pixel {
|
||
r: value,
|
||
g: value,
|
||
b: value,
|
||
a: 255,
|
||
};
|
||
}
|
||
}
|
||
let blurred = box_blur(&source, 2);
|
||
let middle = blurred.as_slice()[9 + 4]; // 第 1 行(共 9 列)的第 4 个像素
|
||
assert!(
|
||
middle.r > 20 && middle.r < 235,
|
||
"交界处应被抹成中间灰,实际 {}",
|
||
middle.r
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn upscale_matches_requested_size_and_interpolates() {
|
||
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(2, 1);
|
||
let pixels = source.make_mut_slice();
|
||
pixels[0] = Rgba8Pixel {
|
||
r: 0,
|
||
g: 0,
|
||
b: 0,
|
||
a: 255,
|
||
};
|
||
pixels[1] = Rgba8Pixel {
|
||
r: 255,
|
||
g: 255,
|
||
b: 255,
|
||
a: 255,
|
||
};
|
||
let big = upscale(&source, 8, 2);
|
||
assert_eq!(big.width(), 8);
|
||
assert_eq!(big.height(), 2);
|
||
let row = big.as_slice();
|
||
// 双线性:中间像素应是灰阶过渡而不是 0/255 二值
|
||
assert!(row[3].r > 60 && row[3].r < 200, "实际 {}", row[3].r);
|
||
}
|
||
}
|