feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer

复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
This commit is contained in:
AskaEth committed 2026-11-02 18:07:32 +08:00
1 parent b8ab46662c
commit 22e4c423fa
18 files changed
+399 -535

No files matched your search

@@ -1,41 +1,16 @@
//! `background` 的单测:填充几何 / 暗化 / 马赛克 / 解码失败。
//! `background` 的单测:解码(png + jpeg)、失败分类、暗化钳位、平铺网格。
//!
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:
//! 能断言的(几何、通道值、错误分类)必须断言;观感(马赛克好不好看、真图直出)不在这里假装。
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。
use super::{
clamp_dim, dimmed, grid, load, mosaic, placement, sample, source_point, BgFill, Image,
LoadError, BG_DIM_MAX, MAX_COLS, MAX_ROWS,
};
use super::{dim_alpha, load, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf};
use unlucky_presenter::prefs::BgFill;
/// 8×4 的合成图:左半红、右半蓝(缩略图与原图同尺寸,取样结果因此可预测)。
fn halves() -> Image {
let (width, height) = (8_u32, 4_u32);
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for _row in 0..height {
for col in 0..width {
if col < width / 2 {
rgba.extend_from_slice(&[200, 40, 40, 255]);
} else {
rgba.extend_from_slice(&[40, 60, 200, 255]);
}
}
}
Image::from_rgba((width, height), width, height, rgba)
}
/// 一整块纯色的合成图(只关心几何时用)。
fn solid(size: (u32, u32), rgb: [u8; 3]) -> Image {
let rgba = [rgb[0], rgb[1], rgb[2], 255].repeat((size.0 * size.1) as usize);
Image::from_rgba(size, size.0, size.1, rgba)
}
fn temp_png(label: &str) -> PathBuf {
fn temp_file(label: &str, name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("ull-bg-{label}-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(&dir).expect("临时目录");
dir.join("bg.png")
dir.join(name)
}
fn cleanup(path: &Path) {
@@ -45,131 +20,63 @@ fn cleanup(path: &Path) {
}
#[test]
fn cover_fills_the_viewport_and_contain_keeps_the_aspect_ratio() {
// 抓得住:两种档的 scale 公式(铺满 vs 完整)与居中偏移的正负号。
// 抓不住:真实的裁切/留白在屏幕上的观感(那要看截图)。
let source = (8, 4); // 2:1
let viewport = (400.0, 400.0);
fn a_real_png_decodes_with_its_original_size_and_raw_bytes() {
// 抓得住:真磁盘 PNG 能读、原图尺寸按图头、缩略图不超预算、原始字节留着给渲染器。
// 抓不住:渲染器用这些字节画出来的颜色(那要看截图)。
let path = temp_file("png", "bg.png");
let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90]));
source.save(&path).expect("写 png");
// cover = max(400/8 = 50, 400/4 = 100) = 100 → 缩放后 800×400,横向溢出 400 被裁。
let cover = placement(BgFill::Cover, source, viewport);
assert_eq!(cover.scale, 100.0, "cover = max(50, 100)");
assert_eq!(cover.origin, (-200.0, 0.0), "cover 居中后横向溢出被裁");
// contain = min(50, 100) = 50 → 缩放后 400×200,上下各留 100 的底色。
let contain = placement(BgFill::Contain, source, viewport);
assert_eq!(contain.scale, 50.0);
assert_eq!(contain.origin, (0.0, 100.0), "contain 上下留底色");
assert!(!contain.repeat && !cover.repeat);
}
#[test]
fn cover_never_leaves_a_hole_and_contain_does() {
let image = solid((8, 4), [10, 20, 30]);
let viewport = (360.0, 360.0);
let covered = mosaic(&image, BgFill::Cover, viewport, 0.0, [0, 0, 0]);
let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读");
assert_eq!(image.source, (300, 20));
assert!(
covered.cells.iter().all(Option::is_some),
"cover 必须铺满(任何一格都不许是 None)"
image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE,
"缩略图 {}×{} 超预算",
image.width,
image.height
);
let contained = mosaic(&image, BgFill::Contain, viewport, 0.0, [0, 0, 0]);
assert_eq!(contained.at(0, 0), None, "contain 的左上角是留底色");
assert_eq!(
contained.at(0, 0),
None,
"(同一格重复读:确保 at() 不是偶发给 None)"
);
let middle = (contained.rows / 2, contained.cols / 2);
assert!(contained.at(middle.0, middle.1).is_some(), "中间必须有图");
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
cleanup(&path);
}
/// M93 r2 的硬要求:`.jpg` 必须能加载(用户挑的背景图/壁纸多半是 jpeg)。
///
/// 抓得住:`image` 的 jpeg 编码器真的开了(没开时 `load` 会返回 `Undecodable`)。
/// 抓不住:渲染器那边(`iced/image`)解码 jpeg 的能力——那条由截图矩阵里那张 jpg 出图举证。
#[test]
fn tile_repeats_the_original_size_art_grid() {
// 抓得住:tile 的 scale 恒 1、origin 在左上角、坐标按图宽/图高取模。
// 抓不住:平铺后每格之间是否有一像素缝(那是渲染层的事)。
let image = halves();
let tile = placement(BgFill::Tile, image.source, (40.0, 40.0));
assert_eq!(tile, tile);
assert_eq!(tile.scale, 1.0);
assert_eq!(tile.origin, (0.0, 0.0));
assert!(tile.repeat);
fn a_real_jpeg_decodes_too() {
let path = temp_file("jpeg", "bg.jpg");
let source =
image::RgbImage::from_fn(64, 32, |x, y| image::Rgb([x as u8 * 4, y as u8 * 8, 90]));
source
.save_with_format(&path, image::ImageFormat::Jpeg)
.expect("写 jpeg");
// 同一源坐标:x 与 x + 图宽 必须取到同一像素。
let left = source_point(&tile, image.source, (3.5, 1.5)).expect("平铺内部总有图");
let wrapped = source_point(&tile, image.source, (3.5 + 8.0 * 3.0, 1.5 + 4.0 * 2.0))
.expect("取模后仍在图内");
assert!((left.0 - wrapped.0).abs() < 1e-4 && (left.1 - wrapped.1).abs() < 1e-4);
// 左半红 / 右半蓝:x=1 取红、x=6 取蓝(平铺不改变这一点)。
let image = load(path.to_str().unwrap()).expect("jpeg 必须能读(M93 r2)");
assert_eq!(image.source, (64, 32));
assert_eq!(
sample(
&image,
source_point(&tile, image.source, (1.0, 1.0)).unwrap()
),
[200, 40, 40]
image.rgba.len(),
(image.width * image.height * 4) as usize,
"像素解出来了"
);
assert_eq!(
sample(
&image,
source_point(&tile, image.source, (6.0, 1.0)).unwrap()
),
[40, 60, 200]
);
}
#[test]
fn dim_mixes_toward_the_panel_colour_and_is_clamped() {
// 抓得住:暗化 = 向底色方向插值(不是简单乘黑);dim 越界被夹到 0..=0.95。
// 语义的关键在浅色主题:向白退(而不是乘黑)才能让深色正文继续读得清。
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断,见报告「没验的部分」)。
assert_eq!(dimmed([200, 100, 50], 0.0, [255, 255, 255]), [200, 100, 50]);
assert_eq!(
dimmed([200, 100, 50], 0.35, [0, 0, 0]),
[130, 65, 33],
"深色主题:向黑退"
);
assert_eq!(
dimmed([200, 100, 50], 0.35, [255, 255, 255]),
[219, 154, 122],
"浅色主题:向白退"
);
assert_eq!(clamp_dim(-1.0), 0.0);
assert_eq!(clamp_dim(f32::NAN), 0.0);
assert_eq!(clamp_dim(9.0), BG_DIM_MAX);
assert_eq!(
dimmed([255, 255, 255], 9.0, [0, 0, 0]),
[13, 13, 13],
"夹到 0.95 → 只剩 5% 原色"
);
}
#[test]
fn grid_is_bounded_so_the_mosaic_cannot_explode() {
// 抓得住:超宽/超高视口下格数被上限截住(渲染成本有界),且至少 1×1。
// 抓不住:这个上限在真机上到底花多少毫秒(没做基准)。
assert_eq!(grid((1180.0, 760.0)), (33, 22));
let (cols, rows) = grid((100_000.0, 100_000.0));
assert_eq!((cols, rows), (MAX_COLS, MAX_ROWS));
assert_eq!(grid((0.0, 0.0)), (1, 1));
cleanup(&path);
}
#[test]
fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 两条都必须是 **Err**(不许静默回一个默认色),且 describe() 里不含 panic 文案。
// 抓不住:本 build 只带 png 编解码器,jpeg/bmp 会在 Undecodable 里出现
// (「Unsigned format」/「The image format ... is not supported」)。
let missing = temp_png("missing");
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
let missing = temp_file("missing", "bg.png");
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
assert!(error
.describe()
.starts_with("Background image could not be read"));
let broken = temp_png("broken");
std::fs::write(&broken, b"this is not a png").expect("写坏文件");
let broken = temp_file("broken", "bg.png");
std::fs::write(&broken, b"this is not an image").expect("写坏文件");
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
cleanup(&broken);
@@ -179,22 +86,39 @@ fn a_missing_or_broken_file_reports_a_readable_reason() {
}
#[test]
fn a_real_png_decodes_and_is_scaled_down_to_the_sample_budget() {
// 抓得住:真磁盘 PNG 能读出来、原图尺寸保留、缩略图边长不超过 MAX_EDGE。
// 抓不住:解码后的颜色是否与它在屏幕上一致(色彩管理不在本 mission)。
let path = temp_png("ok");
let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90]));
source.save(&path).expect("写 png");
fn dim_is_clamped_to_the_supported_range() {
// 抓得住:暗化系数夹在 0..=0.95(下限允许 0 = 不暗化,上限防「背景看不见」),NaN 取 0。
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断)。
assert_eq!(dim_alpha(0.0), 0.0);
assert_eq!(dim_alpha(0.35), 0.35);
assert_eq!(dim_alpha(-1.0), 0.0);
assert_eq!(dim_alpha(f32::NAN), 0.0);
assert_eq!(dim_alpha(9.0), BG_DIM_MAX);
assert_eq!(BG_DIM_MAX, 0.95, "与 core 同源(presenter 重导出)");
}
let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读");
assert_eq!(image.source, (300, 20), "原图尺寸按图头");
assert!(
image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE,
"缩略图边 {}×{} 超预算",
image.width,
image.height
);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
assert_eq!(sample(&image, (0.0, 0.0)), [12, 200, 90], "纯色图取样不变");
cleanup(&path);
#[test]
fn tile_geometry_counts_enough_tiles_and_stays_within_budget() {
// 抓得住:①图比视口小时按原尺寸铺够(ceil);②图比视口大时是 1×1(= 原尺寸裁切);
// ③总块数永远 ≤ MAX_TILES(小图不会拼出上万个控件)。
// 抓不住:铺出来的缝(渲染层的事,看截图)。
let small = tiles((200, 200), (640.0, 480.0));
assert_eq!((small.cols, small.rows), (4, 3));
assert_eq!(small.count(), 12);
let big = tiles((1600, 900), (1180.0, 760.0));
assert_eq!((big.cols, big.rows), (1, 1), "图比视口大 ⇒ 以原尺寸裁切");
let tiny = tiles((16, 16), (3840.0, 2160.0));
assert!(tiny.count() <= MAX_TILES, "{tiny:?} 超预算");
assert!(tiny.cols >= 1 && tiny.rows >= 1, "至少铺一块");
}
/// 编译期钉住:填充档解析走 core 的词表(`cover`/`contain`/`tile`)。
#[test]
fn fill_keys_are_the_schema_vocabulary() {
// 抓得住:有人改了落盘词表却没改这里(截图矩阵的 env 也按这三个词传)。
assert_eq!(BgFill::from_key_value("cover"), BgFill::Cover);
assert_eq!(BgFill::from_key_value("contain"), BgFill::Contain);
assert_eq!(BgFill::from_key_value("tile"), BgFill::Tile);
}