Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/alpha.rs
T
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00

236 lines
10 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.
//! 两层透明度的**判据层**:整窗 α / 面板 α 的钳位链、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;
/// 面板 α 下限(core 的 `PANEL_ALPHA_MIN` = 0.4,经 `unlucky_presenter::prefs` 重导出)。
pub const PANEL_ALPHA_FLOOR: f32 = unlucky_presenter::prefs::PANEL_ALPHA_MIN as f32;
/// 整窗 α 滑块位置(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(= 整窗穿透)或把面板下限降到「前景压不住背景图」。
// 两个下限都来自 `unlucky_presenter::prefs` 的重导出(M93 r2 起不再有本地字面量),
// 所以这条同时钉住「presenter 的重导出没被改坏」。
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);
}
}