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:
1 parent
006eb6869a
commit
7406785325
15 files changed
+902
-18
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Win32 专属的窗口能力。
|
||||
//!
|
||||
//! 目前只有一个 [`transparency`]:D36 定案的整窗 α 唯一实现落点。
|
||||
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
|
||||
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
|
||||
//!
|
||||
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
|
||||
//! 所以调用点不需要 `#[cfg]`,行为(返回「本平台不支持」)可被测试断言。
|
||||
|
||||
pub mod transparency;
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 整窗透明度(D36):**全仓唯一**允许 `unsafe` 的文件。
|
||||
//!
|
||||
//! ## 为什么只有这里有 unsafe
|
||||
//!
|
||||
//! 窗口级 α 没有任何「安全」的 Rust API 可达:`SetLayeredWindowAttributes` 是 Win32 的
|
||||
//! 唯一路径(D36 逐路核实过 wgpu / iced / winit 都没有代理入口)。所以把**整个仓库**的
|
||||
//! FFI 面收在这一处,其余一层([`super::super::effective_window_alpha`])保持纯函数。
|
||||
//! 守门在 `.github/workflows/guards.yml` 的 `unsafe-scope` job:全仓带这个 allow 的文件集合
|
||||
//! 必须**恰好等于**本文件的路径(集合相等,不是黑名单命中)。
|
||||
//!
|
||||
//! ## 只做 LWA_ALPHA
|
||||
//!
|
||||
//! `LWA_COLORKEY` / `DwmEnableBlurBehindWindow` 一律不用:色键会把某个颜色整块变透明(视觉
|
||||
//! 事故),模糊是另一条路线(D36 已排除)。`WS_EX_LAYERED` 是调用前提,由窗口创建侧设置,
|
||||
//! 本文件不负责——它只做「给定句柄,把 α 下发下去」。
|
||||
//!
|
||||
//! ## 非 Windows
|
||||
//!
|
||||
//! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`,
|
||||
//! 而「这个平台不支持」这件事本身可以被断言。
|
||||
|
||||
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
|
||||
///
|
||||
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
|
||||
pub const LWA_ALPHA: u32 = 0x0000_0002;
|
||||
|
||||
/// 一次整窗 α 应用的结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AlphaOutcome {
|
||||
/// α ≥ 1.0:完全不透明,**不调 FFI**(也就不需要分层窗口)。
|
||||
Opaque,
|
||||
/// 已下发,`byte` 是 Win32 收到的 `0..=255`。
|
||||
Applied {
|
||||
/// 量化后的字节值。
|
||||
byte: u8,
|
||||
},
|
||||
/// 拒绝执行。
|
||||
Rejected(AlphaRejection),
|
||||
/// 非 Windows 平台:空实现,什么都没做。
|
||||
Unsupported,
|
||||
/// FFI 自己失败(如句柄无效),`code` 来自 `GetLastError`。
|
||||
Failed {
|
||||
/// Win32 错误码(`1400` = `ERROR_INVALID_WINDOW_HANDLE`)。
|
||||
code: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// [`AlphaOutcome::Rejected`] 的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AlphaRejection {
|
||||
/// 没有可用的 α(`None` / 非正 / 非有限)——整窗穿透的拒绝在这里拦住。
|
||||
NoAlpha,
|
||||
/// 句柄为 0:还没拿到窗口,或窗口已销毁。
|
||||
NoWindow,
|
||||
}
|
||||
|
||||
/// α → Win32 的 `bAlpha`(`0..=255`)。
|
||||
///
|
||||
/// 输入先夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`(NaN 取下限):调用方即使漏了
|
||||
/// [`super::super::effective_window_alpha`] 的过滤,也不会把窗口写成穿透。
|
||||
#[must_use]
|
||||
pub fn alpha_byte(alpha: f64) -> u8 {
|
||||
let clamped = if alpha.is_nan() {
|
||||
WINDOW_ALPHA_MIN
|
||||
} else {
|
||||
alpha.clamp(WINDOW_ALPHA_MIN, 1.0)
|
||||
};
|
||||
(clamped * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// 把整窗 α 下发到窗口。
|
||||
///
|
||||
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
|
||||
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
|
||||
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
|
||||
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);
|
||||
};
|
||||
if alpha >= 1.0 {
|
||||
return AlphaOutcome::Opaque;
|
||||
}
|
||||
if hwnd == 0 {
|
||||
return AlphaOutcome::Rejected(AlphaRejection::NoWindow);
|
||||
}
|
||||
set_layered(hwnd, alpha_byte(alpha))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
/// `SetLayeredWindowAttributes`:窗口必须已带 `WS_EX_LAYERED`,α 才生效。
|
||||
///
|
||||
/// 签名照 Win32 头文件:`BOOL SetLayeredWindowAttributes(HWND, COLORREF, BYTE, DWORD)`。
|
||||
fn SetLayeredWindowAttributes(hwnd: isize, color_key: u32, alpha: u8, flags: u32) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome {
|
||||
// SAFETY: 四个参数都是按值传入的 POD;`flags` 只带 LWA_ALPHA,于是系统忽略 `color_key`
|
||||
// (传 0 与之无关)。句柄由调用方给出:无效句柄只会让 Win32 返回 0(走下面的 Failed 分支),
|
||||
// 不会造成未定义行为——本调用不持有指针,也没有释放语义。
|
||||
let ok = unsafe { SetLayeredWindowAttributes(hwnd, 0, byte, LWA_ALPHA) };
|
||||
if ok == 0 {
|
||||
let code = std::io::Error::last_os_error()
|
||||
.raw_os_error()
|
||||
.unwrap_or(0)
|
||||
.unsigned_abs();
|
||||
return AlphaOutcome::Failed { code };
|
||||
}
|
||||
AlphaOutcome::Applied { byte }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
|
||||
AlphaOutcome::Unsupported
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{alpha_byte, apply_window_alpha, AlphaOutcome, AlphaRejection, LWA_ALPHA};
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
#[test]
|
||||
fn alpha_byte_quantizes_and_clamps() {
|
||||
assert_eq!(alpha_byte(1.0), 255);
|
||||
assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 四舍五入 128");
|
||||
assert_eq!(
|
||||
alpha_byte(WINDOW_ALPHA_MIN),
|
||||
20,
|
||||
"D36 下限 0.08×255=20.4 → 20(字节值不许是 0)"
|
||||
);
|
||||
// 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。
|
||||
for bad in [0.0, -3.0, f64::NAN] {
|
||||
assert_eq!(
|
||||
alpha_byte(bad),
|
||||
alpha_byte(WINDOW_ALPHA_MIN),
|
||||
"{bad} 应取下限"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
LWA_ALPHA, 0x2,
|
||||
"0x2 就是 LWA_ALPHA;写成 0x1 会变成色键、等于没设 α"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_opaque_alpha_never_touches_the_window() {
|
||||
for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] {
|
||||
assert_eq!(
|
||||
apply_window_alpha(1234, none),
|
||||
AlphaOutcome::Rejected(AlphaRejection::NoAlpha),
|
||||
"{none:?} 应被拒绝(整窗穿透不许发生)"
|
||||
);
|
||||
}
|
||||
// α = 1.0:两个平台都返回 Opaque —— 正好证明「不调 FFI」不是靠 Unsupported 混过去的。
|
||||
assert_eq!(apply_window_alpha(1234, Some(1.0)), AlphaOutcome::Opaque);
|
||||
// 合法 α 但没有句柄:拒绝,绝不下发。
|
||||
assert_eq!(
|
||||
apply_window_alpha(0, Some(0.5)),
|
||||
AlphaOutcome::Rejected(AlphaRejection::NoWindow)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn invalid_handle_reports_a_win32_failure_instead_of_panicking() {
|
||||
// 用 1 号句柄(永远无效)真调一次 API:验的是**返回值语义**,不是观感。
|
||||
match apply_window_alpha(1, Some(0.5)) {
|
||||
AlphaOutcome::Failed { code } => {
|
||||
assert_eq!(code, 1400, "ERROR_INVALID_WINDOW_HANDLE")
|
||||
}
|
||||
other => panic!("无效句柄应报 Failed,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn non_windows_stub_reports_unsupported() {
|
||||
assert_eq!(apply_window_alpha(1, Some(0.5)), AlphaOutcome::Unsupported);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user