Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/background.rs
T
AskaEth 5c40c64b5f fix(app-iced): M93 r3 —— 复审 r1 四条返工(Canvas 不吃 α / 平铺补缝 / 诊断上移 / 文档)
先 `git rebase main`(M92 `eae7ae0` 已在主线;`Cargo.lock` 与 `crates/app-iced/Cargo.toml`
自动合并,`image-without-codecs` 与 M92 的 `unlucky-i18n` 两段都在)。

P2-2(口径违约)Canvas 层不许吃面板 α
- `Ui::layer()` 改成按令牌 `MaterialLayer::allows_translucency()` 判定:Canvas 恒不透明,
  只有 Card / Overlay 乘 `panel_alpha`。判据取令牌的不变量,三个调用点(标题栏 / 导航轨 /
  页头带)没有各自特判。
- 守门:`the_canvas_layer_stays_opaque_while_panels_take_the_alpha`(α=0.55 时 Canvas.a==1.0、
  Card/Overlay.a==0.55;CPU 档连卡片都是 1.0)。

P2-1 平铺不许留缝
- `background::tiles()` 超 `MAX_TILES` 时改成**放大图块步长**(`Tiles { cols, rows, scale }`,
  按 1.15 逐级放大到块数进预算),而不是减列/减行;`MAX_TILE_SCALE` 提到 1024 只是安全阀
  (1×1 的图在 1180×760 上需要 ~118 倍才能用 64 块盖满)。
- `picture::tiled()` 用 `ContentFit::Fill` + 格子尺寸 = `源尺寸 × scale`(同比例,不变形):
  `None` 会按图片原生尺寸画、比格子小就留空,正是那道缝。
- 守门:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 120×120 与 1×1,
  断言 `covered >= viewport` 且 `count <= MAX_TILES`);背景层底下本来就有不透明画布底色兜底。

P2-3 诊断上移到首屏
- 诊断块(读盘失败红字 / CPU 降级 / 配色回落 / 当前配色层级)从卡片末尾移到「背景图路径」
  紧下方:1180×760 首屏可见(截图 `r4-broken-path.png` 里红字在 y≈594)。

P2-4 文档对齐
- `transparency.rs`:`WS_EX_LAYERED` 的归属改成「本文件的 `ensure_layered` 负责」,
  窗口创建侧只拿句柄;`platform/mod.rs` / `lib.rs` 的模块表同步。
- 删掉「马赛克取样」这类已删实现的现在时描述(`background` / `picture` / `app` 的注释),
  保留一句 r1→r2 的历史说明;`app/tests/appearance.rs` 的测试意图跟着改。

门禁(工作区级,最终树):`cargo fmt --all --check` 干净;
`cargo clippy --workspace --all-targets -- -D warnings` 无输出;
`cargo test --workspace` 全绿(app-iced 299 lib + 6 集成守门、presenter 126、其余 crate 全过)。
出图:`r4-canvas-opaque-panel55{,-dark}.png`(Canvas 不透明 + 面板透底)、
`r4-tile-120.png`(120×120 平铺右缘无裸白)、`r4-broken-path.png`(诊断首屏可见)。
2026-11-02 18:18:37 +08:00

251 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。
//!
//! ## 真图(M93 r2 起)
//!
//! 渲染走 iced 的 `image`(`crates/app-iced/Cargo.toml` 的 `image-without-codecs`):`Cover` /
//! `Contain` 交给 `ContentFit`,`Tile` 用本模块的 [`tiles`] 算网格后由 `picture` 真铺图块。
//! 本模块只负责「解码一次(取色 + 诊断 + 尺寸)」与「网格/暗化的纯数学」。
//!
//! | 档 | 渲染 | 露底色 |
//! |---|---|---|
//! | `cover` | `ContentFit::Cover`(铺满、裁掉溢出) | 否 |
//! | `contain` | `ContentFit::Contain`(完整显示、等比) | **是**(上下或左右) |
//! | `tile` | 原图尺寸平铺([`tiles`] 给行列数,上限 [`MAX_TILES`]) | 否(图比视口大时=裁切) |
//!
//! ## 解码只在这里(一次)
//!
//! 一张图会被两处用到:①渲染器(`image::Handle::from_path`,由 iced 自己解码;r2 实测
//! `from_bytes` 的异步纹理分配赶不上截图第 3 帧,画面会是空的);②取色(`unlucky_design` 要 RGBA
//! 像素,本模块把解码结果缩到 [`MAX_EDGE`] 内交给它)。本模块的解码只为「取色 + 诊断 + 尺寸」。
//!
//! ## 暗化(`appearance.bg_dim`)
//!
//! 语义是「让背景往面板底色退」,即把版面底色(`Role::Surface`)以 α = `dim` 叠在图上——
//! 与 `rgb + (toward − rgb) × dim` **在数学上等价**(图不透明时),所以这里只给一个钳位函数
//! [`dim_alpha`],真正的合成交给渲染器(CPU/GPU 两档都支持普通 alpha 混合)。
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
//! (偏离登记在 [`super`] 的偏离表 #7)。
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
pub const BG_DIM_MAX: f32 = unlucky_presenter::prefs::BG_DIM_MAX as f32;
/// 解码后保留给取色的最大边长:取色只需要色彩分布,4K 图缩到 256 边后内存从 33MB 掉到 147KB。
pub const MAX_EDGE: u32 = 256;
/// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。
pub const MAX_SOURCE_EDGE: u32 = 16_384;
/// 平铺档一次最多铺多少个图块(列 × 行)。超了**放大图块**而不是少铺——见 [`tiles`] 的不变量。
pub const MAX_TILES: u32 = 64;
/// 图块放大步长(每次乘这个数,直到块数落进预算)。
pub const TILE_SCALE_STEP: f32 = 1.15;
/// 图块缩放上限(**只是安全阀**,正常情况下远达不到)。
///
/// 取 1024 而不是 64:`cols·rows ≤ MAX_TILES` 与「覆盖 ≥ 视口」在**极小图**上要求很大的缩放
/// (1×1 的图在 1180×760 上要 ~118 倍才能用 64 块盖满)。取小了就等于两者不可兼得——那一侧
/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。
pub const MAX_TILE_SCALE: f32 = 1024.0;
/// 解码失败的原因(人话在 [`LoadError::describe`])。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
/// 读文件失败:不存在 / 没权限 / 被占用。
Io(String),
/// 解码失败:格式不认识(webp/tiff 等没开编码器)/ 文件截断 / 不是图片。
Undecodable(String),
/// 图片头里的尺寸超限。
TooLarge {
/// 宽。
width: u32,
/// 高。
height: u32,
},
}
impl LoadError {
/// 可读诊断(设置页提示行与日志共用同一句)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Io(reason) => format!("Background image could not be read: {reason}"),
Self::Undecodable(reason) => format!(
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)"
),
Self::TooLarge { width, height } => format!(
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
),
}
}
}
/// 一张解码好的背景图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// **原图**尺寸(像素):填充几何按它算(`tile` = 原始尺寸平铺)。
pub source: (u32, u32),
/// 缩略图宽(≤ [`MAX_EDGE`])。
pub width: u32,
/// 缩略图高(≤ [`MAX_EDGE`])。
pub height: u32,
/// 缩略图 RGBA8(取色用;`width × height × 4`)。
pub rgba: Vec<u8>,
}
impl Image {
/// 从已解码的数据构造(测试也走这里,避免测试依赖磁盘)。
#[must_use]
pub fn from_rgba(source: (u32, u32), width: u32, height: u32, rgba: Vec<u8>) -> Self {
Self {
source,
width,
height,
rgba,
}
}
}
/// 读盘 + 解码 + 缩到 [`MAX_EDGE`] 内。
///
/// # Errors
/// 见 [`LoadError`]:读不到 / 解不开 / 头部尺寸超限。**不 return 默认色**——回落的决定在
/// [`super::palette`](它会往 `Provenance` 里留原因),这一层只管如实报告失败。
pub fn load(path: &str) -> Result<Image, LoadError> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(LoadError::Io("no path configured".to_owned()));
}
let bytes = std::fs::read(trimmed).map_err(|error| LoadError::Io(error.to_string()))?;
// 第一趟只读图头:先按尺寸拒掉压缩炸弹,再真解码。
let (source_w, source_h) = header_size(&bytes)?;
if source_w > MAX_SOURCE_EDGE || source_h > MAX_SOURCE_EDGE {
return Err(LoadError::TooLarge {
width: source_w,
height: source_h,
});
}
let decoded = image::load_from_memory(&bytes)
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
let (width, height) = (decoded.width().max(1), decoded.height().max(1));
let scale = (f64::from(MAX_EDGE) / f64::from(width.max(height))).min(1.0);
let target_w = ((f64::from(width) * scale).round() as u32).max(1);
let target_h = ((f64::from(height) * scale).round() as u32).max(1);
let thumb = image::imageops::resize(
&decoded,
target_w,
target_h,
image::imageops::FilterType::Triangle,
);
Ok(Image {
source: (source_w, source_h),
width: target_w,
height: target_h,
rgba: thumb.into_raw(),
})
}
/// 原图尺寸(只读图头,不解像素)。
fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
if reader.format().is_none() {
return Err(LoadError::Undecodable(
"unknown image format (magic bytes not recognised)".to_owned(),
));
}
reader
.into_dimensions()
.map_err(|error| LoadError::Undecodable(error.to_string()))
}
/// 暗化系数(叠在图上那层底色的 α):`0` = 不暗化,上限 [`BG_DIM_MAX`]。
#[must_use]
pub fn dim_alpha(dim: f32) -> f32 {
if dim.is_nan() {
0.0
} else {
dim.clamp(0.0, BG_DIM_MAX)
}
}
/// 平铺档的网格(列 × 行 × 图块缩放)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tiles {
/// 列数。
pub cols: u32,
/// 行数。
pub rows: u32,
/// 单块图相对原尺寸的缩放(`1.0` = 原尺寸;超预算时**只放大图块**,不减少覆盖)。
pub scale: f32,
}
impl Tiles {
/// 总块数。
#[must_use]
pub fn count(self) -> u32 {
self.cols * self.rows
}
/// 一块图的实际边长(dp):`源尺寸 × scale`。
#[must_use]
pub fn tile_size(self, source: (u32, u32)) -> (f32, f32) {
(
source.0.max(1) as f32 * self.scale,
source.1.max(1) as f32 * self.scale,
)
}
/// 铺满后覆盖的宽高(dp):**恒 ≥ 视口**([`tiles`] 的硬不变量,单测钉住)。
#[must_use]
pub fn covered(self, source: (u32, u32)) -> (f32, f32) {
let (w, h) = self.tile_size(source);
(self.cols as f32 * w, self.rows as f32 * h)
}
}
/// 平铺档的网格(原图 1 像素 = 1 dp)。
///
/// **硬不变量:`covered >= viewport`**——任何情况都不许留缝(复审 r1 P2-1 的返工点)。
///
/// r1 的写法是「超预算就减列/减行」,于是 120×120 的图需要 10×7=70 块 > [`MAX_TILES`],
/// 列数被砍到 9 ⇒ 覆盖宽 1080 < 1180,右缘 100px 裸白。现在的做法是**放大图块步长**:
/// `scale` 从 1.0 起按 [`TILE_SCALE_STEP`] 逐级变大,直到 `ceil(视口/图块) 的乘积 ≤ 预算`。
/// 图块按同一比例放大 ⇒ 图不变形;`ceil` 保证每行/每列都盖过视口边界(多出来的部分由渲染器裁掉)。
///
/// 图比视口大时是 `1×1, scale=1.0`(= 以原尺寸裁切),与小图平铺是同一套算式。
#[must_use]
pub fn tiles(source: (u32, u32), viewport: (f32, f32)) -> Tiles {
let mut tiles = Tiles {
cols: 1,
rows: 1,
scale: 1.0,
};
loop {
let (width, height) = tiles.tile_size(source);
tiles.cols = ceil_div(viewport.0.max(1.0), width);
tiles.rows = ceil_div(viewport.1.max(1.0), height);
if tiles.count() <= MAX_TILES || tiles.scale >= MAX_TILE_SCALE {
return tiles;
}
tiles.scale = (tiles.scale * TILE_SCALE_STEP).min(MAX_TILE_SCALE);
}
}
fn ceil_div(value: f32, step: f32) -> u32 {
let count = (value / step).ceil();
if count.is_finite() && count >= 1.0 {
count as u32
} else {
1
}
}
#[cfg(test)]
mod tests;