From da2da4cd9b755312ccb84b882f958fcab641c71f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 18:46:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced,presenter):=20M93=20r4=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20Float=20=E9=94=AE=E5=86=99=E4=B8=8D=E5=9B=9E=20/=20?= =?UTF-8?q?prefs.rs=20=E8=B6=85=E9=99=90=20/=20CPU=20=E6=A1=A3=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=20/=20=E5=B9=B3=E9=93=BA=E9=80=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审(代码)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)。 --- crates/app-iced/src/app/backend.rs | 113 ++++++++++++++++++ crates/app-iced/src/app/mod.rs | 1 + crates/app-iced/src/app/state.rs | 5 +- crates/app-iced/src/app/tests/appearance.rs | 2 +- .../src/pages/appearance/background.rs | 50 +++++++- .../src/pages/appearance/background/tests.rs | 55 ++++++++- crates/app-iced/src/pages/appearance/mod.rs | 4 +- .../app-iced/src/pages/appearance/picture.rs | 18 ++- .../app-iced/src/pages/appearance/section.rs | 8 +- .../src/platform/win32/transparency.rs | 18 +++ crates/presenter/src/host/settings.rs | 59 ++++----- crates/presenter/src/host/settings_tests.rs | 86 +++++++++++++ crates/presenter/src/prefs.rs | 91 +------------- crates/presenter/src/prefs/tests.rs | 90 ++++++++++++++ 14 files changed, 468 insertions(+), 132 deletions(-) create mode 100644 crates/app-iced/src/app/backend.rs create mode 100644 crates/presenter/src/prefs/tests.rs diff --git a/crates/app-iced/src/app/backend.rs b/crates/app-iced/src/app/backend.rs new file mode 100644 index 0000000..3eb9d02 --- /dev/null +++ b/crates/app-iced/src/app/backend.rs @@ -0,0 +1,113 @@ +//! 本进程**实际**用的是哪个渲染后端(D36 的「CPU 档」判据)。 +//! +//! ## 为什么不能直接看 `--renderer` 的请求值 +//! +//! `Options::backend` 是**请求**(默认 `Auto`)。真正落到哪个渲染器由 `probe::apply` 决定, +//! 它把结论写进进程环境变量 `ICED_BACKEND`(iced 只认这个)。于是默认参数 + 没有独显的机器上: +//! 请求是 `Auto`,实际是 tiny-skia(CPU)——如果面板 α / 玻璃的降级判据只看请求值, +//! D36 明写的「CPU 档面板 α 强制 1.0」**恰好在它最该生效的场景失效**(复审 r1 P2-2)。 +//! +//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 的候选列表翻译回 [`Backend`]。 +//! 这里是**唯一**的翻译点,别在别处再读一遍环境变量。 +//! +//! ## 已知边界(诚实登记) +//! +//! `ICED_BACKEND="wgpu,tiny-skia"` 表示「优先 wgpu,拿不到适配器再回退 tiny-skia」。运行期到底 +//! 选了谁只有渲染器自己知道,本 crate 拿不到(iced 0.14 没有公开的「当前后端」查询)。 +//! 这种机器按 **GPU 档**判(面板 α 生效);如果 wgpu 在运行期回退到 tiny-skia,面板 α 会 +//! 在 CPU 渲染器上生效——成本比 D27 的顾虑略高,但不会画错。要彻底收口得让 iced 暴露实际后端。 + +use crate::Backend; + +/// `ICED_BACKEND` 的候选列表 → 实际档位(纯函数,好测)。 +/// +/// | `ICED_BACKEND` | 含义(`probe::backend_env` 的产物) | 结论 | +/// |---|---|---| +/// | `tiny-skia` | 只有 CPU 候选(`--software-renderer` / 未编 wgpu / Auto 无线程硬件) | [`Backend::Cpu`] | +/// | `wgpu,tiny-skia` | 点名要 GPU(保留回退) | [`Backend::Gpu`] | +/// | `tiny-skia,wgpu` | Auto + 有 wgpu 无硬件(CPU 起步、wgpu 兜底) | [`Backend::Cpu`] | +/// | 未设 | Auto + 有硬件(交给 iced 默认顺序) | 请求值(`Auto`) | +#[must_use] +pub(super) fn backend_from_env(request: Backend, env: Option<&str>) -> Backend { + let Some(list) = env.map(str::trim).filter(|value| !value.is_empty()) else { + return request; + }; + // 候选列表的第一项就是起步渲染器:`tiny-skia` 起步 = CPU 档(D36 的降级对象)。 + let first = list.split(',').next().unwrap_or(list).trim(); + match first { + "tiny-skia" => Backend::Cpu, + "wgpu" => Backend::Gpu, + _ => request, + } +} + +/// 本进程实际生效的后端(判据 = `probe::apply` 写下的 `ICED_BACKEND`)。 +#[must_use] +pub(super) fn effective_backend(request: Backend) -> Backend { + backend_from_env(request, std::env::var("ICED_BACKEND").ok().as_deref()) +} + +#[cfg(test)] +mod tests { + use super::backend_from_env; + use crate::Backend; + use unlucky_presenter::prefs::{PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN}; + + /// 抓得住:`probe::backend_env` 的每一种产物都被翻译成正确的档位(含最容易踩的 + /// 「Auto + 没独显」= `tiny-skia,wgpu` → CPU)。 + /// 抓不住:运行期 wgpu 真回退到 tiny-skia 的情况(iced 没暴露实时候选,见模块文档)。 + #[test] + fn every_iced_backend_list_maps_to_a_tier() { + assert_eq!( + backend_from_env(Backend::Auto, Some("tiny-skia")), + Backend::Cpu + ); + assert_eq!( + backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")), + Backend::Cpu, + "Auto + 无硬件:CPU 起步 ⇒ CPU 档" + ); + assert_eq!( + backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")), + Backend::Gpu + ); + assert_eq!( + backend_from_env(Backend::Gpu, Some("wgpu,tiny-skia")), + Backend::Gpu + ); + // 未设 / 空白:保持请求值(Auto + 有硬件 ⇒ 交给 iced,按 GPU 档判)。 + assert_eq!(backend_from_env(Backend::Auto, None), Backend::Auto); + assert_eq!(backend_from_env(Backend::Auto, Some(" ")), Backend::Auto); + // 不认识的取值不猜:保留请求值。 + assert_eq!( + backend_from_env(Backend::Auto, Some("metal")), + Backend::Auto + ); + } + + /// **复审 r1 P2-2 的守门**:默认档(`Auto`)在无独显机器上必须落进 CPU 档, + /// 从而让「面板 α / 玻璃强制 1.0」在默认路径上真的生效。 + /// + /// 抓得住:判据走 `effective_backend()` 这条链(谁把 `State::new` 改回只吃请求值,这条会红)。 + /// 抓不住:整窗 α 在 CPU 档仍要生效(那是另一条判据,见 `alpha` 模块真值表)。 + #[test] + fn auto_with_a_software_renderer_forces_the_panel_opaque() { + use crate::pages::appearance::alpha::{effective_glass, effective_panel_alpha}; + + let backend = backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")); + assert_eq!(backend, Backend::Cpu); + assert_eq!( + effective_panel_alpha(backend, 0.55), + 1.0, + "D36:CPU 档面板 1.0" + ); + assert!(!effective_glass(backend, true), "D36:CPU 档玻璃强制关"); + // 但整窗 α 不受限(D36 对 D27 的修订)。 + assert!(crate::platform::effective_window_alpha(backend, 0.8).is_some()); + + // 反方向:GPU 档不做这个降级(否则「强制」就没意义了)。 + let gpu = backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")); + assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55); + assert!((PANEL_ALPHA_MIN - 0.4).abs() < 1e-9 && (WINDOW_ALPHA_MIN - 0.08).abs() < 1e-9); + } +} diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index f0201e8..a58ccbb 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -28,6 +28,7 @@ //! (组件源码里的文档与测试都按这个路径写)。 mod appearance; +mod backend; mod boot; mod message; mod state; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 6ab4ce7..e762648 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -164,10 +164,13 @@ impl App { } else { (bridge.load().0, bridge.load_base()) }; + // 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2): + // 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。 + let backend_tier = super::backend::effective_backend(options.backend); let appearance = pages::appearance::State::new( &appearance_prefs, &appearance_base, - options.backend, + backend_tier, flavor, ); let view = pages::Model::new( diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index ac2b8df..cde13d8 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -46,7 +46,7 @@ fn cleanup(dir: &PathBuf) { std::fs::remove_dir_all(dir).ok(); } -/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 204(钳位链的入口那一段)。 +/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 205(钳位链的入口那一段)。 /// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。 #[test] fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() { diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index 29eace1..d706029 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -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 { }); } - 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 { + 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 { diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index ec1c626..4e69b88 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -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()); +} diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index cdb85da..4ce2137 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -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 | ✅ | //! //! ## 取证 diff --git a/crates/app-iced/src/pages/appearance/picture.rs b/crates/app-iced/src/pages/appearance/picture.rs index 646ea9d..f4280fa 100644 --- a/crates/app-iced/src/pages/appearance/picture.rs +++ b/crates/app-iced/src/pages/appearance/picture.rs @@ -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::>(); - iced::widget::row(cells).into() + iced::widget::row(cells) + .width(Length::Fill) + .height(Length::Fixed(height)) + .into() }) .collect::>(); diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index 671193f..9c87cac 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -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, ¬e, ui.color(Role::OnSurfaceVariant))); + } if let Some(note) = state.palette_note() { rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); } diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 2985ab8..9be8cb5 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -18,6 +18,24 @@ //! 所以窗口创建侧(`app::boot`)不碰扩展样式,它只负责拿到句柄后先调 [`ensure_layered`] //! 再调 [`apply_window_alpha`]——两件事都在同一个 unsafe 落点里,门禁仍是「集合相等」。 //! +//! ## 为什么它不影响命中测试与自绘标题栏拖动(D36 任务书要求写清) +//! +//! `LWA_ALPHA` 只改**合成**(DWM 把整窗乘一个常数),不动窗口的命中测试: +//! +//! | 机制 | 会不会让鼠标穿透 | 本项目用不用 | +//! |---|---|---| +//! | `WS_EX_TRANSPARENT`(分层窗口的「点击穿透」位) | **会**(鼠标事件直接发给下层的窗口) | 不用 | +//! | `LWA_COLORKEY`(色键:某种颜色整块变透明) | **会**(命中测试按颜色挖洞) | 不用 | +//! | `WS_EX_LAYERED` + `LWA_ALPHA` | **不会**(只是合成时乘 α,整个矩形仍然接收命中) | 用这一条 | +//! +//! 所以自绘标题栏的拖动照旧:`Message::DragWindow` → `window::drag(id)` 走 winit 的 +//! `WM_NCLBUTTONDOWN(HTCAPTION)`,它依赖的是**窗口矩形内的鼠标事件**,与 α 无关。 +//! 唯一会「点不动」的情形是 α = 0(整窗不可见,用户以为程序消失)——这就是 +//! [`unlucky_presenter::prefs::WINDOW_ALPHA_MIN`](0.08)作为硬底、且 `α ≤ 0` 直接拒绝的原因。 +//! +//! D36 已在真机上实测「α ≥ 1 时分层样式不破坏命中测试与拖动」;本仓库的 RDP 环境无法复核 +//! 观感,所以这条结论的**行为**部分归真机验收(见 M93 报告「没验的部分」)。 +//! //! ## 非 Windows //! //! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`, diff --git a/crates/presenter/src/host/settings.rs b/crates/presenter/src/host/settings.rs index 5f3d39b..5e45361 100644 --- a/crates/presenter/src/host/settings.rs +++ b/crates/presenter/src/host/settings.rs @@ -9,6 +9,7 @@ use serde_json::Value; use unlucky_core::game::java::JdkVendor; +use unlucky_core::setting::spec::ValueKind; use crate::dto::{SettingGroupView, SettingRowView}; @@ -336,37 +337,37 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> { } /// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。 +/// +/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):原先是「硬编码 Int/Bool +/// 键名,其余当字符串」,于是 `appearance.window_opacity`(Float)被当 String 送进 `store.set`, +/// schema 的 `as_f64()` 返回 `None` ⇒ `InvalidValue` ⇒ **`save()` 不执行**(重启回旧值 + 弹 toast)。 +/// 现在新增任何 Float/Int/Bool 键都自动正确;集合级守门是 +/// `every_float_key_in_the_schema_can_be_written_back`。 fn parse_value(key: &str, value: &str) -> Result { - if matches!(key, "download.concurrency" | "download.retry_limit") { - let number: u32 = value - .trim() - .parse() - .map_err(|_| format!("{key} 需要整数:{value}"))?; - return Ok(Value::from(number)); + let schema = unlucky_core::setting::keys::launcher_schema(); + let Some(spec) = schema.spec(key) else { + // 未知键:原样交下去,由 `store.set` 用 schema 报「未知键」(这里不猜类型)。 + return Ok(Value::String(value.to_string())); + }; + + let trimmed = value.trim(); + match &spec.kind { + ValueKind::Bool => match trimmed.to_ascii_lowercase().as_str() { + "1" | "true" | "on" | "yes" => Ok(Value::Bool(true)), + "0" | "false" | "off" | "no" => Ok(Value::Bool(false)), + _ => Err(format!("{key} 需要布尔值:{value}")), + }, + ValueKind::Int { .. } => trimmed + .parse::() + .map(Value::from) + .map_err(|_| format!("{key} 需要整数:{value}")), + ValueKind::Float { .. } => match trimmed.parse::() { + Ok(number) if number.is_finite() => Ok(Value::from(number)), + _ => Err(format!("{key} 需要有限数字:{value}")), + }, + // 文本 / 枚举 / 路径 / 列表 / 任意 JSON:字符串原样交下去(越界与词表由 schema 判)。 + _ => Ok(Value::String(value.to_string())), } - if matches!(key, "java.default_memory_mb" | "agent.mcp.port") { - let number: u32 = value - .trim() - .parse() - .map_err(|_| format!("{key} 需要整数:{value}"))?; - return Ok(Value::from(number)); - } - if matches!( - key, - "java.auto_install" - | "ai.enabled" - | "ai.yolo" - | "agent.mcp.enabled" - | "agent.mcp.allow_write" - ) { - let flag = match value.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "on" | "yes" => true, - "0" | "false" | "off" | "no" => false, - _ => return Err(format!("{key} 需要布尔值:{value}")), - }; - return Ok(Value::Bool(flag)); - } - Ok(Value::String(value.to_string())) } /// 清掉 24h 地区缓存(下次建引擎重新判定)。 diff --git a/crates/presenter/src/host/settings_tests.rs b/crates/presenter/src/host/settings_tests.rs index 87b5b05..0cabb39 100644 --- a/crates/presenter/src/host/settings_tests.rs +++ b/crates/presenter/src/host/settings_tests.rs @@ -75,3 +75,89 @@ fn detect_region_action_removes_cache() { assert!(!path.exists()); std::fs::remove_dir_all(&dir).ok(); } + +/// **P1-1 的回归**:整窗透明度是 `ValueKind::Float` 的键,`SetSetting` 的载荷是字符串。 +/// +/// 历史(复审 r1 P1-1):`parse_value` 只认硬编码的 Int/Bool 键名,Float 键被当成字符串交给 +/// `store.set`,schema 的 `as_f64()` 对 String 返回 None ⇒ `InvalidValue` ⇒ **`save()` 不执行**, +/// 于是 α 只在内存生效、重启回旧值,且每次拖动弹一条 Error toast。 +/// +/// 抓得住:`set` → 落盘 → **新开一个 store(= 模拟重启)** 读回同一值。 +/// 抓不住:UI 侧是否真的把滑块接到 `SetSetting`(由 `app` 侧的 `on_appearance` 测试管)。 +#[test] +fn float_key_round_trips_through_set_setting() { + let dir = std::env::temp_dir().join(format!("ull-host-settings-float-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = env_in(&dir); + + set(&env, "appearance.window_opacity", "0.8").expect("写整窗透明度"); + set(&env, "appearance.panel_alpha", "0.55").expect("写面板透明度"); + + // 新开一个 store:从磁盘重新加载(等价「重启进程」)。 + let reopened = env.store(); + assert_eq!( + reopened.doc().effective_f64("appearance.window_opacity"), + Some(0.8) + ); + assert_eq!( + reopened.doc().effective_f64("appearance.panel_alpha"), + Some(0.55) + ); + + // 越界仍然被 schema 拒绝(钳位在 core 的读侧,写侧是拒绝)。 + assert!(set(&env, "appearance.window_opacity", "0.0").is_err()); + // 非数字报人话,而不是「期望数字,实际是 \"abc\"」这种 schema 原话。 + assert!(set(&env, "appearance.window_opacity", "abc").is_err()); + assert!(set(&env, "appearance.window_opacity", "NaN").is_err()); + std::fs::remove_dir_all(&dir).ok(); +} + +/// **集合级守门(复审 P1-1 要求的那条)**:schema 里**每一个** `ValueKind::Float` 的键都必须 +/// 能被 `SetSetting` 写回,而不只是 `window_opacity`。 +/// +/// 为什么这条比单键测试重要:下次谁再加一个 Float 键(比如玻璃强度、模糊半径), +/// 只要 `parse_value` 又退化成「硬编码表」,这里立刻红——单键测试抓不住新键。 +/// +/// 抓得住:类型解析与 `save()` 是否真的执行(读回是**新开 store**,走磁盘)。 +/// 抓不住:UI 控件是否接上了这些键(那是各页面的接线测试)。 +#[test] +fn every_float_key_in_the_schema_can_be_written_back() { + let dir = + std::env::temp_dir().join(format!("ull-host-settings-allfloat-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = env_in(&dir); + + let schema = unlucky_core::setting::keys::launcher_schema(); + let mut checked = 0_usize; + for spec in schema.specs() { + let unlucky_core::setting::spec::ValueKind::Float { min, max } = spec.kind else { + continue; + }; + // 取一个落在 [min, max] 内的值(0.5 是多数区间的好选择,落不进时取中点)。 + let target = if (min..=max).contains(&0.5) { + 0.5 + } else { + (min + max) / 2.0 + }; + set(&env, spec.key, &format!("{target}")) + .unwrap_or_else(|error| panic!("Float 键 {} 写不回:{error}", spec.key)); + let reread = env + .store() + .doc() + .effective_f64(spec.key) + .unwrap_or_else(|| panic!("Float 键 {} 写完后读不到", spec.key)); + assert!( + (reread - target).abs() < 1e-9, + "Float 键 {} 写 {target} 读回 {reread}", + spec.key + ); + checked += 1; + } + + assert!( + checked >= 3, + "schema 里 Float 键只剩 {checked} 个:本断言要求 ≥3(window_opacity / panel_alpha / bg_dim / ui.scale)。\ + 若是有意删减,请同步更新这条断言" + ); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index a29d8c8..d2224ad 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -312,93 +312,4 @@ pub mod keys { } #[cfg(test)] -mod tests { - use super::{ - keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode, - UiPrefs, WINDOW_ALPHA_MIN, - }; - - #[test] - fn defaults_match_d16() { - let prefs = UiPrefs::default(); - assert_eq!(prefs.tile_size, TileSize::Medium); - assert_eq!(prefs.tile_size.columns(), 4); - assert_eq!(prefs.instance_view, InstanceView::Tiles); - assert_eq!(prefs.theme, ThemeMode::Auto); - assert_eq!(prefs.theme.as_str(), "auto"); - assert!(!prefs.nav_collapsed); - assert!(!prefs.ai_collapsed); - } - - #[test] - fn tile_size_columns_follow_q24() { - assert_eq!(TileSize::Small.columns(), 6); - assert_eq!(TileSize::Medium.columns(), 4); - assert_eq!(TileSize::Large.columns(), 3); - } - - #[test] - fn theme_mode_round_trips_through_key_values() { - for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] { - assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode); - } - // 坏值兜底 - assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto); - } - - #[test] - fn pref_keys_are_unique() { - let mut keys = keys::ALL.to_vec(); - let total = keys.len(); - keys.sort_unstable(); - keys.dedup(); - assert_eq!(keys.len(), total); - } - - fn base_of(raw: serde_json::Value) -> AppearanceBase { - let schema = unlucky_core::setting::keys::launcher_schema(); - let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw); - AppearanceBase::from_document(&doc) - } - - #[test] - fn appearance_base_missing_keys_use_factory() { - let base = base_of(serde_json::json!({})); - assert_eq!( - base, - AppearanceBase::factory(), - "缺键 → 出厂态(不透明 / auto / preset)" - ); - assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α"); - } - - #[test] - fn appearance_base_clamps_out_of_range_opacity() { - for raw in [0.0, 0.02, -1.0] { - let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } })); - assert_eq!( - base.window_opacity, WINDOW_ALPHA_MIN as f32, - "raw={raw} 应夹到下限" - ); - } - for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] { - let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } })); - assert_eq!(base.window_opacity, want, "raw={raw}"); - } - } - - #[test] - fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() { - let bad = base_of(serde_json::json!({ - "appearance": { "titlebar_mode": "allways", "palette_source": "desktop" } - })); - assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto"); - assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset"); - // 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。 - let good = base_of(serde_json::json!({ - "appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" } - })); - assert_eq!(good.titlebar_mode, TitlebarMode::Never); - assert_eq!(good.palette_source, PaletteSource::Wallpaper); - } -} +mod tests; diff --git a/crates/presenter/src/prefs/tests.rs b/crates/presenter/src/prefs/tests.rs new file mode 100644 index 0000000..1a8b2f2 --- /dev/null +++ b/crates/presenter/src/prefs/tests.rs @@ -0,0 +1,90 @@ +//! `prefs` 的单测(拆文件守 400 行上限;结构体与 re-export 一律留在 `prefs.rs`)。 + +use super::{ + keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode, UiPrefs, + WINDOW_ALPHA_MIN, +}; + +#[test] +fn defaults_match_d16() { + let prefs = UiPrefs::default(); + assert_eq!(prefs.tile_size, TileSize::Medium); + assert_eq!(prefs.tile_size.columns(), 4); + assert_eq!(prefs.instance_view, InstanceView::Tiles); + assert_eq!(prefs.theme, ThemeMode::Auto); + assert_eq!(prefs.theme.as_str(), "auto"); + assert!(!prefs.nav_collapsed); + assert!(!prefs.ai_collapsed); +} + +#[test] +fn tile_size_columns_follow_q24() { + assert_eq!(TileSize::Small.columns(), 6); + assert_eq!(TileSize::Medium.columns(), 4); + assert_eq!(TileSize::Large.columns(), 3); +} + +#[test] +fn theme_mode_round_trips_through_key_values() { + for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] { + assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode); + } + // 坏值兜底 + assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto); +} + +#[test] +fn pref_keys_are_unique() { + let mut keys = keys::ALL.to_vec(); + let total = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), total); +} + +fn base_of(raw: serde_json::Value) -> AppearanceBase { + let schema = unlucky_core::setting::keys::launcher_schema(); + let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw); + AppearanceBase::from_document(&doc) +} + +#[test] +fn appearance_base_missing_keys_use_factory() { + let base = base_of(serde_json::json!({})); + assert_eq!( + base, + AppearanceBase::factory(), + "缺键 → 出厂态(不透明 / auto / preset)" + ); + assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α"); +} + +#[test] +fn appearance_base_clamps_out_of_range_opacity() { + for raw in [0.0, 0.02, -1.0] { + let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } })); + assert_eq!( + base.window_opacity, WINDOW_ALPHA_MIN as f32, + "raw={raw} 应夹到下限" + ); + } + for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] { + let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } })); + assert_eq!(base.window_opacity, want, "raw={raw}"); + } +} + +#[test] +fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() { + let bad = base_of(serde_json::json!({ + "appearance": { "titlebar_mode": "allways", "palette_source": "desktop" } + })); + assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto"); + assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset"); + // 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。 + let good = base_of(serde_json::json!({ + "appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" } + })); + assert_eq!(good.titlebar_mode, TitlebarMode::Never); + assert_eq!(good.palette_source, PaletteSource::Wallpaper); +}