用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
Dwm* / SetWindowRgn。本 mission 先做实测,再按平台分开落策略。
判据(纯函数、可真值表):
- `platform::plan_corner(策略 × 系统版本 × 渲染档)` → `source=dwm|square` + `excluded_reason`。
Win11 / Server 2025(build ≥ 22000)→ DWM;Win10 及更早 → 直角(不做 SetWindowRgn 毛边默认);
探测不到版本(macOS / X11 / Wayland)→ 直角 + `unknown-os`(「不承诺」而不是「做不到」)。
CPU 档在 Win11 上照样用 DWM(D36 限制的是我们自己的合成技巧,不是系统合成器)。
落点:`platform/win32/transparency.rs` 仍是全仓唯一 unsafe 文件,新增 DWM 的 FFI 与安全包装;
`alpha.rs` / `dwm.rs` / `probe.rs` 三个新子模块零 unsafe、零 extern(沿 exstyle.rs 的既有形状)。
实测(本机 Server 2025 build 26100、RDP 会话断开、同进程三窗口对照):
- `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 在 WS_OVERLAPPEDWINDOW / WS_POPUP /
WS_POPUP|WS_EX_LAYERED(α=0.8) 上**全部成功** ⇒ 无边框与整窗 α 不会让属性设不上;
- `DwmGetWindowAttribute` 同一属性三种样式**全部 E_INVALIDARG** ⇒ 本机不支持回读,与样式无关;
- `DWMWA_EXTENDED_FRAME_BOUNDS − GetWindowRect`:有边框 = +0,−7,popup = +0,+0
⇒ DWM 只给有边框窗口加不可见调整带,我们的窗口没有可画的圆角区域(结构性解释)。
- 像素(角上透明/方块)**未验**:RDP 断连导致 CopyFromScreen / gdigrab 全废。
同时修掉一个自己踩到的判据错误:`i32::unsigned_abs()` 不能用来打印 HRESULT
(E_INVALIDARG 0x80070057 会被印成 0x7FF8FFA9)。现在 HRESULT 用 `hr as u32` + 严重性位,
GetLastError 的 DWORD 语义保持 `unsigned_abs` 并写死注释(两者不同源,别统一)。
配置:新键 `appearance.window_corner ∈ {system, square}`(出厂 system),schema 1.5.0 只推 minor、
不写迁移:老文件缺键 = 取默认 = 跟随系统;旧版本读新文件时未知键被登记并原样写回(`RenderMode::Full`),
只在显式 `store.set` 该键时才 `UnknownKey`。
设置 UI:外观组只留两档(跟随系统 / 直角)+ 一句按平台说清的说明,文案走 i18n 中英。
日志:`[corner] <ctx> corner=policy=… source=… os=… excluded_reason=… radius=system pref=… readback=… frame=… layered=…`
(形状由 `corner::format_line` 的格式化单测钉住,用探针窗口真实读数构造)。
证据:DevDocs/artifacts/m116/{README.md,probe-table.md}
308 lines
12 KiB
Rust
308 lines
12 KiB
Rust
//! 「外观(高级)」配置簇的读写(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) |
|
||
//! | `appearance.window_corner` | `system` / `square` | `system` | 只存策略;「这台机器能不能圆」的判定在 `app-iced` 的 `platform`(M116) |
|
||
//!
|
||
//! 非法枚举**不报错**但也**不静默**:[`diagnostics`] 给出「读到什么 → 生效什么」的可读说明。
|
||
|
||
use serde_json::Value;
|
||
|
||
use super::document::SettingDocument;
|
||
use super::store::SettingStore;
|
||
|
||
mod modes;
|
||
|
||
pub use modes::{
|
||
diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, WindowCorner, PALETTE_SOURCES,
|
||
TITLEBAR_MODES, WINDOW_CORNERS,
|
||
};
|
||
|
||
#[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,
|
||
/// 窗口外框圆角策略(M116;默认 [`WindowCorner::System`] = 跟随系统)。
|
||
///
|
||
/// 只管用户要什么:真能不能圆由平台决定(Win11/Server 2025 走 DWM,Win10 保持直角),
|
||
/// 判定在 `unlucky-app-iced` 的 `platform`,core 不探测系统。
|
||
pub window_corner: WindowCorner,
|
||
}
|
||
|
||
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,
|
||
window_corner: WindowCorner::System,
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}),
|
||
window_corner: text("appearance.window_corner")
|
||
.map_or(default.window_corner, |value| {
|
||
WindowCorner::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()),
|
||
)?;
|
||
store.set(
|
||
"appearance.window_corner",
|
||
Value::from(self.window_corner.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)
|
||
}
|
||
}
|