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

@@ -29,6 +29,12 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
pub const LWA_ALPHA: u32 = 0x0000_0002;
/// `WS_EX_LAYERED`:分层窗口扩展样式(`0x0008_0000`)。**整窗 α 的调用前提**。
pub const WS_EX_LAYERED: isize = 0x0008_0000;
/// `GWL_EXSTYLE`:扩展样式在 `Get/SetWindowLongPtrW` 里的槽位号(`-20`)。
pub const GWL_EXSTYLE: i32 = -20;
/// 一次整窗 α 应用的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaOutcome {
@@ -77,11 +83,96 @@ pub fn alpha_byte(alpha: f64) -> u8 {
(clamped * 255.0).ceil() as u8
}
/// 给窗口补上 `WS_EX_LAYERED`(幂等)。
///
/// ## 为什么这件事必须在这里(而不是「窗口创建侧」)
///
/// 加扩展样式只有 `GetWindowLongPtrW` / `SetWindowLongPtrW` 一条路,都是 `unsafe`;而
/// [`super::super`] 的 CI 门禁(`.github/workflows/guards.yml` 的 `unsafe-scope`)要求
/// 全仓显式放行 `unsafe_code` 的文件集合**恰好等于本文件**。所以窗口创建侧的
/// `app/boot.rs` 只负责「拿句柄 → 调本函数 → 调 [`apply_window_alpha`]」,
/// 不新增第二个放行文件。
///
/// ## 为什么创建侧无法预先带上这一位
///
/// iced 0.14 的 `window::Settings` 没有扩展样式入口;winit 0.30 只在
/// `IGNORE_CURSOR_EVENT`(鼠标穿透,`WS_EX_TRANSPARENT | WS_EX_LAYERED`)时置位,
/// `transparent: true` 走的是 DWM 那条路(`window.rs` 的 `ENABLE BLUR BEHIND WINDOW`),
/// 与 `LWA_ALPHA` 无关。所以只能拿到句柄后补。
///
/// ## 返回值
///
/// `true` = 这一位**已就位**(原本就有,或本次加上了)。判据是**再加读一次**验证,
/// 而不是信 `SetWindowLongPtrW` 的返回值:Win32 的它「失败返回 0 且成功也可能返回 0」
/// (前值就是 0 时),只能靠回读取证。句柄为 0 / 无效 → `false`(`GetWindowLongPtrW`
/// 也读不到这一位)。
pub fn ensure_layered(hwnd: isize) -> bool {
if hwnd == 0 {
return false;
}
if has_layered(hwnd) {
return true;
}
raw_set_ex_style(hwnd, raw_ex_style(hwnd) | WS_EX_LAYERED);
has_layered(hwnd)
}
/// 读扩展样式(跨位宽的唯一落点:64 位走 `GetWindowLongPtrW`,32 位走 `GetWindowLongW`)。
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
fn raw_ex_style(hwnd: isize) -> isize {
// SAFETY: 只读一个窗口属性,按值传参、不持有指针、无所有权转移;无效句柄只会返回 0。
unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }
}
/// 32 位目标:符号是 `GetWindowLongW`(`link_name` 已映射),返回 `i32`,扩到 `isize`。
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
fn raw_ex_style(hwnd: isize) -> isize {
// SAFETY: 同 64 位分支。
unsafe { isize::from(GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) }
}
/// 非 Windows:没有扩展样式这个概念,读 0(`ensure_layered` 因此恒 `false`)。
#[cfg(not(target_os = "windows"))]
fn raw_ex_style(_hwnd: isize) -> isize {
0
}
/// 写扩展样式(位宽分档同上)。
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
fn raw_set_ex_style(hwnd: isize, style: isize) {
// SAFETY: 同 `raw_ex_style`:按值传参、不持有指针;返回值刻意不用(改完靠回读取证)。
unsafe {
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
}
}
/// 32 位目标:符号是 `SetWindowLongW`;扩展样式本身就是 32 位量,收窄无损。
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
fn raw_set_ex_style(hwnd: isize, style: isize) {
let style = i32::try_from(style).unwrap_or(i32::MAX);
// SAFETY: 同 64 位分支。
unsafe {
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
}
}
/// 非 Windows:空实现。
#[cfg(not(target_os = "windows"))]
fn raw_set_ex_style(_hwnd: isize, _style: isize) {}
/// 回读判据:这一位在不在扩展样式里。
fn has_layered(hwnd: isize) -> bool {
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
}
/// 把整窗 α 下发到窗口。
///
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
///
/// 调用前提是窗口已带 `WS_EX_LAYERED`(见 [`ensure_layered`]):没有它 `SetLayeredWindowAttributes`
/// 一定失败(Win32 返回 0,本机实测 `GetLastError` 常见 87 / `ERROR_INVALID_PARAMETER`)。
pub fn apply_window_alpha(hwnd: isize, alpha: Option<f64>) -> AlphaOutcome {
let Some(alpha) = alpha.filter(|value| value.is_finite() && *value > 0.0) else {
return AlphaOutcome::Rejected(AlphaRejection::NoAlpha);
@@ -102,6 +193,26 @@ extern "system" {
///
/// 签名照 Win32 头文件:`BOOL SetLayeredWindowAttributes(HWND, COLORREF, BYTE, DWORD)`。
fn SetLayeredWindowAttributes(hwnd: isize, color_key: u32, alpha: u8, flags: u32) -> i32;
/// `GetWindowLongPtrW`:读窗口属性(`GWL_EXSTYLE` = `-20`)。
///
/// 头文件里 `GetWindowLongPtrW` 是宏:64 位映射到 `GetWindowLongPtrW`、32 位映射到
/// `GetWindowLongW`。user32 在 32 位导出里**没有** `...PtrW`,所以这里按指针宽度分档声明,
/// 否则 i686 目标会在链接期失败(本仓的 Win7 冒烟是 x64,但编译不该只在一个位宽上成立)。
#[cfg(target_pointer_width = "64")]
fn GetWindowLongPtrW(hwnd: isize, index: i32) -> isize;
/// `SetWindowLongPtrW`:写窗口属性(同上,32 位映射到 `SetWindowLongW`)。
#[cfg(target_pointer_width = "64")]
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: isize) -> isize;
/// 32 位目标的实际符号(见上)。
#[cfg(target_pointer_width = "32")]
#[link_name = "GetWindowLongW"]
fn GetWindowLongPtrW(hwnd: isize, index: i32) -> i32;
/// 32 位目标的实际符号(见上)。
#[cfg(target_pointer_width = "32")]
#[link_name = "SetWindowLongW"]
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: i32) -> i32;
}
#[cfg(target_os = "windows")]
@@ -127,9 +238,28 @@ fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
#[cfg(test)]
mod tests {
use super::{alpha_byte, apply_window_alpha, AlphaOutcome, AlphaRejection, LWA_ALPHA};
use super::{
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
LWA_ALPHA, WS_EX_LAYERED,
};
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
#[test]
fn layered_constants_match_the_win32_header() {
// 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。
// 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。
assert_eq!(WS_EX_LAYERED, 0x0008_0000);
assert_eq!(GWL_EXSTYLE, -20);
}
#[test]
fn ensure_layered_refuses_a_missing_or_invalid_window() {
// 句柄 0 = 还没拿到窗口:不许「假设成功」。
assert!(!ensure_layered(0), "句柄 0 必须返回 false");
// 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。
assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)");
}
#[test]
fn alpha_byte_quantizes_upward_never_below_the_floor() {
assert_eq!(alpha_byte(1.0), 255);