Merge branch 'feat/p4-appearance-base-keys-titlebar-mode-wi'
This commit is contained in:
commit
27f0d2a551
15 files changed
+1091
-18
No files matched your search
@@ -6,6 +6,7 @@ name: Guards
|
||||
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
|
||||
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
|
||||
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
|
||||
# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -149,6 +150,175 @@ jobs:
|
||||
fi
|
||||
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
||||
|
||||
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
|
||||
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
|
||||
# 判据必须是**集合相等**而不是「黑名单命中」:
|
||||
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
|
||||
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
|
||||
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
|
||||
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
|
||||
unsafe-scope:
|
||||
name: unsafe 落点守门(集合必须恰好一个文件)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
|
||||
run: |
|
||||
set -uo pipefail
|
||||
EXPECTED='crates/app-iced/src/platform/win32/transparency.rs'
|
||||
# 扫描口径(M91 r3):一个小词法器,而不是正则。
|
||||
#
|
||||
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
|
||||
# `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏,
|
||||
# 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到——
|
||||
# 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证:
|
||||
# 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。
|
||||
# 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 /
|
||||
# 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个
|
||||
# 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。
|
||||
#
|
||||
# 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会
|
||||
# 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在
|
||||
# `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。
|
||||
#
|
||||
# 仍然抓不住的(已知边界,别当全覆盖):
|
||||
# - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数);
|
||||
# - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里,
|
||||
# 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性;
|
||||
# - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行;
|
||||
# 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码);
|
||||
# - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml;
|
||||
#
|
||||
# r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(),
|
||||
# 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们):
|
||||
# 红 #[doc = r#"a "b"#] 后跟真 allow
|
||||
# 红 #[cfg_attr(docsrs, doc = r##"a " b "##)] 后跟真 allow
|
||||
# 红 #[cfg_attr(docsrs, my_macro(39 as char))] 一类含字符字面量/转义的属性后跟真 allow
|
||||
# 具体形态:#![cfg_attr(docsrs, my_macro('\''))] 后跟真 allow
|
||||
# 红 以上三者的 CRLF 版本
|
||||
# 绿 #[doc = r#"x" allow(unsafe_code) is banned "y"#](纯文档,不得误报)
|
||||
scan() {
|
||||
awk -v path="$1" '
|
||||
# ---- 唯一的状态机:主循环与属性内**共用**同一个 step() ----
|
||||
# 为什么必须复用:r2 是「属性外一套状态机、属性内另写一套」,两套各自演进,
|
||||
# r3 的属性内就只剩「遇 " 跳到下一个 "」,于是 raw string / 字符字面量在属性内
|
||||
# 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿),
|
||||
# `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。
|
||||
# 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。
|
||||
#
|
||||
# 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。
|
||||
# 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符);
|
||||
# g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0,
|
||||
# 所以它们既不会被当成属性、也不会参与属性内的 [ ] 计数与文本匹配)。
|
||||
function step(s, n, i, c, nx, j, h, ok) {
|
||||
c = substr(s, i, 1)
|
||||
g_plain = 0
|
||||
if (g_state == 1) { if (c == "\n") g_state = 0; return i }
|
||||
if (g_state == 2) {
|
||||
if (c == "/" && substr(s, i + 1, 1) == "*") { g_depth++; return i + 1 }
|
||||
if (c == "*" && substr(s, i + 1, 1) == "/") { g_depth--; if (g_depth <= 0) g_state = 0; return i + 1 }
|
||||
return i
|
||||
}
|
||||
if (g_state == 3) {
|
||||
if (c == "\\") return i + 1
|
||||
if (c == "\"" || c == "\n") g_state = 0
|
||||
return i
|
||||
}
|
||||
if (g_state == 4) {
|
||||
if (c == "\"") {
|
||||
ok = 1
|
||||
for (j = 1; j <= g_hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0
|
||||
if (ok) { g_state = 0; return i + g_hashes }
|
||||
}
|
||||
return i
|
||||
}
|
||||
if (g_state == 5) {
|
||||
if (c == "\\") return i + 1
|
||||
if (c == g_q || c == "\n") g_state = 0
|
||||
return i
|
||||
}
|
||||
if (c == "/" && substr(s, i + 1, 1) == "/") { g_state = 1; return i + 1 }
|
||||
if (c == "/" && substr(s, i + 1, 1) == "*") { g_state = 2; g_depth = 1; return i + 1 }
|
||||
if (c == "\"") { g_state = 3; return i }
|
||||
if (c == g_q) {
|
||||
nx = substr(s, i + 1, 1)
|
||||
if (nx == "\\" || substr(s, i + 2, 1) == g_q) { g_state = 5; return i }
|
||||
g_plain = 1
|
||||
return i
|
||||
}
|
||||
if (c == "r" || c == "b") {
|
||||
j = i + 1
|
||||
if (c == "b") {
|
||||
if (substr(s, j, 1) == "\"") { g_state = 3; return j }
|
||||
if (substr(s, j, 1) == g_q) { g_state = 5; return j }
|
||||
if (substr(s, j, 1) != "r") { g_plain = 1; return i }
|
||||
j++
|
||||
}
|
||||
h = 0
|
||||
while (substr(s, j, 1) == "#") { h++; j++ }
|
||||
if (substr(s, j, 1) == "\"") { g_hashes = h; g_state = 4; return j }
|
||||
g_plain = 1
|
||||
return i
|
||||
}
|
||||
g_plain = 1
|
||||
return i
|
||||
}
|
||||
function lex( s, line, n, i, j, k, ch, adepth, attr) {
|
||||
s = ""
|
||||
while ((getline line < path) > 0) s = s line "\n"
|
||||
close(path)
|
||||
n = length(s)
|
||||
g_state = 0; g_depth = 0; g_hashes = 0; g_found = 0
|
||||
for (i = 1; i <= n; i++) {
|
||||
i = step(s, n, i)
|
||||
if (!g_plain || substr(s, i, 1) != "#") continue
|
||||
j = i + 1
|
||||
if (substr(s, j, 1) == "!") j++
|
||||
if (substr(s, j, 1) != "[") continue
|
||||
# 属性:**复用 step()**,只多一层「[ ] 深度决定何时离开属性」。
|
||||
# 属性里的字符串 / raw 字符串 / 字符字面量因此与属性外同一套识别。
|
||||
attr = ""; adepth = 0; k = j
|
||||
while (k <= n) {
|
||||
k = step(s, n, k)
|
||||
if (g_plain) {
|
||||
ch = substr(s, k, 1)
|
||||
if (ch == "[") adepth++
|
||||
else if (ch == "]") adepth--
|
||||
attr = attr ch
|
||||
if (adepth <= 0) break
|
||||
}
|
||||
k++
|
||||
}
|
||||
if (attr ~ /(allow|expect)\([^]]*unsafe_code/) g_found = 1
|
||||
i = k
|
||||
}
|
||||
return g_found
|
||||
}
|
||||
BEGIN { g_q = "\047"; g_found = 0; exit(lex() ? 0 : 1) }
|
||||
'
|
||||
}
|
||||
# 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,
|
||||
# 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。
|
||||
actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
||||
if scan "$f"; then printf '%s\n' "$f"; fi
|
||||
done | LC_ALL=C sort)
|
||||
echo "期望(恰好这一个):$EXPECTED"
|
||||
echo "实际扫到的集合:"
|
||||
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
||||
if [ "$actual" != "$EXPECTED" ]; then
|
||||
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
|
||||
if [ -n "$extra" ]; then
|
||||
echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
|
||||
printf '%s\n' "$extra" | sed 's/^/ - /'
|
||||
fi
|
||||
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
|
||||
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
|
||||
fi
|
||||
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
|
||||
|
||||
file-size:
|
||||
name: 文件行数 ≤400 守门
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
|
||||
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
|
||||
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
|
||||
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI —— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 |
|
||||
//!
|
||||
//! ## 消费纪律
|
||||
//!
|
||||
@@ -25,6 +26,7 @@
|
||||
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
|
||||
|
||||
pub mod pages;
|
||||
pub mod platform;
|
||||
pub mod shot;
|
||||
pub mod theme;
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 平台相关的窗口能力(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));
|
||||
}
|
||||
|
||||
/// `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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Win32 专属的窗口能力。
|
||||
//!
|
||||
//! 目前只有一个 [`transparency`]:D36 定案的整窗 α 唯一实现落点。
|
||||
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
|
||||
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
|
||||
//!
|
||||
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
|
||||
//! 所以调用点不需要 `#[cfg]`,行为(返回「本平台不支持」)可被测试断言。
|
||||
|
||||
pub mod transparency;
|
||||
@@ -0,0 +1,209 @@
|
||||
//! 整窗透明度(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`] 的过滤,也不会把窗口写成穿透。
|
||||
///
|
||||
/// **向上取整**(`ceil`)是刻意的:`bAlpha/255` 才是实际下发的 α,用 `round` 会让
|
||||
/// `0.08×255 = 20.4 → 20`,实际下发 `20/255 = 0.0784`,反而**低于** D36 的下限。
|
||||
/// 因此下发值可能比请求值略高(最多 1/255 ≈ 0.004),但绝不会低于下限。
|
||||
#[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).ceil() 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_upward_never_below_the_floor() {
|
||||
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),
|
||||
21,
|
||||
"0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限"
|
||||
);
|
||||
// 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。
|
||||
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 applied_alpha_is_never_below_the_floor() {
|
||||
// 真正生效的 α 是 `bAlpha / 255`,不是传进来的 f64。这条把「改用 round」的行为钉死:
|
||||
// round 下 0.08 → 20 → 20/255 = 0.0784 < 0.08 → 这里红。
|
||||
for requested in [WINDOW_ALPHA_MIN, 0.081, 0.1, 0.33, 0.5, 0.77, 0.999, 1.0] {
|
||||
let byte = alpha_byte(requested);
|
||||
let applied = f64::from(byte) / 255.0;
|
||||
assert!(
|
||||
applied >= WINDOW_ALPHA_MIN - 1e-12,
|
||||
"requested={requested} → byte={byte} → 实际 α={applied},低于下限 {WINDOW_ALPHA_MIN}"
|
||||
);
|
||||
assert!(
|
||||
applied <= 1.0,
|
||||
"requested={requested} → 实际 α={applied} 超过 1.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,31 @@
|
||||
//! 设计约束(09 §111):一切高级特性**默认关闭**(出厂 Metro 纯色);全部走 D6 schema
|
||||
//! (AI 可写白名单内,可被 agent 调节)。因此默认值必须等于「什么都没开」的出厂态:
|
||||
//! `panel_alpha = 1.0`、`glass = false`、`bg_image_enabled = false`、`accent` 空串(= 出厂 accent)。
|
||||
//!
|
||||
//! P4 追加的三个「下层键」(M91 / D34·D36·D37·D38,同样是**出厂即可见**的默认值):
|
||||
//!
|
||||
//! | 键 | 取值 | 出厂 | 本模块负责 / 不负责 |
|
||||
//! |---|---|---|---|
|
||||
//! | `appearance.window_opacity` | `0.08..=1.0` | `1.0` | 负责钳位;**不**调 FFI(落点在 `app-iced` 的 win32 层) |
|
||||
//! | `appearance.titlebar_mode` | `auto` / `always` / `never` | `auto` | 只存值;`auto` 的判定属于 UI 侧(WCHROME-IMPL) |
|
||||
//! | `appearance.palette_source` | `background-image` / `wallpaper` / `preset` | `preset` | 只存档位;三级编排在 `unlucky-design`(D38) |
|
||||
//!
|
||||
//! 非法枚举**不报错**但也**不静默**:[`diagnostics`] 给出「读到什么 → 生效什么」的可读说明。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::document::SettingDocument;
|
||||
use super::store::SettingStore;
|
||||
|
||||
mod modes;
|
||||
|
||||
pub use modes::{
|
||||
diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// `appearance.bg_fill` 的允许值(09 §101:cover / contain / tile)。**顺序即 UI 分段控件的顺序**。
|
||||
pub const BG_FILLS: &[&str] = &["cover", "contain", "tile"];
|
||||
|
||||
@@ -26,6 +45,13 @@ pub const BG_DIM_MIN: f64 = 0.0;
|
||||
/// `appearance.bg_dim` 上限:再高背景图基本看不见,等于关掉。
|
||||
pub const BG_DIM_MAX: f64 = 0.95;
|
||||
|
||||
/// `appearance.window_opacity` 下限(D36 定案 0.08):这是**整窗** α 的硬底。
|
||||
///
|
||||
/// 为什么不是 0:α = 0 时窗口内容整体不可见 = 整窗穿透,用户会以为程序卡死/消失了。
|
||||
/// 0.08 是「几乎看不见但仍能点到」的最后一档,比它更低不给设置。面板 α([`PANEL_ALPHA_MIN`])
|
||||
/// 是另一回事,两者互不推导。
|
||||
pub const WINDOW_ALPHA_MIN: f64 = 0.08;
|
||||
|
||||
/// 背景图填充模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum BgFill {
|
||||
@@ -79,6 +105,15 @@ pub struct Appearance {
|
||||
pub glass: bool,
|
||||
/// 自定义强调色 `#RRGGBB`(空串 = 出厂 [`DEFAULT_ACCENT`])。
|
||||
pub accent_color: String,
|
||||
/// 整窗透明度 [`WINDOW_ALPHA_MIN`]~1.0(1.0 = 出厂完全不透明;D36)。
|
||||
///
|
||||
/// 只管取值:真正的窗口 α 由 `unlucky-app-iced` 的 win32 层调
|
||||
/// `SetLayeredWindowAttributes(LWA_ALPHA)` 落地,core 不碰 FFI。
|
||||
pub window_opacity: f64,
|
||||
/// 标题栏三态(D37)。`auto` 怎么判定属于 UI 侧,core 只存用户的选择。
|
||||
pub titlebar_mode: TitlebarMode,
|
||||
/// 配色来源档(D38 三级;默认 [`PaletteSource::Preset`] = 内置色卡)。
|
||||
pub palette_source: PaletteSource,
|
||||
}
|
||||
|
||||
impl Default for Appearance {
|
||||
@@ -92,6 +127,9 @@ impl Default for Appearance {
|
||||
panel_alpha: 1.0,
|
||||
glass: false,
|
||||
accent_color: String::new(),
|
||||
window_opacity: 1.0,
|
||||
titlebar_mode: TitlebarMode::Auto,
|
||||
palette_source: PaletteSource::Preset,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +158,18 @@ impl Appearance {
|
||||
accent_color: text("appearance.accent_color")
|
||||
.map(|value| normalize_hex(&value).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
window_opacity: number("appearance.window_opacity")
|
||||
.map_or(default.window_opacity, |value| {
|
||||
clamp(value, WINDOW_ALPHA_MIN, 1.0)
|
||||
}),
|
||||
titlebar_mode: text("appearance.titlebar_mode")
|
||||
.map_or(default.titlebar_mode, |value| {
|
||||
TitlebarMode::from_key_value(&value)
|
||||
}),
|
||||
palette_source: text("appearance.palette_source")
|
||||
.map_or(default.palette_source, |value| {
|
||||
PaletteSource::from_key_value(&value)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +226,21 @@ impl Appearance {
|
||||
"appearance.accent_color",
|
||||
Value::from(self.accent_color.as_str()),
|
||||
)?;
|
||||
// 越界钳位而不是报错(D36):α 的下限是硬底,不许把窗口写到穿透;
|
||||
// 上限 1.0 = 完全不透明。非法枚举(titlebar_mode / palette_source)由
|
||||
// `as_str()` 保证永远是合法字面量,不会写坏文件。
|
||||
store.set(
|
||||
"appearance.window_opacity",
|
||||
Value::from(clamp(self.window_opacity, WINDOW_ALPHA_MIN, 1.0)),
|
||||
)?;
|
||||
store.set(
|
||||
"appearance.titlebar_mode",
|
||||
Value::from(self.titlebar_mode.as_str()),
|
||||
)?;
|
||||
store.set(
|
||||
"appearance.palette_source",
|
||||
Value::from(self.palette_source.as_str()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 外观三键的取值表与「非法值回落」诊断(D36 / D37 / D38)。
|
||||
//!
|
||||
//! 与 [`super`] 拆开只为单文件 ≤400 行的纪律:这里是纯字面量表 + 纯判据,
|
||||
//! 不碰文档读写。对外路径仍是 `setting::appearance::*`([`super`] 逐个重导出)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::setting::document::SettingDocument;
|
||||
|
||||
/// `appearance.titlebar_mode` 的允许值(D37 三态)。**顺序即设置页分段控件的顺序**。
|
||||
pub const TITLEBAR_MODES: &[&str] = &["auto", "always", "never"];
|
||||
|
||||
/// 标题栏三态(D37)。
|
||||
///
|
||||
/// 只持有「用户想要哪种」,**不含** `auto` 的判定 —— 「非 Wayland → 显示 / 合成器名精确命中
|
||||
/// → 隐藏 / 旁证 / 兜底显示」那套顺序属于 UI 侧(WCHROME-IMPL),core 不探测环境。
|
||||
/// 特别地:判定不许用 `is_decorated()`,那条纪律与本文档无关,别在这里实现。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TitlebarMode {
|
||||
/// 按平台/合成器自动决定(默认;由 UI 侧判定)
|
||||
#[default]
|
||||
Auto,
|
||||
/// 永远显示自绘标题栏
|
||||
Always,
|
||||
/// 永远不显示(合成器已给服务端装饰时)
|
||||
Never,
|
||||
}
|
||||
|
||||
impl TitlebarMode {
|
||||
/// 落盘取值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Always => "always",
|
||||
Self::Never => "never",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从落盘取值解析;未知值按 `auto` 兜底(手改坏配置也不崩,
|
||||
/// 回落原因由 [`diagnostics`] 给出可读诊断)。
|
||||
#[must_use]
|
||||
pub fn from_key_value(value: &str) -> Self {
|
||||
match value {
|
||||
"always" => Self::Always,
|
||||
"never" => Self::Never,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `appearance.palette_source` 的允许值:D38 的三级配色来源。
|
||||
///
|
||||
/// **取值与 `unlucky_design::palette::Level::code()` 同名同值**(`background-image` /
|
||||
/// `wallpaper` / `preset`)。这里抄字面量而不是引用 design:`core` 不许反过来依赖 UI 侧
|
||||
/// (分层纪律,D3),一致性由 `tests` 里的字面量断言固定 —— 与 `JDK_VENDORS` 的处理同一条路子。
|
||||
pub const PALETTE_SOURCES: &[&str] = &["background-image", "wallpaper", "preset"];
|
||||
|
||||
/// 配色来源档(D38:背景图取色 > 桌面壁纸取色 > 内置色卡)。
|
||||
///
|
||||
/// 这个键**不是**优先级链本身(链在 `design::palette::resolve` 里),而是「当前生效的档」
|
||||
/// 与「用户/调用方希望的最强来源」的登记位:默认 [`Self::Preset`] 对应莫奈取色关闭的出厂态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PaletteSource {
|
||||
/// ① 启动器背景图取色
|
||||
BackgroundImage,
|
||||
/// ② 桌面壁纸取色
|
||||
Wallpaper,
|
||||
/// ③ 内置色卡(默认:出厂不取色)
|
||||
#[default]
|
||||
Preset,
|
||||
}
|
||||
|
||||
impl PaletteSource {
|
||||
/// 落盘取值(与 design 侧 `Level::code()` 逐字一致)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundImage => "background-image",
|
||||
Self::Wallpaper => "wallpaper",
|
||||
Self::Preset => "preset",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从落盘取值解析;未知值按 `preset`(内置色卡)兜底。
|
||||
#[must_use]
|
||||
pub fn from_key_value(value: &str) -> Self {
|
||||
match value {
|
||||
"background-image" => Self::BackgroundImage,
|
||||
"wallpaper" => Self::Wallpaper,
|
||||
_ => Self::Preset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次「按默认档兜底」的诊断:哪个键、读到了什么、兜底成什么、一句给人看的话。
|
||||
///
|
||||
/// 存在的理由:非法枚举**不报错**(配置坏掉不该把启动器卡住),但也**不许静默**——
|
||||
/// 用户/日志要能看出「我写的 `always` 拼错了,现在按 auto 跑」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AppearanceDiagnostic {
|
||||
/// 出问题的键。
|
||||
pub key: &'static str,
|
||||
/// 文件里读到的原始文本。
|
||||
pub raw: String,
|
||||
/// 实际生效的兜底档。
|
||||
pub fallback: &'static str,
|
||||
/// 一句可读的说明(可直接进日志;UI 侧可再包一层文案)。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 读出需要提示的回落(空 = 一切正常)。
|
||||
///
|
||||
/// 只覆盖**枚举型**键(`titlebar_mode` / `palette_source`)。数值键(`window_opacity`)
|
||||
/// 越界是「钳位且不报错」的既定语义(D36 下限 0.08),不算诊断项。
|
||||
#[must_use]
|
||||
pub fn diagnostics(doc: &SettingDocument) -> Vec<AppearanceDiagnostic> {
|
||||
let mut found = Vec::new();
|
||||
let text = |key: &str| {
|
||||
doc.effective(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
};
|
||||
|
||||
if let Some(raw) = text("appearance.titlebar_mode") {
|
||||
if !TITLEBAR_MODES.contains(&raw.as_str()) {
|
||||
let fallback = TitlebarMode::Auto.as_str();
|
||||
found.push(AppearanceDiagnostic {
|
||||
key: "appearance.titlebar_mode",
|
||||
message: format!(
|
||||
"appearance.titlebar_mode 的值 {raw:?} 不在 {TITLEBAR_MODES:?} 之中,已按 {fallback} 处理"
|
||||
),
|
||||
raw,
|
||||
fallback,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(raw) = text("appearance.palette_source") {
|
||||
if !PALETTE_SOURCES.contains(&raw.as_str()) {
|
||||
let fallback = PaletteSource::Preset.as_str();
|
||||
found.push(AppearanceDiagnostic {
|
||||
key: "appearance.palette_source",
|
||||
message: format!(
|
||||
"appearance.palette_source 的值 {raw:?} 不在 {PALETTE_SOURCES:?} 之中,已按 {fallback} 处理"
|
||||
),
|
||||
raw,
|
||||
fallback,
|
||||
});
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 外观三键(整窗透明度 / 标题栏三态 / 配色来源)的读侧契约。
|
||||
//!
|
||||
//! 与 `mod.rs` 拆开只为单文件 ≤400 行的纪律(D36/D37/D38 的键仍是
|
||||
//! `setting::appearance::*`),测试属于同一个模块。
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
diagnostics, Appearance, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES,
|
||||
WINDOW_ALPHA_MIN,
|
||||
};
|
||||
use crate::setting::document::SettingDocument;
|
||||
use crate::setting::keys::launcher_schema;
|
||||
use crate::setting::schema::{SchemaId, SchemaRef, SchemaVersion};
|
||||
use crate::setting::store::SettingStore;
|
||||
|
||||
fn doc(raw: serde_json::Value) -> SettingDocument {
|
||||
SettingDocument::load(&launcher_schema(), &raw).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_fall_back_to_factory_values() {
|
||||
let document = doc(json!({}));
|
||||
let appearance = Appearance::read(&document);
|
||||
assert_eq!(appearance.window_opacity, 1.0, "D36:出厂完全不透明");
|
||||
assert_eq!(
|
||||
appearance.titlebar_mode,
|
||||
TitlebarMode::Auto,
|
||||
"D37:默认 auto"
|
||||
);
|
||||
assert_eq!(
|
||||
appearance.palette_source,
|
||||
PaletteSource::Preset,
|
||||
"D38:默认内置色卡(莫奈取色关闭的出厂档)"
|
||||
);
|
||||
assert!(
|
||||
diagnostics(&document).is_empty(),
|
||||
"缺键 = 取默认,不该产生回落诊断"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_opacity_is_clamped_to_the_d36_floor() {
|
||||
let cases = [
|
||||
(0.0, WINDOW_ALPHA_MIN),
|
||||
(0.02, WINDOW_ALPHA_MIN),
|
||||
(WINDOW_ALPHA_MIN, WINDOW_ALPHA_MIN),
|
||||
(0.5, 0.5),
|
||||
(1.0, 1.0),
|
||||
(4.0, 1.0),
|
||||
];
|
||||
for (raw, want) in cases {
|
||||
let appearance = Appearance::read(&doc(json!({ "appearance": { "window_opacity": raw } })));
|
||||
assert!(
|
||||
(appearance.window_opacity - want).abs() < 1e-9,
|
||||
"window_opacity={raw} 应读出 {want},实际 {}",
|
||||
appearance.window_opacity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_opacity_is_clamped_not_rejected() {
|
||||
// 手改文件绕过 schema:load 只记一条 problem(不失败、不 panic),读侧再夹一次。
|
||||
let (document, problems) = SettingDocument::load(
|
||||
&launcher_schema(),
|
||||
&json!({ "appearance": { "window_opacity": 0.02 } }),
|
||||
);
|
||||
assert!(
|
||||
!problems.is_empty(),
|
||||
"越界值必须留下痕迹(LoadProblem),否则「钳位」变成静默改用户的数"
|
||||
);
|
||||
assert!((Appearance::read(&document).window_opacity - WINDOW_ALPHA_MIN).abs() < 1e-9);
|
||||
|
||||
// 类型错(字符串)也不算错误:回落默认 1.0。
|
||||
let appearance = Appearance::read(&doc(json!({ "appearance": { "window_opacity": "half" } })));
|
||||
assert_eq!(appearance.window_opacity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_enum_falls_back_and_leaves_a_readable_diagnostic() {
|
||||
let document = doc(json!({
|
||||
"appearance": { "titlebar_mode": "allways", "palette_source": "desktop" }
|
||||
}));
|
||||
let appearance = Appearance::read(&document);
|
||||
assert_eq!(appearance.titlebar_mode, TitlebarMode::Auto);
|
||||
assert_eq!(appearance.palette_source, PaletteSource::Preset);
|
||||
|
||||
let found = diagnostics(&document);
|
||||
assert_eq!(found.len(), 2, "两个非法枚举各一条诊断:{found:?}");
|
||||
assert_eq!(found[0].key, "appearance.titlebar_mode");
|
||||
assert_eq!(found[0].raw, "allways");
|
||||
assert_eq!(found[0].fallback, "auto");
|
||||
assert!(
|
||||
found[0].message.contains("allways") && found[0].message.contains("auto"),
|
||||
"诊断要同时说清「读到了什么」和「生效的是什么」:{}",
|
||||
found[0].message
|
||||
);
|
||||
assert_eq!(found[1].key, "appearance.palette_source");
|
||||
assert_eq!(found[1].raw, "desktop");
|
||||
assert_eq!(found[1].fallback, "preset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_tables_are_self_consistent() {
|
||||
assert_eq!(TITLEBAR_MODES, ["auto", "always", "never"], "D37 三态");
|
||||
// 这里只钉 **core 自己的字面量**(改这三个字符串要过这一关)。
|
||||
// 与 `unlucky_design::palette::Level::code()` 的一致性 core 守不了——core 不依赖 design
|
||||
// (分层纪律),本 crate 的测试永远看不到 design 的代码。那条一致性由
|
||||
// `crates/app-iced/src/platform/mod.rs::palette_source_codes_match_design_level_codes`
|
||||
// 守(那个 crate 同时依赖 design 与 presenter,是唯一能对照的地方)。
|
||||
assert_eq!(
|
||||
PALETTE_SOURCES,
|
||||
["background-image", "wallpaper", "preset"],
|
||||
"D38 三级(与 design 的 Level::code() 同名)"
|
||||
);
|
||||
for source in [
|
||||
PaletteSource::BackgroundImage,
|
||||
PaletteSource::Wallpaper,
|
||||
PaletteSource::Preset,
|
||||
] {
|
||||
assert_eq!(PaletteSource::from_key_value(source.as_str()), source);
|
||||
assert!(PALETTE_SOURCES.contains(&source.as_str()));
|
||||
}
|
||||
for mode in [
|
||||
TitlebarMode::Auto,
|
||||
TitlebarMode::Always,
|
||||
TitlebarMode::Never,
|
||||
] {
|
||||
assert_eq!(TitlebarMode::from_key_value(mode.as_str()), mode);
|
||||
assert!(TITLEBAR_MODES.contains(&mode.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_document_loads_and_write_back_keeps_existing_keys() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"ull-appearance-base-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(temp.join("config")).expect("临时目录");
|
||||
let path = temp.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// 1.3.0 的老文件:没有任何新键,但已有外观键要原样保住。
|
||||
let old = json!({
|
||||
"$schema": SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 3, 0)).url(),
|
||||
"appearance": { "theme_mode": "dark", "panel_alpha": 0.8, "glass": true }
|
||||
});
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&old).expect("序列化")).expect("写老文件");
|
||||
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
let before = Appearance::read(store.doc());
|
||||
assert_eq!(before.theme_mode, "dark", "老文件的已有键照读");
|
||||
assert!((before.panel_alpha - 0.8).abs() < 1e-9);
|
||||
assert!(before.glass);
|
||||
|
||||
let mut appearance = before;
|
||||
appearance.window_opacity = 0.9;
|
||||
appearance.titlebar_mode = TitlebarMode::Always;
|
||||
appearance.write(&mut store).expect("写新键");
|
||||
store.save().expect("落盘");
|
||||
|
||||
let reloaded = Appearance::from_store(&SettingStore::load(&path, launcher_schema()));
|
||||
assert!((reloaded.window_opacity - 0.9).abs() < 1e-9);
|
||||
assert_eq!(reloaded.titlebar_mode, TitlebarMode::Always);
|
||||
assert_eq!(
|
||||
reloaded.palette_source,
|
||||
PaletteSource::Preset,
|
||||
"未写的键仍是默认"
|
||||
);
|
||||
assert_eq!(reloaded.theme_mode, "dark", "写回不许丢已有键");
|
||||
assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9);
|
||||
assert!(reloaded.glass);
|
||||
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
@@ -49,9 +49,12 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
|
||||
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
|
||||
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
|
||||
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
|
||||
/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` /
|
||||
/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值
|
||||
/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。
|
||||
#[must_use]
|
||||
pub fn launcher_version() -> SchemaVersion {
|
||||
SchemaVersion::new(1, 3, 0)
|
||||
SchemaVersion::new(1, 4, 0)
|
||||
}
|
||||
|
||||
/// `config/launcher.json` 的 schema。
|
||||
@@ -310,6 +313,29 @@ pub fn launcher_schema() -> SettingSchema {
|
||||
AiPolicy::audited(),
|
||||
"自定义强调色 #RRGGBB(空 = 出厂 #0078D4;accent 令牌全域生效)",
|
||||
))
|
||||
// M91(P4 / D36·D37·D38):「外观下层」三键——只登记取值与范围,判定与落地都在 UI 侧。
|
||||
// 分级同 D17 那批(audited/Medium):纯视觉、可一键复原,但要留审计痕迹。
|
||||
.with(SettingSpec::float(
|
||||
"appearance.window_opacity",
|
||||
1.0,
|
||||
(super::appearance::WINDOW_ALPHA_MIN, 1.0),
|
||||
AiPolicy::audited(),
|
||||
"整窗透明度 0.08~1.0(1.0 = 完全不透明;下限 0.08 是为了不把窗口写到穿透,D36)",
|
||||
))
|
||||
.with(SettingSpec::enumeration(
|
||||
"appearance.titlebar_mode",
|
||||
"auto",
|
||||
super::appearance::TITLEBAR_MODES,
|
||||
AiPolicy::audited(),
|
||||
"标题栏三态:auto(按合成器判定)/ always(总是自绘)/ never(合成器已装饰),默认 auto(D37)",
|
||||
))
|
||||
.with(SettingSpec::enumeration(
|
||||
"appearance.palette_source",
|
||||
"preset",
|
||||
super::appearance::PALETTE_SOURCES,
|
||||
AiPolicy::audited(),
|
||||
"配色来源档(D38 三级):background-image(背景图取色)/ wallpaper(桌面壁纸取色)/ preset(内置色卡,默认)",
|
||||
))
|
||||
// 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。
|
||||
.with(
|
||||
SettingSpec::new(
|
||||
|
||||
@@ -96,6 +96,10 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
|
||||
"appearance.panel_alpha",
|
||||
"appearance.glass",
|
||||
"appearance.accent_color",
|
||||
// M91(D36/D37/D38)的外观下层三键,同样 audited。
|
||||
"appearance.window_opacity",
|
||||
"appearance.titlebar_mode",
|
||||
"appearance.palette_source",
|
||||
];
|
||||
for key in audited_keys {
|
||||
let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记"));
|
||||
@@ -125,7 +129,7 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
|
||||
|
||||
#[test]
|
||||
fn schema_registration_shape_is_right() {
|
||||
assert_eq!(launcher_version().minor, 3, "1.3.0 登记外观键");
|
||||
assert_eq!(launcher_version().minor, 4, "1.4.0 登记外观下层三键");
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(
|
||||
schema.spec("appearance.bg_fill").unwrap().kind,
|
||||
|
||||
@@ -145,8 +145,9 @@ fn schema_version_mirror_mismatch_is_reported_but_schema_wins() {
|
||||
|
||||
#[test]
|
||||
fn newer_patch_preserves_original_schema_string() {
|
||||
// 同 minor、更高 patch(launcher schema 当前 1.3.0)。
|
||||
let newer = SchemaVersion::new(1, 3, 7);
|
||||
// 同 minor、更高 patch(**相对构造**:版本号涨了这条也不会假绿)。
|
||||
let current = launcher_version();
|
||||
let newer = SchemaVersion::new(current.major, current.minor, current.patch + 1);
|
||||
let url = schema_url(newer);
|
||||
let (doc, _) = loaded(json!({ SCHEMA_KEY: url.clone() }));
|
||||
assert_eq!(
|
||||
|
||||
@@ -128,8 +128,8 @@ fn overrides_key_is_an_enum_whitelist() {
|
||||
|
||||
#[test]
|
||||
fn schema_versions_register_the_new_keys_without_migration() {
|
||||
// 1.3.0 登记 D17 的「外观(高级)」键(1.2.0 是 D23 的 JDK 落点/优先级键)。
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0));
|
||||
// 1.4.0 登记 M91 的外观下层三键(1.3.0 是 D17 的「外观(高级)」键,1.2.0 是 D23 的 JDK 落点/优先级键)。
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 4, 0));
|
||||
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
||||
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||||
|
||||
@@ -66,7 +66,7 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
|
||||
|
||||
#[test]
|
||||
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0));
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 4, 0));
|
||||
assert_eq!(
|
||||
launcher_version().major,
|
||||
1,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::setting::document::LoadProblem;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
|
||||
use unlucky_core::setting::schema::{
|
||||
FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY,
|
||||
};
|
||||
@@ -124,8 +124,9 @@ fn corrupt_file_is_backed_up_before_overwrite() {
|
||||
#[test]
|
||||
fn newer_minor_is_read_only_and_keeps_its_schema() {
|
||||
let temp = TempDir::new("store-readonly");
|
||||
// 比当前 schema(1.3.0)更高的 minor:只读且保留原 $schema。
|
||||
let url = schema_url(SchemaVersion::new(1, 4, 0));
|
||||
// 比当前 schema 高一个 minor(**相对构造**:版本号涨了这条也不会假绿):只读且保留原 $schema。
|
||||
let current = launcher_version();
|
||||
let url = schema_url(SchemaVersion::new(current.major, current.minor + 1, 0));
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: url, "download": { "concurrency": 32 } }).to_string(),
|
||||
@@ -148,12 +149,10 @@ fn newer_minor_is_read_only_and_keeps_its_schema() {
|
||||
store.save_forced().expect("用户确认强写");
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert!(
|
||||
raw[SCHEMA_KEY]
|
||||
.as_str()
|
||||
.expect("$schema")
|
||||
.ends_with("launcher/1.4.0"),
|
||||
"更 minor 的文件强写后也要保留原 $schema"
|
||||
assert_eq!(
|
||||
raw[SCHEMA_KEY].as_str().expect("$schema"),
|
||||
url,
|
||||
"更 minor 的文件强写后也要保留原 $schema(与构造出来的那个 url 逐字相同)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。
|
||||
pub use unlucky_core::setting::appearance::BgFill;
|
||||
|
||||
pub use unlucky_core::setting::appearance::PALETTE_SOURCES;
|
||||
/// 外观下层的三张表/常量(D36·D37·D38):复用 core 的定义,一处真相。
|
||||
///
|
||||
/// [`WINDOW_ALPHA_MIN`] 还要给 `unlucky-app-iced` 的 win32 α 层用(它只依赖本 crate,不直接依赖
|
||||
/// core):窗口 α 的硬底必须与设置侧同源,两边各写一个 `0.08` 迟早会漂。
|
||||
pub use unlucky_core::setting::appearance::{PaletteSource, TitlebarMode, WINDOW_ALPHA_MIN};
|
||||
|
||||
use crate::dto::VersionCategory;
|
||||
|
||||
/// 预设强调色板(09 §104)。**必须**与 UI 侧设置页「外观」分组的色板控件同序同值:
|
||||
@@ -162,6 +169,51 @@ impl AppearancePrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// 外观下层的**只读**快照:整窗 α / 标题栏三态 / 配色来源(D36·D37·D38)。
|
||||
///
|
||||
/// 不并进 [`AppearancePrefs`] 的两个理由:那个结构体由 `settings_bridge` 逐字段字面量
|
||||
/// 构造(加字段就得改那边),且这三个键只读不写——落盘仍走 core 的 `Appearance::write`。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AppearanceBase {
|
||||
/// 整窗透明度 [`WINDOW_ALPHA_MIN`]~1.0(1.0 = 完全不透明)
|
||||
pub window_opacity: f32,
|
||||
/// 标题栏三态(`auto` 的具体判定属于 UI 侧,本 crate 只给值)
|
||||
pub titlebar_mode: TitlebarMode,
|
||||
/// 配色来源档(三级编排在 `unlucky-design`,本 crate 只给值)
|
||||
pub palette_source: PaletteSource,
|
||||
}
|
||||
|
||||
impl AppearanceBase {
|
||||
/// 出厂态:不透明 / `auto` / 内置色卡。
|
||||
#[must_use]
|
||||
pub fn factory() -> Self {
|
||||
Self {
|
||||
window_opacity: 1.0,
|
||||
titlebar_mode: TitlebarMode::Auto,
|
||||
palette_source: PaletteSource::Preset,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从配置文档读一份快照:缺键 → 出厂默认、越界 → 钳位、非法枚举 → 回落。
|
||||
///
|
||||
/// 三条语义都在 core 里落地(本函数只搬运,不重新判一遍)。
|
||||
#[must_use]
|
||||
pub fn from_document(doc: &unlucky_core::setting::document::SettingDocument) -> Self {
|
||||
let appearance = unlucky_core::setting::appearance::Appearance::read(doc);
|
||||
Self {
|
||||
window_opacity: appearance.window_opacity as f32,
|
||||
titlebar_mode: appearance.titlebar_mode,
|
||||
palette_source: appearance.palette_source,
|
||||
}
|
||||
}
|
||||
|
||||
/// 整窗 α 是否需要真的落到窗口上(1.0 = 不透明,调 FFI 纯属多余)。
|
||||
#[must_use]
|
||||
pub fn is_transparent(&self) -> bool {
|
||||
self.window_opacity < 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要跨会话记忆的 UI 偏好。
|
||||
///
|
||||
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
|
||||
@@ -226,9 +278,15 @@ pub mod keys {
|
||||
pub const ACCENT_COLOR: &str = "appearance.accent_color";
|
||||
/// 下载页分类
|
||||
pub const DOWNLOAD_CATEGORY: &str = "ui.download.category";
|
||||
/// 整窗透明度(D36;下限见 [`super::WINDOW_ALPHA_MIN`])
|
||||
pub const WINDOW_OPACITY: &str = "appearance.window_opacity";
|
||||
/// 标题栏三态(D37)
|
||||
pub const TITLEBAR_MODE: &str = "appearance.titlebar_mode";
|
||||
/// 配色来源档(D38)
|
||||
pub const PALETTE_SOURCE: &str = "appearance.palette_source";
|
||||
|
||||
/// 本模块登记的全部键(自检/迁移用)。
|
||||
pub const ALL: [&str; 13] = [
|
||||
pub const ALL: [&str; 16] = [
|
||||
NAV_COLLAPSED,
|
||||
AI_COLLAPSED,
|
||||
TILE_SIZE,
|
||||
@@ -242,12 +300,18 @@ pub mod keys {
|
||||
GLASS,
|
||||
ACCENT_COLOR,
|
||||
DOWNLOAD_CATEGORY,
|
||||
WINDOW_OPACITY,
|
||||
TITLEBAR_MODE,
|
||||
PALETTE_SOURCE,
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{keys, InstanceView, ThemeMode, TileSize, UiPrefs};
|
||||
use super::{
|
||||
keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode,
|
||||
UiPrefs, WINDOW_ALPHA_MIN,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn defaults_match_d16() {
|
||||
@@ -285,4 +349,51 @@ mod tests {
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), total);
|
||||
}
|
||||
|
||||
fn base_of(raw: serde_json::Value) -> AppearanceBase {
|
||||
let schema = unlucky_core::setting::keys::launcher_schema();
|
||||
let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw);
|
||||
AppearanceBase::from_document(&doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_base_missing_keys_use_factory() {
|
||||
let base = base_of(serde_json::json!({}));
|
||||
assert_eq!(
|
||||
base,
|
||||
AppearanceBase::factory(),
|
||||
"缺键 → 出厂态(不透明 / auto / preset)"
|
||||
);
|
||||
assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_base_clamps_out_of_range_opacity() {
|
||||
for raw in [0.0, 0.02, -1.0] {
|
||||
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
|
||||
assert_eq!(
|
||||
base.window_opacity, WINDOW_ALPHA_MIN as f32,
|
||||
"raw={raw} 应夹到下限"
|
||||
);
|
||||
}
|
||||
for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] {
|
||||
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
|
||||
assert_eq!(base.window_opacity, want, "raw={raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() {
|
||||
let bad = base_of(serde_json::json!({
|
||||
"appearance": { "titlebar_mode": "allways", "palette_source": "desktop" }
|
||||
}));
|
||||
assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto");
|
||||
assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset");
|
||||
// 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。
|
||||
let good = base_of(serde_json::json!({
|
||||
"appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" }
|
||||
}));
|
||||
assert_eq!(good.titlebar_mode, TitlebarMode::Never);
|
||||
assert_eq!(good.palette_source, PaletteSource::Wallpaper);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user