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,291 @@
|
||||
//! 「外观(高级)」配置簇的读写(D17 · `09-ui-layout.md` §2 G6 + §111 约束)。
|
||||
//!
|
||||
//! 为什么放在 core:主题令牌的**取值与校验**是配置事实,视觉实现(Slint 侧)只消费结果。
|
||||
//! 分层纪律要求 core 零 UI 依赖——本文件只认 `appearance.*` 这些键与它们的取值形状,
|
||||
//! 不认任何像素/控件([`super::keys`] 登记同一份键表,两边由 `tests/setting/appearance_keys.rs` 固定)。
|
||||
//!
|
||||
//! 设计约束(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"];
|
||||
|
||||
/// 出厂强调色(Metro 蓝,D14);`appearance.accent_color` 为空串时用它。
|
||||
pub const DEFAULT_ACCENT: &str = "#0078D4";
|
||||
|
||||
/// `appearance.panel_alpha` 下限:再低前景就压不住背景图(09 §111「对比度检查提示」的硬底)。
|
||||
pub const PANEL_ALPHA_MIN: f64 = 0.4;
|
||||
/// `appearance.bg_dim` 下限(0 = 不暗化,允许但设置页会提示对比度风险)。
|
||||
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 {
|
||||
/// 等比铺满、裁切溢出(默认:不露底色)
|
||||
#[default]
|
||||
Cover,
|
||||
/// 等比完整显示、留底色
|
||||
Contain,
|
||||
/// 原始尺寸平铺
|
||||
Tile,
|
||||
}
|
||||
|
||||
impl BgFill {
|
||||
/// 落盘取值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cover => "cover",
|
||||
Self::Contain => "contain",
|
||||
Self::Tile => "tile",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从落盘取值解析;未知值按 `cover` 兜底(手改坏配置也不崩)。
|
||||
#[must_use]
|
||||
pub fn from_key_value(value: &str) -> Self {
|
||||
match value {
|
||||
"contain" => Self::Contain,
|
||||
"tile" => Self::Tile,
|
||||
_ => Self::Cover,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外观(高级)配置簇:**只是取值快照**,读写都走 [`super::store::SettingStore`]。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Appearance {
|
||||
/// 主题三态:`auto` / `light` / `dark`(与 `prefs::ThemeMode` 同名同值)。
|
||||
pub theme_mode: String,
|
||||
/// 自定义窗口背景图开关(默认关)。
|
||||
pub bg_image_enabled: bool,
|
||||
/// 背景图路径(PNG;空 = 未设置)。
|
||||
pub bg_image: String,
|
||||
/// 背景图填充模式。
|
||||
pub bg_fill: BgFill,
|
||||
/// 叠加暗化层强度 0.0~0.95(越大前景越清晰)。
|
||||
pub bg_dim: f64,
|
||||
/// 全局面板半透明度 0.4~1.0(1.0 = 出厂不透明)。
|
||||
pub panel_alpha: f64,
|
||||
/// 玻璃拟态卡片(预生成模糊背景层)。
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_mode: "auto".to_owned(),
|
||||
bg_image_enabled: false,
|
||||
bg_image: String::new(),
|
||||
bg_fill: BgFill::Cover,
|
||||
bg_dim: 0.35,
|
||||
panel_alpha: 1.0,
|
||||
glass: false,
|
||||
accent_color: String::new(),
|
||||
window_opacity: 1.0,
|
||||
titlebar_mode: TitlebarMode::Auto,
|
||||
palette_source: PaletteSource::Preset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
/// 从配置文档读一份快照(文件值 → 默认值;坏值按默认值兜底,绝不 panic)。
|
||||
#[must_use]
|
||||
pub fn read(doc: &SettingDocument) -> Self {
|
||||
let default = Self::default();
|
||||
let text = |key: &str| doc.effective_str(key).map(str::to_owned);
|
||||
let boolean = |key: &str| doc.effective(key).and_then(Value::as_bool);
|
||||
let number = |key: &str| doc.effective(key).and_then(Value::as_f64);
|
||||
|
||||
Self {
|
||||
theme_mode: text("appearance.theme_mode").unwrap_or(default.theme_mode),
|
||||
bg_image_enabled: boolean("appearance.bg_image_enabled").unwrap_or(false),
|
||||
bg_image: text("appearance.bg_image").unwrap_or_default(),
|
||||
bg_fill: text("appearance.bg_fill")
|
||||
.map_or(default.bg_fill, |value| BgFill::from_key_value(&value)),
|
||||
bg_dim: number("appearance.bg_dim")
|
||||
.map_or(default.bg_dim, |value| clamp(value, BG_DIM_MIN, BG_DIM_MAX)),
|
||||
panel_alpha: number("appearance.panel_alpha").map_or(default.panel_alpha, |value| {
|
||||
clamp(value, PANEL_ALPHA_MIN, 1.0)
|
||||
}),
|
||||
glass: boolean("appearance.glass").unwrap_or(false),
|
||||
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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 store 读(配置读写的统一入口)。
|
||||
#[must_use]
|
||||
pub fn from_store(store: &SettingStore) -> Self {
|
||||
Self::read(store.doc())
|
||||
}
|
||||
|
||||
/// 实际生效的强调色:未自定义时回落到出厂 accent。
|
||||
#[must_use]
|
||||
pub fn accent_or_default(&self) -> &str {
|
||||
if self.accent_color.is_empty() {
|
||||
DEFAULT_ACCENT
|
||||
} else {
|
||||
&self.accent_color
|
||||
}
|
||||
}
|
||||
|
||||
/// 背景图是否真的会用上(开关打开 **且** 路径非空)。
|
||||
#[must_use]
|
||||
pub fn bg_image_active(&self) -> bool {
|
||||
self.bg_image_enabled && !self.bg_image.trim().is_empty()
|
||||
}
|
||||
|
||||
/// 写回一组键(调用方负责 `save()`;默认值不落盘由 `SettingDocument` 决定)。
|
||||
///
|
||||
/// 值先过一遍规范化:`bg_fill` 认枚举、`bg_dim`/`panel_alpha` 夹到范围内、
|
||||
/// `accent_color` 只接受 `#RRGGBB`(空串表示清掉自定义)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 键值被 schema 拒绝(只读文件 / 未知键 / 越界)时返回 [`super::error::SettingError`]。
|
||||
pub fn write(&self, store: &mut SettingStore) -> super::error::SettingResult<()> {
|
||||
store.set(
|
||||
"appearance.theme_mode",
|
||||
Value::from(self.theme_mode.as_str()),
|
||||
)?;
|
||||
store.set(
|
||||
"appearance.bg_image_enabled",
|
||||
Value::from(self.bg_image_enabled),
|
||||
)?;
|
||||
store.set("appearance.bg_image", Value::from(self.bg_image.as_str()))?;
|
||||
store.set("appearance.bg_fill", Value::from(self.bg_fill.as_str()))?;
|
||||
store.set(
|
||||
"appearance.bg_dim",
|
||||
Value::from(clamp(self.bg_dim, BG_DIM_MIN, BG_DIM_MAX)),
|
||||
)?;
|
||||
store.set(
|
||||
"appearance.panel_alpha",
|
||||
Value::from(clamp(self.panel_alpha, PANEL_ALPHA_MIN, 1.0)),
|
||||
)?;
|
||||
store.set("appearance.glass", Value::from(self.glass))?;
|
||||
store.set(
|
||||
"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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `#RRGGBB` 规范化(大写、去空格);不合法返回 `None`。
|
||||
#[must_use]
|
||||
pub fn normalize_hex(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
let body = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
||||
if body.len() != 6 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("#{}", body.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
/// `#RRGGBB` → `(r, g, b)`(UI 侧构造颜色用)。
|
||||
#[must_use]
|
||||
pub fn hex_to_rgb(text: &str) -> Option<(u8, u8, u8)> {
|
||||
let hex = normalize_hex(text)?;
|
||||
let byte = |range: std::ops::Range<usize>| u8::from_str_radix(&hex[range], 16).ok();
|
||||
Some((byte(1..3)?, byte(3..5)?, byte(5..7)?))
|
||||
}
|
||||
|
||||
/// 相对亮度(WCAG 2.x,`0.0`~`1.0`):设置页「对比度检查提示」用它判断前景是否够读。
|
||||
#[must_use]
|
||||
pub fn relative_luminance(text: &str) -> Option<f64> {
|
||||
let (r, g, b) = hex_to_rgb(text)?;
|
||||
let channel = |value: u8| {
|
||||
let value = f64::from(value) / 255.0;
|
||||
if value <= 0.039_28 {
|
||||
value / 12.92
|
||||
} else {
|
||||
((value + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
Some(0.2126f64.mul_add(
|
||||
channel(r),
|
||||
0.7152f64.mul_add(channel(g), 0.0722 * channel(b)),
|
||||
))
|
||||
}
|
||||
|
||||
fn clamp(value: f64, min: f64, max: f64) -> f64 {
|
||||
if value.is_nan() {
|
||||
min
|
||||
} else {
|
||||
value.clamp(min, max)
|
||||
}
|
||||
}
|
||||
@@ -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,175 @@
|
||||
//! 外观三键(整窗透明度 / 标题栏三态 / 配色来源)的读侧契约。
|
||||
//!
|
||||
//! 与 `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_stable_and_match_design_level_codes() {
|
||||
assert_eq!(TITLEBAR_MODES, ["auto", "always", "never"], "D37 三态");
|
||||
// 与 `unlucky_design::palette::Level::code()` 逐字一致;core 不依赖 design(分层纪律),
|
||||
// 所以这条一致性只能靠字面量钉住——design 侧改码值就会在这里红。
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user