Files
Unlucky-Launcher/crates/app-iced/src/platform/mod.rs
T
AskaEth b1c09c4c91 feat(app-iced): 页面切换按导航深度定向 + 模态缩放淡入(M256 MOTION1)
- transition.rs:唯一一张动效表。同级换页走水平(±12 / 旧页 ∓8)、进深层走纵向
  (新页 +16 上浮 / 旧页 -8),进腿 medium2/medium1(300/250),退腿 short4→medium1(更短),
  进用 Standard、退用 StandardAccelerate。幅度全部取自 Space 令牌。
- pump.rs:Slot 三互斥槽 → Kind(Boot/Peer{forward}/Push/Pop)+ 两腿轨道;新增模态状态机
  (Opening/Open/Closing,展开后不占帧,退场走完交回一次 take_modal_closed)。
- components/motion/zoom.rs:绕中心缩放的自绘控件(renderer.with_transformation,
  tiny-skia 与 wgpu 都实现了,文字也真的跟着缩放);scale==1.0 时不套变换 ⇒ 出图基线不变。
- pages/mod.rs:page_with_settings_transition(ui, current, previous, …) 叠
  stack![opaque(旧页@退场位移), 新页@进场位移];旧函数只剩一句委托(blast radius 不变)。
- App.previous_view:换页时用 std::mem::replace **移动**旧模型(冻结快照),过渡结束当拍
  置 None(不随换页次数增长)。
- 模态:逻辑状态当拍关(state.dialogs 立刻空),画面多留 150ms;退场期间弹窗消息被闸门吞掉。
- platform/motion.rs:系统「减少动效」三态探针(Windows=SystemParametersInfoW,
  Linux/macOS=子进程+300ms 超时),OnceLock 缓存;Unknown **不降级**(探针失败不静默关动效)。
- MotionScale::HALF 语义核清并写进 design 文档:是**时长减半**(更快),不是半速。
2026-11-12 10:50:02 +08:00

200 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.
//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196 / M256):整窗透明度与窗口圆角的
//! **判据**、三处 `unsafe` 落点、系统语言真值源、系统「减少动效」偏好、以及数据根派生——每一件
//! 各归一处(判据不碰 FFI,FFI 只住落点文件)。
//!
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`] / [`plan_corner_for`](M116 / M182):
//! 用户策略 × 宿主平台 × 渲染档 → 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明;
//! - [`win32::transparency`] = 整窗 α 的 FFI 落点(补 `WS_EX_LAYERED` +
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
//! - [`win32::console`] = M196 的控制台附着落点(`AttachConsole`):GUI 子系统进程的输出
//! 为什么在 cmd 里会静默丢失,以及唯一的补救;
//! - [`motion`] = M256:系统「减少动效」偏好的**三态**探针(Windows 走
//! `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)`,Linux/macOS 走子进程 + 超时)。
//! 三处落点的放行范围各自收在一个文件里,`unsafe-scope` 门禁的期望集合就是这三个路径
//! (集合相等,不是黑名单);
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
//!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
//! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示
//! 适配器、无 Mac 真机)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 +
//! 没测试的观感」这条诚实的分界。
//!
//! ## 平台分册(M182:非 Windows 不再是「未知」一格)
//!
//! | 模块 | 里面有什么 | 有没有 FFI |
//! |---|---|---|
//! | [`data`] | 数据根派生(字体缓存分区 + 一条设置键的生效值,M168) | 没有 |
//! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 |
//! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) |
//! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) |
//! | [`motion`] | 系统「减少动效」三态探针(M256;缓存 + 子进程超时) | **有**(仅 Windows 一支;Linux/macOS 走子进程) |
//! | [`win32`] | Windows 的实现与探针(DWM / 分层 / 控制台附着) | **有**(两处落点:`transparency` + `console`) |
//!
//! 两个新平台模块**只有文档、没有代码**:M182 的裁决是不实现(macOS 要动手就得先迁移原生标题栏,
//! 那会引入两套窗口控件,必须连着撤掉自绘三键 + 真机验收一起做)。把结论写在这里而不是写进报告,
//! 是因为报告会随会话消失,而下一轮动这块的人一定先读代码。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend;
pub mod data;
pub mod host;
pub mod linux;
pub mod locale;
pub mod macos;
pub mod motion;
pub mod win32;
/// 窗口圆角的**判定**(M116 / M182)住在 `win32::corner`:判据是用户策略 × 宿主平台 × 渲染档。
/// 非 Windows 的两档(macOS = 合成器不裁 borderless 窗、Linux = CSD 没人裁)在
/// [`macos`] / [`linux`] 两个模块里逐条给了证据。
pub use host::HostOs;
pub use win32::corner::{plan_corner, plan_corner_for, CornerExclusion, CornerPlan, CornerSource};
/// 本机 Windows 版本(`None` = 非 Windows / 读不到)。**进程内只探测一次**。
///
/// 为什么缓存:底层是 `reg.exe` 子进程(`unlucky_core::platform::windows_version`,实测约 50 ms),
/// 而圆角判定会在启动、切档、热重载时各问一次。探测结果在进程生命周期内不会变。
///
/// 为什么走 `unlucky_presenter::prefs` 的重导出而不是 `unlucky_core` 直连:
/// `unlucky-app-iced` 不直接依赖 core(分层:UI → presenter → core),这是既有的唯一通道。
#[must_use]
pub fn current_windows_version() -> Option<unlucky_presenter::prefs::WindowsVersion> {
static CACHED: std::sync::OnceLock<Option<unlucky_presenter::prefs::WindowsVersion>> =
std::sync::OnceLock::new();
*CACHED.get_or_init(unlucky_presenter::prefs::probe_windows_version)
}
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
///
/// - `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));
}
/// `appearance.palette_source` 的码值必须与 `design` 的 `Level::code()` 逐字一致。
///
/// 为什么这条断言住在这里:`unlucky-core` 按分层纪律不依赖 `unlucky-design`,
/// 它的测试**看不到** design,所以 core 自己只能钉字面量。本 crate 同时依赖
/// `unlucky-design`(`Level`)与 `unlucky-presenter`(重导出 `PaletteSource`),
/// 是全仓唯一能真做这次对照的地方。
///
/// 抓得住:design 侧改任一档的稳定码(例如把 `wallpaper` 改成 `desktop`)、或有人改了
/// core 的字符串表——两个方向都红(下面往返那条覆盖「只改一侧」)。
/// 抓不住:档位的**语义**是否仍对齐(只保证字符串一致),以及 UI 是否真按这个码值取色。
#[test]
fn palette_source_codes_match_design_level_codes() {
use unlucky_design::palette::Level;
use unlucky_presenter::prefs::PaletteSource;
for (source, level) in [
(PaletteSource::BackgroundImage, Level::BackgroundImage),
(PaletteSource::Wallpaper, Level::Wallpaper),
(PaletteSource::Preset, Level::Preset),
] {
assert_eq!(
source.as_str(),
level.code(),
"core 的 {source:?} 码值与 design 的 {level:?} 不一致"
);
// 反方向:design 的码值喂回 core 必须还原成同一档(防「两个字符串互换了」)。
assert_eq!(PaletteSource::from_key_value(level.code()), source);
// 且必须真的在 core 的允许值表里(否则 schema 会拒绝写这个值)。
assert!(unlucky_presenter::prefs::PALETTE_SOURCES.contains(&level.code()));
}
}
}