feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源

把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。

整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
  (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
  `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
  先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。

面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
  CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。

背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
  iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。

设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
  背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
  bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
  走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
  `window_opacity` / `palette_source`。

取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
  截图通道不读用户配置(D30)。
This commit is contained in:
AskaEth committed 2026-11-02 18:07:31 +08:00
1 parent eae7ae0ff4
commit b8ab46662c
24 files changed
+3119 -48

No files matched your search

@@ -0,0 +1,386 @@
//! 外观的**运行期状态**:配置值 → 生效值(钳位 / 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};
use crate::Backend;
use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque};
use super::background::{self, Image, Mosaic};
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,
/// 渲染后端(决定 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,
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)
}
/// 玻璃是否生效(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()
}
/// 算一次马赛克(渲染层每帧调一次;纯函数,成本 = 格数)。
///
/// `toward` 是当前主题的面板底色(暗化往它退,见 [`background::dimmed`])。
#[must_use]
pub fn mosaic(&self, viewport: (f32, f32), toward: [u8; 3]) -> Option<Mosaic> {
let image = self.image.as_ref()?;
if !self.bg_enabled {
return None;
}
Some(background::mosaic(
image,
self.bg_fill,
viewport,
self.bg_dim,
toward,
))
}
/// 取色得到的方案(`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::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} backend={} bg={} fill={} dim={:.2} palette={} group={}",
self.window_opacity,
self.effective_panel_alpha(),
self.panel_alpha,
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.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())
}