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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user