Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/picture.rs
T
AskaEth da2da4cd9b 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)。
2026-11-02 18:46:43 +08:00

114 lines
4.5 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.
//! 背景层的**像素**:真图直出(`Cover` / `Contain` / `Tile`)+ 暗化叠层。
//!
//! 与 [`super::section`] 拆开只为单文件 ≤400 行的纪律;对外入口仍是
//! `pages::appearance::layer`(本文件 [`layer`] 的再导出)。
//!
//! ## 三种填充
//!
//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做);`Tile` 用
//! [`background::tiles`] 算行列后铺 N 个图块(单块尺寸 = 原尺寸 × `tiles.scale`)。
//!
//! 暗化不是逐像素改色,而是**盖一层「版面底色 @ α=dim」**:图不透明时它与「向底色插值」
//! 数学等价([`background::dim_alpha`] 有推导),而普通 alpha 混合 CPU/GPU 两档都支持。
use iced::widget::image;
use iced::widget::{column, container, image as image_widget, space};
use iced::{ContentFit, Element, Length};
use unlucky_design::color::Role;
use unlucky_presenter::prefs::BgFill;
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::background;
use super::state::State;
/// 背景层(铺满整窗、垫在最下层)。`None` = 没有可用的背景图(调用方就不叠这一层)。
///
/// 真图直出(M93 r2):`Cover` / `Contain` 交给 iced 的 `ContentFit`,`Tile` 用
/// [`background::tiles`] 算网格铺 N 个图块。暗化 = 一层「版面底色 @ α=dim」盖在图上
/// (见 [`background::dim_alpha`] 的等价性说明)。
#[must_use]
pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option<Element<'static, Message>> {
let picture = state.image()?;
if !state.bg_enabled {
return None;
}
let handle = image_handle(state.bg_path.trim());
let art: Element<'static, Message> = match state.bg_fill {
BgFill::Cover => fitted(handle, ContentFit::Cover),
BgFill::Contain => fitted(handle, ContentFit::Contain),
BgFill::Tile => tiled(handle, picture.source, viewport),
};
// 暗化:底色 @ α=dim 盖在图上(等价于逐像素向底色插值,见 `background::dim_alpha`)。
let scrim_color = ui
.color(Role::Surface)
.scale_alpha(background::dim_alpha(state.bg_dim));
let scrim: Element<'static, Message> = container(space::horizontal())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| frame::filled(scrim_color))
.into();
Some(
iced::widget::stack![art, scrim]
.width(Length::Fill)
.height(Length::Fill)
.into(),
)
}
/// 文件字节 → iced 的图片句柄(渲染器自己解复用,不再读盘)。
fn image_handle(path: &str) -> image::Handle {
image::Handle::from_path(path)
}
/// `Cover` / `Contain`:交给渲染器的等比缩放档。
fn fitted(handle: image::Handle, fit: ContentFit) -> Element<'static, Message> {
image_widget(handle)
.content_fit(fit)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// `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) = 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::Fill)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.into()
})
.collect::<Vec<_>>();
iced::widget::row(cells)
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
})
.collect::<Vec<_>>();
column(rows).width(Length::Fill).height(Length::Fill).into()
}