feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源

把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。

整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
  (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
  `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
  先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。

面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
  CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。

背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
  iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。

设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
  背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
  bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
  走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
  `window_opacity` / `palette_source`。

取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
  截图通道不读用户配置(D30)。
This commit is contained in:
AskaEth committed 2026-11-02 18:07:31 +08:00
1 parent eae7ae0ff4
commit b8ab46662c
24 files changed
+3119 -48

No files matched your search

@@ -0,0 +1,239 @@
//! 两层透明度的**判据层**:整窗 α / 面板 α 的钳位链、CPU 档降级、滑块映射。纯函数、零 iced。
//!
//! ## 为什么要分成「两层」
//!
//! 用户定案(D34 / D36):
//!
//! - **整窗 α**:整扇窗口(含自绘标题栏、导航轨、所有卡片)作为一个整体乘上 α。Windows 上唯一
//! 实现是 `SetLayeredWindowAttributes(LWA_ALPHA)`,DWM 常数乘法、成本≈0,所以**CPU 档也允许**;
//! - **面板 α**:只有面板/卡片这一层半透明,让背景图透出来。CPU 档**强制 1.0**(D36 对 D27 的
//! 修订:CPU 档被限制的是「多层半透明的合成成本」,不是窗口级常数乘法)。
//!
//! ## 真值表(三层 × 三个后端;`x` = 用户设置值)
//!
//! | 项 | GPU | CPU | auto | 下限 | 上限 | 钳位还是拒绝 |
//! |---|---|---|---|---|---|---|
//! | 整窗 α | `clamp(x, 0.08, 1.0)` | **同左**(D36 修订:CPU 也允许) | 同左 | 0.08 | 1.0 | 钳位;`x ≤ 0` 或非有限值 → **拒绝**(整窗穿透) |
//! | 面板 α | `clamp(x, 0.4, 1.0)` | **恒 1.0** | `clamp(x, 0.4, 1.0)`(auto 是探测后的择优档) | 0.4 | 1.0 | 钳位 |
//! | 玻璃 | `x` | **恒 false** | `x` | — | — | 无(布尔) |
//!
//! 「auto」按**请求值**判(`Backend::Auto` 是用户没指定、让探测决定):它不走 CPU 的强制分支,
//! 因为「强制 1.0」是 CPU 渲染档的降级规则,而探测结果可能是 GPU。真落到 tiny-skia 时
//! `probe` 会写 `ICED_BACKEND=tiny-skia`,但那时 `Options::backend` 仍是 `Auto`——这条已知
//! 张力登记在 [`crate::pages::appearance`] 的偏离表里(要收口得让探测结果回灌 `Backend`)。
//!
//! ## 两条链为什么都在本文件
//!
//! 它们共用一个**滑块映射**与一套钳位纪律,分开写迟早会出现「滑块给 0.0、钳位给 0.08、
//! 日志又打 0.0」这种三处各说一套的事故。整窗 α 的**执行**(FFI)在
//! `crate::platform::win32::transparency`,本文件只给「该下发什么」与「下发成什么字节」。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::platform::effective_window_alpha;
use crate::platform::win32::transparency::alpha_byte;
use crate::Backend;
/// 整窗 α 下限的 `f32` 形态(唯一真源仍是 `unlucky_presenter::prefs::WINDOW_ALPHA_MIN`)。
pub const WINDOW_ALPHA_FLOOR: f32 = WINDOW_ALPHA_MIN as f32;
/// 面板 α 下限(0.4)。
///
/// 与 `unlucky_core::setting::appearance::PANEL_ALPHA_MIN` **同值**,但只能在这里再写一遍:
/// `unlucky-presenter` 没有重导出它(只重导出了 `WINDOW_ALPHA_MIN`),而 `app-iced`
/// 不直接依赖 core。已报 finding,收口方式是让 presenter 重导出。
pub const PANEL_ALPHA_FLOOR: f32 = 0.4;
/// 整窗 α 滑块位置(0..=1)→ 请求值(0.08..=1.0)。
#[must_use]
pub fn window_alpha_from_fraction(fraction: f32) -> f32 {
let fraction = sanitize_fraction(fraction);
WINDOW_ALPHA_FLOOR + fraction * (1.0 - WINDOW_ALPHA_FLOOR)
}
/// 请求值 → 滑块位置([`window_alpha_from_fraction`] 的逆;钳位后落在 0..=1)。
#[must_use]
pub fn window_fraction(alpha: f32) -> f32 {
let alpha = clamp(alpha, WINDOW_ALPHA_FLOOR, 1.0);
(alpha - WINDOW_ALPHA_FLOOR) / (1.0 - WINDOW_ALPHA_FLOOR)
}
/// 面板 α 滑块位置(0..=1)→ 请求值(0.4..=1.0)。
#[must_use]
pub fn panel_alpha_from_fraction(fraction: f32) -> f32 {
let fraction = sanitize_fraction(fraction);
PANEL_ALPHA_FLOOR + fraction * (1.0 - PANEL_ALPHA_FLOOR)
}
/// 请求值 → 滑块位置。
#[must_use]
pub fn panel_fraction(alpha: f32) -> f32 {
let alpha = clamp(alpha, PANEL_ALPHA_FLOOR, 1.0);
(alpha - PANEL_ALPHA_FLOOR) / (1.0 - PANEL_ALPHA_FLOOR)
}
/// 该后端是否**强制**面板不透明(D36:CPU 档面板 α / 玻璃 / 模糊一律 1.0)。
#[must_use]
pub fn panel_is_forced_opaque(backend: Backend) -> bool {
matches!(backend, Backend::Cpu)
}
/// 面板 α 的生效值:CPU 档恒 1.0,其余钳到 `0.4..=1.0`。
#[must_use]
pub fn effective_panel_alpha(backend: Backend, requested: f32) -> f32 {
if panel_is_forced_opaque(backend) {
return 1.0;
}
clamp(requested, PANEL_ALPHA_FLOOR, 1.0)
}
/// 玻璃拟态卡片的生效值:CPU 档恒关(它要的是多层半透明 + 模糊底,那是 D27 的动机)。
#[must_use]
pub fn effective_glass(backend: Backend, requested: bool) -> bool {
requested && !panel_is_forced_opaque(backend)
}
/// 整窗 α 最终**下发到 Win32 的字节**(`None` = 不该下发:`α ≥ 1` 或整窗穿透被拒绝)。
///
/// 这条链是「配置值 → 判据 → 量化字节」的唯一定义点:
/// `effective_window_alpha`(钳位/拒绝)→ [`alpha_byte`](**向上取整**,永不低于下限)。
#[must_use]
pub fn window_alpha_byte(backend: Backend, requested: f32) -> Option<u8> {
effective_window_alpha(backend, f64::from(requested))
.filter(|alpha| *alpha < 1.0)
.map(alpha_byte)
}
fn sanitize_fraction(fraction: f32) -> f32 {
clamp(fraction, 0.0, 1.0)
}
fn clamp(value: f32, min: f32, max: f32) -> f32 {
if value.is_nan() {
min
} else {
value.clamp(min, max)
}
}
#[cfg(test)]
mod tests {
use super::{
effective_glass, effective_panel_alpha, panel_alpha_from_fraction, panel_fraction,
panel_is_forced_opaque, window_alpha_byte, window_alpha_from_fraction, window_fraction,
PANEL_ALPHA_FLOOR, WINDOW_ALPHA_FLOOR,
};
use crate::Backend;
const BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto];
#[test]
fn floor_values_are_the_ones_the_user_decided() {
// 抓得住:有人把下限改回 0(= 整窗穿透)或把面板下限降到「前景压不住背景图」。
// 抓不住:数值本身是否来自令牌(`WINDOW_ALPHA_FLOOR` 由 presenter 重导出,这条能对;
// 面板下限是本地字面量,只能钉 0.4 这个事实)。
assert_eq!(WINDOW_ALPHA_FLOOR, 0.08);
assert_eq!(PANEL_ALPHA_FLOOR, 0.4);
}
/// 任务要求的**钳位链**:配置写 0.8 → 判据放行 0.8 → 下发字节 205。
///
/// 抓得住:①`effective_window_alpha` 把 0.8 原样放行(CPU 档也放行,D36 修订);
/// ②**向上取整**这条纪律(`f32` 的 0.8 扩到 `f64` 是 0.8000000119…,×255 = 204.000003,
/// ceil = **205**;若谁改成 `round` 就会变 204 并在这里红——那正是「实际下发可能低于下限」
/// 的同一个坑);③`α ≥ 1` 时**不下发**(`None`,而不是 255)。
/// 抓不住:这 205 个字节真的被 Win32 收下(FFI 成败由 `transparency` 的单测与本机日志举证)。
#[test]
fn the_clamp_chain_reports_the_byte_that_would_be_sent() {
for backend in BACKENDS {
assert_eq!(
window_alpha_byte(backend, 0.8),
Some(205),
"{backend:?}:0.8 → 205(f32→f64 略大于 0.8,ceil 上去)"
);
assert_eq!(
window_alpha_byte(backend, 0.5),
Some(128),
"{backend:?}:0.5×255=127.5 → 128"
);
assert_eq!(
window_alpha_byte(backend, 1.0),
None,
"{backend:?}:不透明不下发"
);
assert_eq!(
window_alpha_byte(backend, 0.001),
Some(21),
"{backend:?}:低于下限先夹到 0.08,再向上取整 21(0.0824 ≥ 0.08)"
);
assert_eq!(
window_alpha_byte(backend, -1.0),
None,
"{backend:?}:整窗穿透被拒绝,不给字节"
);
assert_eq!(window_alpha_byte(backend, f32::NAN), None);
}
}
/// D36 的核心分界:**CPU 档允许整窗 α,强制面板 1.0**。
///
/// 抓得住:谁把这条写反(CPU 档整窗 α 也强制 1.0,或 CPU 档面板 α 跟着用户值走)。
/// 抓不住:真实 CPU 档下窗口是否真的半透明(观感未验,见报告「没验的部分」)。
#[test]
fn cpu_tier_allows_the_window_alpha_but_forces_the_panel_opaque() {
assert!(
window_alpha_byte(Backend::Cpu, 0.5).is_some(),
"CPU 档允许整窗 α"
);
assert!(panel_is_forced_opaque(Backend::Cpu));
assert_eq!(
effective_panel_alpha(Backend::Cpu, 0.5),
1.0,
"CPU 档面板强制 1.0"
);
assert!(!effective_glass(Backend::Cpu, true), "CPU 档玻璃强制关");
// 另外两档不做这个降级(否则「强制」就失去了意义)。
for backend in [Backend::Gpu, Backend::Auto] {
assert!(!panel_is_forced_opaque(backend), "{backend:?} 不强制");
assert_eq!(effective_panel_alpha(backend, 0.5), 0.5);
assert!(effective_glass(backend, true));
}
}
#[test]
fn panel_alpha_is_clamped_into_range() {
for backend in [Backend::Gpu, Backend::Auto] {
assert_eq!(effective_panel_alpha(backend, 0.0), PANEL_ALPHA_FLOOR);
assert_eq!(effective_panel_alpha(backend, 0.2), PANEL_ALPHA_FLOOR);
assert_eq!(effective_panel_alpha(backend, 3.0), 1.0);
assert_eq!(effective_panel_alpha(backend, f32::NAN), PANEL_ALPHA_FLOOR);
}
}
#[test]
fn slider_fractions_round_trip_without_leaving_the_range() {
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
let alpha = window_alpha_from_fraction(fraction);
assert!(
(WINDOW_ALPHA_FLOOR..=1.0).contains(&alpha),
"{fraction} → {alpha}"
);
assert!((window_fraction(alpha) - fraction).abs() < 1e-6);
}
// 越界输入(滑块自己不会给,但别让下游吃 NaN)
assert_eq!(window_alpha_from_fraction(-1.0), WINDOW_ALPHA_FLOOR);
assert_eq!(window_alpha_from_fraction(9.0), 1.0);
assert_eq!(window_alpha_from_fraction(f32::NAN), WINDOW_ALPHA_FLOOR);
for fraction in [0.0, 0.5, 1.0] {
let alpha = panel_alpha_from_fraction(fraction);
assert!((PANEL_ALPHA_FLOOR..=1.0).contains(&alpha));
assert!((panel_fraction(alpha) - fraction).abs() < 1e-6);
}
// 0.4 与 1.0 是滑块两端:端点必须精确(否则「拖到头还不是 1.0」)
assert_eq!(panel_alpha_from_fraction(1.0), 1.0);
assert_eq!(panel_fraction(1.0), 1.0);
assert_eq!(window_alpha_from_fraction(1.0), 1.0);
assert_eq!(window_fraction(WINDOW_ALPHA_FLOOR), 0.0);
}
}