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`(诊断首屏可见)。
This commit is contained in:
AskaEth committed 2026-11-02 18:18:37 +08:00
1 parent 22e4c423fa
commit 5c40c64b5f
13 files changed
+181 -85

No files matched your search

@@ -1,11 +1,10 @@
//! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。
//!
//! ## 真图直出(M93 r2 修订)
//! ## 真图(M93 r2 起)
//!
//! r1 因为 iced 的 `image` feature 没开,只能把像素画成 36dp 网格的马赛克。r2 打开了
//! `iced/image`(`crates/app-iced/Cargo.toml`),于是**真图直出**:填充由 iced 的 `ContentFit`
//! 负责(`Cover` / `Contain`),平铺由本模块的 [`tiles`] 算网格、渲染层真的铺 N 个图块。
//! 马赛克那条路已删除(不再有「每格一个颜色」的降级)。
//! 渲染走 iced 的 `image`(`crates/app-iced/Cargo.toml` 的 `image-without-codecs`):`Cover` /
//! `Contain` 交给 `ContentFit`,`Tile` 用本模块的 [`tiles`] 算网格后由 `picture` 真铺图块。
//! 本模块只负责「解码一次(取色 + 诊断 + 尺寸)」与「网格/暗化的纯数学」。
//!
//! | 档 | 渲染 | 露底色 |
//! |---|---|---|
@@ -36,10 +35,19 @@ pub const MAX_EDGE: u32 = 256;
/// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。
pub const MAX_SOURCE_EDGE: u32 = 16_384;
/// 平铺档一次最多铺多少个图块(列 × 行)。超了就不铺——避免一张 8×8 的小图在 4K 上拼出
/// 上万个子控件。
/// 平铺档一次最多铺多少个图块(列 × 行)。超了**放大图块**而不是少铺——见 [`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 {
@@ -165,13 +173,15 @@ pub fn dim_alpha(dim: f32) -> f32 {
}
}
/// 平铺档的网格(列 × 行)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 平铺档的网格(列 × 行 × 图块缩放)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tiles {
/// 列数。
pub cols: u32,
/// 行数。
pub rows: u32,
/// 单块图相对原尺寸的缩放(`1.0` = 原尺寸;超预算时**只放大图块**,不减少覆盖)。
pub scale: f32,
}
impl Tiles {
@@ -180,33 +190,51 @@ impl Tiles {
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;总块数不超过 [`MAX_TILES`])。
/// 平铺档的网格(原图 1 像素 = 1 dp)。
///
/// 图比视口大时是 `1×1`(等于「以原尺寸裁切」),与小图平铺是同一套算式。
/// **硬不变量:`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 width = source.0.max(1) as f32;
let height = source.1.max(1) as f32;
let cols = ceil_div(viewport.0.max(1.0), width);
let rows = ceil_div(viewport.1.max(1.0), height);
// 超预算时按比例退:先保列再保行,至少留 1×1(否则什么都画不出来)。
let mut tiles = Tiles {
cols: cols.max(1),
rows: rows.max(1),
cols: 1,
rows: 1,
scale: 1.0,
};
while tiles.count() > MAX_TILES {
if tiles.cols > 1 {
tiles.cols -= 1;
} else if tiles.rows > 1 {
tiles.rows -= 1;
} else {
break;
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);
}
tiles
}
fn ceil_div(value: f32, step: f32) -> u32 {