用户问「为什么窗口没有圆角」。核实:组件层圆角齐,但窗口外框**零处理**——代码里没有任何
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}
391 lines
14 KiB
Rust
391 lines
14 KiB
Rust
//! 外观的**运行期状态**:配置值 → 生效值(钳位 / 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())
|
||
}
|