Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/state.rs
T
AskaEth 88dbf6d955 feat(app-iced): M116 窗口外框圆角策略(跟随系统/直角)+ DWM 读写的受控对照实验
用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
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}
2026-11-05 06:35:03 +08:00

391 lines
14 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.
//! 外观的**运行期状态**:配置值 → 生效值(钳位 / CPU 档降级)→ 要下发的字节与要画的格子。
//!
//! 这一层是「键 → 像素」的中间站,App 持有一份(`crate::app::state::App::appearance`):
//!
//! ```text
//! launcher.json ──SettingsBridge::load/load_base──┐
//! ▼
//! State(本文件)
//! ┌────────────┴────────────┐
//! 整窗 α → 字节 │ │ 面板 α / 背景图 → Element
//! ▼ ▼
//! app::window_alpha(FFI) shell / 设置页(像素)
//! ```
//!
//! **纯的与不纯的**分界刻意写在这里:钳位链([`super::alpha`])、几何([`super::background`])、
//! 优先序([`super::palette`])都是纯函数;本文件只把它们的输入输出连起来,并把「读盘失败」
//! 变成一条**可读诊断**而不是一个 `unwrap`。
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_presenter::prefs::{
AppearanceBase, AppearancePrefs, BgFill, PaletteSource, WindowCorner,
};
use crate::Backend;
use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque};
use super::background::{self, Image};
use super::palette::{self, Resolved, Source};
/// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。
pub const ENV_WINDOW_ALPHA: &str = "ULL_APPEARANCE_WINDOW_ALPHA";
/// 面板 α。
pub const ENV_PANEL_ALPHA: &str = "ULL_APPEARANCE_PANEL_ALPHA";
/// 背景图路径。
pub const ENV_BG_IMAGE: &str = "ULL_APPEARANCE_BG_IMAGE";
/// 填充档(`cover` / `contain` / `tile`)。
pub const ENV_BG_FILL: &str = "ULL_APPEARANCE_BG_FILL";
/// 暗化强度。
pub const ENV_BG_DIM: &str = "ULL_APPEARANCE_BG_DIM";
/// 配色来源档(`background-image` / `wallpaper` / `preset`)。
pub const ENV_PALETTE: &str = "ULL_APPEARANCE_PALETTE";
/// 外观的运行期状态。
#[derive(Debug, Clone, PartialEq)]
pub struct State {
/// 用户请求的整窗 α(未钳位原值;下发前过 [`super::alpha`])。
pub window_opacity: f32,
/// 用户请求的面板 α(CPU 档会被强制成 1.0,见 [`State::effective_panel_alpha`])。
pub panel_alpha: f32,
/// 背景图总开关。
pub bg_enabled: bool,
/// 背景图路径(空 = 未设置)。
pub bg_path: String,
/// 填充档。
pub bg_fill: BgFill,
/// 暗化强度。
pub bg_dim: f32,
/// 配色来源档。
pub palette_source: PaletteSource,
/// 玻璃拟态开关(CPU 档强制关)。
pub glass: bool,
/// 窗口圆角策略(M116:`system` / `square`;真能不能圆由平台判,见 `platform::plan_corner`)。
pub window_corner: WindowCorner,
/// 渲染后端(决定 CPU 档的两条降级)。
pub backend: Backend,
/// 设置页当前分组(0 = General … 1 = Appearance;见 [`super::APPEARANCE_GROUP`])。
pub selected_group: usize,
/// 已解码的背景图(`None` = 没设 / 读失败)。
image: Option<Image>,
/// 读盘/解码失败的**可读诊断**(按发生顺序追加)。
diagnostics: Vec<String>,
/// 上一次的配色解析结果。
resolved: Option<Resolved>,
/// 当前深浅(配色解析的输入)。
flavor: SchemeFlavor,
}
impl State {
/// 从配置读一份(启动时调一次)。
#[must_use]
pub fn new(
prefs: &AppearancePrefs,
base: &AppearanceBase,
backend: Backend,
flavor: SchemeFlavor,
) -> Self {
let mut state = Self {
window_opacity: base.window_opacity,
panel_alpha: prefs.panel_alpha,
bg_enabled: prefs.bg_image_enabled,
bg_path: prefs.bg_image.clone(),
bg_fill: prefs.bg_fill,
bg_dim: prefs.bg_dim,
palette_source: base.palette_source,
window_corner: base.window_corner,
glass: prefs.glass,
backend,
selected_group: super::APPEARANCE_GROUP,
image: None,
diagnostics: Vec::new(),
resolved: None,
flavor,
};
state.refresh_background();
state.refresh_palette();
state
}
/// 截图/真机取证用:把 `ULL_APPEARANCE_*` 灌进来(不设 = 不动)。
///
/// 为什么用环境变量而不是 CLI 参数:`crates/app-iced/src/cli.rs` 不在本 mission 的 scope。
/// 语义与既有取证开关(`ULL_TICK_TRACE`)一致:**只影响这次进程**,不落盘。
pub fn apply_env(&mut self) {
if let Some(value) = env_f32(ENV_WINDOW_ALPHA) {
self.window_opacity = value;
}
if let Some(value) = env_f32(ENV_PANEL_ALPHA) {
self.panel_alpha = value;
}
if let Some(value) = std::env::var_os(ENV_BG_IMAGE) {
let path = value.to_string_lossy().to_string();
self.bg_enabled = !path.trim().is_empty();
self.bg_path = path;
}
if let Some(value) = std::env::var_os(ENV_BG_FILL) {
self.bg_fill = BgFill::from_key_value(&value.to_string_lossy());
}
if let Some(value) = env_f32(ENV_BG_DIM) {
self.bg_dim = value;
}
if let Some(value) = std::env::var_os(ENV_PALETTE) {
self.palette_source = PaletteSource::from_key_value(&value.to_string_lossy());
}
self.refresh_background();
self.refresh_palette();
}
/// 面板 α 的**生效值**(CPU 档恒 1.0)。
#[must_use]
pub fn effective_panel_alpha(&self) -> f32 {
effective_panel_alpha(self.backend, self.panel_alpha)
}
/// 面板 α 是否被后端强制(设置页据此禁用滑块并给说明)。
#[must_use]
pub fn panel_is_forced(&self) -> bool {
panel_is_forced_opaque(self.backend)
}
/// 当前圆角策略(设置页显示选中项、日志与下发都用它)。
#[must_use]
pub fn window_corner(&self) -> WindowCorner {
self.window_corner
}
/// 改圆角策略(消息落点见 `app::update::appearance`;这里只管状态,不碰窗口)。
pub fn set_window_corner(&mut self, policy: WindowCorner) {
self.window_corner = policy;
}
/// 玻璃是否生效(CPU 档恒关)。
#[must_use]
pub fn effective_glass(&self) -> bool {
effective_glass(self.backend, self.glass)
}
/// 整窗 α 要下发的字节(`None` = 不调 FFI)。
#[must_use]
pub fn window_alpha_byte(&self) -> Option<u8> {
alpha::window_alpha_byte(self.backend, self.window_opacity)
}
/// 背景图是否真的会画(开关开 + 路径非空 + 解码成功)。
#[must_use]
pub fn background_active(&self) -> bool {
self.bg_enabled && self.image.is_some()
}
/// 已解码的背景图。
#[must_use]
pub fn image(&self) -> Option<&Image> {
self.image.as_ref()
}
/// 取色得到的方案(`None` = 色卡档,继续用令牌 baseline)。
///
/// 为什么色卡档不换 scheme:`resolve_source` 的色卡档走的是 `DEFAULT_CARD`(天蓝卡),
/// 与令牌 baseline 逐色不同;直接应用会把**全部既有验收图的像素**改掉。色卡档的视觉基线
/// 归令牌层(`Scheme::baseline`),这里只接管「取色真的成功」的那两档。
#[must_use]
pub fn derived_scheme(&self) -> Option<&Scheme> {
let resolved = self.resolved.as_ref()?;
if resolved.level() == unlucky_design::palette::Level::Preset {
return None;
}
Some(&resolved.scheme)
}
/// 读盘/解码失败的诊断(设置页逐条显示)。
#[must_use]
pub fn diagnostics(&self) -> &[String] {
&self.diagnostics
}
/// CPU 档降级的说明(`None` = 没有降级)。
#[must_use]
pub fn backend_note(&self) -> Option<&'static str> {
self.panel_is_forced().then_some(
"Software renderer: panel transparency and glass stay fully opaque (window \
transparency still works).",
)
}
/// 配色来源的提示(`None` = 一级成功)。
#[must_use]
pub fn palette_note(&self) -> Option<&'static str> {
self.resolved.as_ref()?.note()
}
/// 生效的配色层级码(日志用)。
#[must_use]
pub fn palette_level(&self) -> &'static str {
match &self.resolved {
Some(resolved) => resolved.level().code(),
None => self.palette_source.as_str(),
}
}
/// 深浅切换后重算配色(`Ui` 换主题时调)。
pub fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.flavor == flavor {
return;
}
self.flavor = flavor;
self.refresh_palette();
}
/// 当前深浅。
#[must_use]
pub fn flavor(&self) -> SchemeFlavor {
self.flavor
}
/// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。
pub fn apply(&mut self, change: super::Change) -> bool {
use super::Change;
match change {
Change::WindowOpacity(alpha) => {
self.window_opacity = alpha;
true
}
Change::PanelAlpha(alpha) => {
self.panel_alpha = alpha;
false
}
Change::BgEnabled(enabled) => {
self.bg_enabled = enabled;
self.refresh_background();
self.refresh_palette();
false
}
Change::BgImage(path) => {
self.bg_path = path;
self.bg_enabled = !self.bg_path.trim().is_empty();
self.refresh_background();
self.refresh_palette();
false
}
Change::BgFill(fill) => {
self.bg_fill = fill;
false
}
Change::BgDim(dim) => {
self.bg_dim = dim;
false
}
Change::PaletteSource(source) => {
self.palette_source = source;
self.refresh_palette();
false
}
Change::Corner(policy) => {
self.window_corner = policy;
false
}
Change::SelectGroup(index) => {
self.selected_group = index;
false
}
}
}
/// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 8 个字段)。
///
/// 注意:**强调色 / 玻璃不在本 mission 的管辖内**,调用方(`app::appearance`)会拿配置里
/// 已有的快照做基底、只覆盖本 mission 真改了的字段,所以这里不产出完整快照。
/// 这个函数只给「当前值是什么」一个只读视图(日志与测试用)。
#[must_use]
pub fn current_values(&self) -> AppearancePrefs {
AppearancePrefs {
bg_image_enabled: self.bg_enabled,
bg_image: self.bg_path.clone(),
bg_fill: self.bg_fill,
bg_dim: self.bg_dim,
panel_alpha: self.panel_alpha,
glass: self.glass,
accent_color: String::new(),
}
}
/// 取证日志(**一行**,数值而不是观感):`[appearance] window_alpha=… panel_alpha=… byte=…`。
#[must_use]
pub fn log_line(&self, context: &str) -> String {
let byte = self
.window_alpha_byte()
.map_or_else(|| "none".to_owned(), |byte| byte.to_string());
format!(
"[appearance] {context} window_alpha={:.3} panel_alpha={:.3} (requested {:.3}) \
byte={byte} corner={} backend={} bg={} fill={} dim={:.2} palette={} group={}",
self.window_opacity,
self.effective_panel_alpha(),
self.panel_alpha,
self.window_corner.as_str(),
self.backend.name(),
if self.background_active() {
self.bg_path.as_str()
} else {
"none"
},
self.bg_fill.as_str(),
self.bg_dim,
self.palette_level(),
self.selected_group
)
}
/// 重读配置后同步(热切)。
pub fn reload(&mut self, prefs: &AppearancePrefs, base: &AppearanceBase) {
self.window_opacity = base.window_opacity;
self.palette_source = base.palette_source;
self.window_corner = base.window_corner;
self.panel_alpha = prefs.panel_alpha;
self.bg_enabled = prefs.bg_image_enabled;
self.bg_fill = prefs.bg_fill;
self.bg_dim = prefs.bg_dim;
self.glass = prefs.glass;
if self.bg_path != prefs.bg_image {
self.bg_path = prefs.bg_image.clone();
}
self.refresh_background();
self.refresh_palette();
}
// ------------------------------------------------------------ 内部
fn refresh_background(&mut self) {
self.image = None;
self.diagnostics.clear();
let trimmed = self.bg_path.trim();
if !self.bg_enabled || trimmed.is_empty() {
return;
}
match background::load(trimmed) {
Ok(image) => self.image = Some(image),
Err(error) => self.diagnostics.push(error.describe()),
}
}
fn refresh_palette(&mut self) {
let background = match (&self.image, self.bg_enabled) {
(Some(image), true) => Source::Pixels(image),
(None, true) if !self.bg_path.trim().is_empty() => Source::Undecodable,
_ => Source::Absent,
};
// 桌面壁纸探测(M73)尚未落地:这一级永远 `Absent`,回落链仍按 design 的规则走。
self.resolved = Some(palette::resolve(
self.palette_source,
background,
Source::Absent,
self.flavor,
));
}
}
fn env_f32(key: &str) -> Option<f32> {
std::env::var(key)
.ok()
.and_then(|value| value.trim().parse::<f32>().ok())
.filter(|value| value.is_finite())
}