feat(appearance): M91 —— 外观下层三键 + win32 整窗 α FFI + unsafe 落点门禁

core(D36/D37/D38 的键与范围)
- appearance.window_opacity(0.08..=1.0,默认 1.0,越界钳位且不报错;下限 D36 定案)
- appearance.titlebar_mode(auto|always|never,默认 auto;判定逻辑不在本 mission)
- appearance.palette_source(background-image|wallpaper|preset,与 design 的 Level::code() 同名)
- schema 1.3.0 → 1.4.0:纯增量、不加迁移步骤(新键出厂值 = 老文件缺键)
- appearance 拆成目录 mod/modes/tests(单文件 ≤400 行),非法枚举由 diagnostics() 给可读回落说明

presenter(只读语义,不破 M89 零 UI 门禁)
- AppearanceBase { window_opacity, titlebar_mode, palette_source } + from_document()
- 单测:缺键→出厂值、越界→钳位、非法枚举→回落且合法值照读

app-iced(D36 唯一 unsafe 落点)
- platform::effective_window_alpha(backend, requested) 纯函数:≤0/非有限 → None(拒绝整窗穿透),
  其余夹到 0.08..=1.0;CPU 档同样允许窗口级 α(D36 对 D27 的修订),4 组真值表测试
- platform/win32/transparency.rs:SetLayeredWindowAttributes(LWA_ALPHA) 的 FFI +
  alpha_byte 量化 + apply_window_alpha;非 Windows 同签名空实现返回 Unsupported

CI
- guards.yml 新增 unsafe-scope job:全仓 allow(unsafe_code) 文件集合必须恰好等于
  crates/app-iced/src/platform/win32/transparency.rs(集合相等,不是黑名单命中)

版本号绊线
- isolation_keys / java_install_keys:字面量 1.3.0 → 1.4.0(绊线保留字面量)
- store / document 的两个关系夹具改成相对 launcher_version() 构造(「更高 minor 只读」/
  「同 minor 更高 patch 保留 $schema」),下次 bump 不再假绿或假红
This commit is contained in:
AskaEth committed 2026-11-02 13:58:33 +08:00
1 parent 006eb6869a
commit 7406785325
15 files changed
+902 -18

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
//! 平台相关的窗口能力(P4 / D36):整窗透明度的**判据**与唯一 `unsafe` 落点分居两处。
//!
//! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。
//!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
//! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示
//! 适配器)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 + 没测试的观感」这条
//! 诚实的分界。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend;
pub mod win32;
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
///
/// - `requested <= 0.0`(含 `-0.0`)→ `None`:**拒绝整窗穿透**。α = 0 表示窗口内容全透明,
/// 用户会以为程序消失/卡死,所以这不是「合法的最小值」而是「不给设」(D36)。
/// 非有限值(`NaN` / `±∞`)同样 `None`——它们没有合法落点,也不该被 `clamp` 悄悄吞掉。
/// - 其余一律夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`。
/// - 后端**不**参与「能不能用窗口级 α」的判断:D36 **修订**了 D27 的旧结论——
/// CPU 渲染档同样允许整窗 α(CPU 档被限制的是面板 α / 玻璃 / 模糊,不是窗口 α)。
/// 因此三个后端分支给出同一个值;写成 `match` 是为了让「将来真分档」只改这一处,
/// 而不是让读代码的人以为这里漏了一个分支。
#[must_use]
pub fn effective_window_alpha(backend: Backend, requested: f64) -> Option<f64> {
if !requested.is_finite() || requested <= 0.0 {
return None;
}
match backend {
Backend::Gpu | Backend::Cpu | Backend::Auto => Some(requested.clamp(WINDOW_ALPHA_MIN, 1.0)),
}
}
#[cfg(test)]
mod tests {
use super::effective_window_alpha;
use crate::Backend;
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
const BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto];
#[test]
fn truth_table_valid_alpha_passes_through_every_backend() {
// GPU / CPU × 合法值:四个格子都要 Some(CPU 档不再强制不透明)。
for backend in BACKENDS {
assert_eq!(
effective_window_alpha(backend, 0.5),
Some(0.5),
"{backend:?}:合法 α 原样返回"
);
assert_eq!(effective_window_alpha(backend, 1.0), Some(1.0));
assert_eq!(
effective_window_alpha(backend, WINDOW_ALPHA_MIN),
Some(WINDOW_ALPHA_MIN)
);
}
}
#[test]
fn truth_table_out_of_range_is_clamped_not_refused() {
for backend in BACKENDS {
assert_eq!(
effective_window_alpha(backend, 0.001),
Some(WINDOW_ALPHA_MIN),
"{backend:?}:正数低于下限 → 夹到 0.08(钳位,不拒绝)"
);
assert_eq!(
effective_window_alpha(backend, 9.0),
Some(1.0),
"{backend:?}:高于 1.0 → 夹到 1.0"
);
}
}
#[test]
fn truth_table_non_positive_is_refused_on_every_backend() {
for backend in BACKENDS {
for requested in [0.0, -0.0, -0.01, -1.0] {
assert_eq!(
effective_window_alpha(backend, requested),
None,
"{backend:?}:requested={requested} → 拒绝整窗穿透"
);
}
}
}
#[test]
fn truth_table_non_finite_is_refused() {
// NaN 用 `<= 0.0` 判不出来(比较恒 false),所以必须先 `is_finite()`;
// 否则 NaN 会穿到 `clamp` 里再原样返回,变成「给窗口一个 NaN」。
for backend in BACKENDS {
for requested in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(
effective_window_alpha(backend, requested),
None,
"{backend:?}:requested={requested} 不是有限值"
);
}
}
}
#[test]
fn cpu_tier_is_not_forced_opaque() {
// D36 对 D27 的**修订**:旧文档写「CPU 档一律不透明」,这条测试钉住修订后的行为,
// 防止谁照着旧文档把 CPU 分支改回 `None`。
assert_eq!(effective_window_alpha(Backend::Cpu, 0.5), Some(0.5));
assert_eq!(effective_window_alpha(Backend::Gpu, 0.5), Some(0.5));
}
}