feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源

把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。

整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
  (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
  `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
  先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。

面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
  CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。

背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
  iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。

设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
  背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
  bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
  走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
  `window_opacity` / `palette_source`。

取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
  截图通道不读用户配置(D30)。
This commit is contained in:
AskaEth committed 2026-11-02 18:07:31 +08:00
1 parent eae7ae0ff4
commit b8ab46662c
24 files changed
+3119 -48

No files matched your search

@@ -0,0 +1,200 @@
//! `background` 的单测:填充几何 / 暗化 / 马赛克 / 解码失败。
//!
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:
//! 能断言的(几何、通道值、错误分类)必须断言;观感(马赛克好不好看、真图直出)不在这里假装。
use super::{
clamp_dim, dimmed, grid, load, mosaic, placement, sample, source_point, BgFill, Image,
LoadError, BG_DIM_MAX, MAX_COLS, MAX_ROWS,
};
use std::path::{Path, PathBuf};
/// 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 {
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")
}
fn cleanup(path: &Path) {
if let Some(dir) = path.parent() {
std::fs::remove_dir_all(dir).ok();
}
}
#[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);
// 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]);
assert!(
covered.cells.iter().all(Option::is_some),
"cover 必须铺满(任何一格都不许是 None)"
);
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(), "中间必须有图");
}
#[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);
// 同一源坐标: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 取蓝(平铺不改变这一点)。
assert_eq!(
sample(
&image,
source_point(&tile, image.source, (1.0, 1.0)).unwrap()
),
[200, 40, 40]
);
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));
}
#[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");
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 error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
cleanup(&broken);
let empty = load(" ").expect_err("空路径必须报错");
assert!(matches!(empty, LoadError::Io(_)));
}
#[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");
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);
}