fix(app-iced,presenter): M93 r4 —— Float 键写不回 / prefs.rs 超限 / CPU 档判定 / 平铺退化

复审(代码)r1 的两条 P1 + 两条必修。

P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值)
- `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**,
  不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进
  `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**,
  α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。
- 测试(presenter 层,`host/settings_tests.rs`):
  ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8;
  ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有**
  `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。

P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试
- `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export
  一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。

P2-2 CPU 档判定只在 `--renderer=cpu` 时成立
- 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND`
  候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。
  `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上**
  也会走「面板 α / 玻璃强制 1.0」。
- 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。
- 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→
  `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。

P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`)
- `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale`
  完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。
- 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。
- 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句
  「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。
- 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1,
  断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。
- 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、
  `r5-tile-big.png`。

顺手(复审 P2-3/P2-4/P2-5)
- P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc`
  320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode`
  (`TooLarge` 这条分支此前无测试)。
- P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与
  自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag`
  走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。
- P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`;
  测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。
- 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。
This commit is contained in:
AskaEth committed 2026-11-02 18:46:43 +08:00
1 parent 5c40c64b5f
commit da2da4cd9b
14 files changed
+468 -132

No files matched your search

@@ -26,14 +26,29 @@
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
//! (偏离登记在 [`super`] 的偏离表 #7)。
use unlucky_presenter::prefs::BgFill;
/// 暗化上限(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;
/// 解码**之前**允许的原始边长上限。
///
/// 复审 r1 P2-4:原来写 16_384,等于允许一张 16384×16384 的 PNG 被**整幅解成 RGBA**
/// (≈1.07 GB)再缩到 256——那是解压炸弹。现在按「真实会遇到的壁纸尺寸」定:8K(7680×4320)
/// 留一倍余量 ⇒ **8192**。再大的图直接 [`LoadError::TooLarge`] + 可读提示(不静默)。
pub const MAX_SOURCE_EDGE: u32 = 8_192;
/// 解码时允许的最大内存占用(`image::Limits::max_alloc`)。
///
/// 8192×8192 的 RGBA 是 256 MB;给到 320 MB 足够正常图,同时把「边长合法但通道数/位深
/// 变态」的图挡在分配之前(第二道闸,第一道是 [`MAX_SOURCE_EDGE`])。
pub const MAX_DECODE_BYTES: u64 = 320 * 1024 * 1024;
/// 平铺档「小图」的判据(dp):短边低于它就一定会被放大铺,值得给一句提示。
pub const SMALL_TILE_EDGE: f32 = 200.0;
/// 平铺档一次最多铺多少个图块(列 × 行)。超了**放大图块**而不是少铺——见 [`tiles`] 的不变量。
pub const MAX_TILES: u32 = 64;
@@ -127,7 +142,17 @@ pub fn load(path: &str) -> Result<Image, LoadError> {
});
}
let decoded = image::load_from_memory(&bytes)
// 第二道闸(复审 r1 P2-4):把解码器也关进同样的上限里,别只靠自己先读图头。
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bytes))
.with_guessed_format()
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_SOURCE_EDGE);
limits.max_image_height = Some(MAX_SOURCE_EDGE);
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
let decoded = reader
.decode()
.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);
@@ -163,6 +188,25 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
.map_err(|error| LoadError::Undecodable(error.to_string()))
}
/// 平铺档的**诊断**(复审 r1 P2-1):小图会被放大图块,必须说出来而不是静默。
///
/// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是
/// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。
#[must_use]
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
if !matches!(fill, BgFill::Tile) {
return None;
}
let (width, height) = (image.source.0 as f32, image.source.1 as f32);
if width.min(height) >= SMALL_TILE_EDGE {
return None;
}
Some(format!(
"Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).",
image.source.0, image.source.1
))
}
/// 暗化系数(叠在图上那层底色的 α):`0` = 不暗化,上限 [`BG_DIM_MAX`]。
#[must_use]
pub fn dim_alpha(dim: f32) -> f32 {
@@ -2,7 +2,7 @@
//!
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。
use super::{dim_alpha, load, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf};
use unlucky_presenter::prefs::BgFill;
@@ -145,3 +145,56 @@ fn fill_keys_are_the_schema_vocabulary() {
assert_eq!(BgFill::from_key_value("contain"), BgFill::Contain);
assert_eq!(BgFill::from_key_value("tile"), BgFill::Tile);
}
/// **复审 r1 P2-4**:解压炸弹的第二道闸——图头就超上限时要走 `TooLarge`,而且这条分支
/// 以前**没有任何测试**(三态诊断里唯一没被钉住的一态)。
///
/// 抓得住:`header_size` 的尺寸判据(构造一张 9000×4 的真 PNG,只花 ~100 KB 就能造出来)。
/// 抓不住:内存分配真的被 `image::Limits` 挡住(那要真喂一张巨图跑到 OOM,本机不做)。
#[test]
fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
let path = temp_file("huge", "huge.png");
let source = image::RgbImage::new(9_000, 4);
source.save(&path).expect("写大尺寸图");
let error = load(path.to_str().unwrap()).expect_err("超过 MAX_SOURCE_EDGE 必须报错");
assert!(
matches!(
error,
LoadError::TooLarge {
width: 9_000,
height: 4
}
),
"实际 {error:?}"
);
assert!(
error.describe().contains("too large"),
"提示要人话:{}",
error.describe()
);
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
cleanup(&path);
}
/// **复审 r1 P2-1 的诊断面**:小图平铺会被放大(图块步长放大,见 `tiles`),必须说出来。
///
/// 抓得住:短边 < `SMALL_TILE_EDGE` 的图在 tile 档下一定有提示;cover/contain 档不提示;
/// 大图不提示。
/// 抓不住:提示文案在真实界面上的位置(由截图看)。
#[test]
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
assert!(note.contains("32×32"), "{note}");
assert!(note.contains("scaled up"), "{note}");
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
assert!(small_tile_note(&small, BgFill::Cover).is_none());
assert!(small_tile_note(&small, BgFill::Contain).is_none());
// 大图不提示。
let big = super::Image::from_rgba((1200, 800), 4, 4, vec![0; 64]);
assert!(small_tile_note(&big, BgFill::Tile).is_none());
}
+2 -2
View File
@@ -27,13 +27,13 @@
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
//! |---|---|---|---|
//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:`iced/image` 打开,真图直出(`ContentFit` + 平铺网格) | r1 时 `Cargo.toml` 不在 scope;M93 r2 由 tower 扩 scope 解决 | ✅ |
//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:iced 的 `image-without-codecs` 打开,真图直出(`ContentFit` + 平铺网格) | r1 时 `Cargo.toml` 不在 scope;M93 r2 由 tower 扩 scope 解决 | ✅ |
//! | 2 | ~~面板 α 只覆盖外壳层~~ → **r2 已销**:α 收敛到 `Ui::layer`(唯一施加点),`frame::card` / 页头带 / 外壳 / 外观卡片自动同源 | M93 r2 扩了 `pages/frame.rs` + `theme.rs` 的 scope | ✅ |
//! | 3 | 桌面壁纸取色(第二级)永远 `Absent` | 壁纸探测(M73)还没落地;优先序本身仍按 design 的三级走,测试里用合成像素证明第二级会被用到 | M73 |
//! | 4 | 色卡档**不换** `Ui` 的 scheme | `resolve_source` 的色卡档返回 `DEFAULT_CARD`(天蓝卡)的方案,与令牌 baseline 逐色不同;应用它会把全部既有验收图的像素改掉。取色档(①②)才换 | 色卡选择 UI 落地时一并决议 |
//! | 5 | `Backend::Auto` 不触发 CPU 档降级 | 「强制 1.0」是 CPU 渲染档的规则,而 `Auto` 是「探测前的请求值」;探测结果没有回灌 `Backend` | 让 `probe` 把结果回灌 `Options::backend` |
//! | 6 | ~~背景图只支持 png~~ → **r2 已销**:`image` 补了 `jpeg`/`bmp`,单测与截图都用 `.jpg` 举证 | 同上 | ✅ |
//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dimmed`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) |
//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dim_alpha`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) |
//! | 8 | ~~页头文字压在画布上~~ → **r2 已销**:`frame::header_bar` 加了面板底色 | M93 r2 扩了 `pages/frame.rs` 的 scope | ✅ |
//!
//! ## 取证
@@ -75,27 +75,37 @@ fn fitted(handle: image::Handle, fit: ContentFit) -> Element<'static, Message> {
.into()
}
/// `Tile`:原图尺寸平铺(块数上限见 [`background::MAX_TILES`])。
/// `Tile`:按 [`background::tiles`] 的网格平铺。
///
/// 三个细节都是为了「不留缝」(复审 r1 P2-1):
/// 1. 单块边长 = `源尺寸 × tiles.scale`——超预算时图块**等比例放大**(不是少铺),
/// 所以 `cols × 边长 >= 视口宽`、`rows × 边长 >= 视口高` 由 [`background::tiles`] 保证;
/// 2. `ContentFit::Fill`:`None` 会按**图片原生尺寸**画,比格子小就留空——那道缝正是它造成的;
/// 这里格子尺寸恰好等于「等比放大后的尺寸」,`Fill` 就是等比缩放本身(不变形);
/// 3. 行高显式写死(`Fixed`),列整体 `Fill`:不靠子控件撑,布局结果与算式一致。
fn tiled(
handle: image::Handle,
source: (u32, u32),
viewport: (f32, f32),
) -> Element<'static, Message> {
let tiles = background::tiles(source, viewport);
let (width, height) = (source.0.max(1) as f32, source.1.max(1) as f32);
let (width, height) = tiles.tile_size(source);
let rows = (0..tiles.rows)
.map(|_| {
let cells = (0..tiles.cols)
.map(|_| -> Element<'static, Message> {
image_widget(handle.clone())
.content_fit(ContentFit::None)
.content_fit(ContentFit::Fill)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.into()
})
.collect::<Vec<_>>();
iced::widget::row(cells).into()
iced::widget::row(cells)
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
})
.collect::<Vec<_>>();
@@ -40,7 +40,7 @@ use super::alpha::{
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
WINDOW_ALPHA_FLOOR,
};
use super::background::BG_DIM_MAX;
use super::background::{self, BG_DIM_MAX};
use super::state::State;
use super::Change;
@@ -128,6 +128,12 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
if let Some(note) = state.backend_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
rows = rows.push(note_line(ui, &note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}