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,239 @@
//! 两层透明度的**判据层**:整窗 α / 面板 α 的钳位链、CPU 档降级、滑块映射。纯函数、零 iced。
//!
//! ## 为什么要分成「两层」
//!
//! 用户定案(D34 / D36):
//!
//! - **整窗 α**:整扇窗口(含自绘标题栏、导航轨、所有卡片)作为一个整体乘上 α。Windows 上唯一
//! 实现是 `SetLayeredWindowAttributes(LWA_ALPHA)`,DWM 常数乘法、成本≈0,所以**CPU 档也允许**;
//! - **面板 α**:只有面板/卡片这一层半透明,让背景图透出来。CPU 档**强制 1.0**(D36 对 D27 的
//! 修订:CPU 档被限制的是「多层半透明的合成成本」,不是窗口级常数乘法)。
//!
//! ## 真值表(三层 × 三个后端;`x` = 用户设置值)
//!
//! | 项 | GPU | CPU | auto | 下限 | 上限 | 钳位还是拒绝 |
//! |---|---|---|---|---|---|---|
//! | 整窗 α | `clamp(x, 0.08, 1.0)` | **同左**(D36 修订:CPU 也允许) | 同左 | 0.08 | 1.0 | 钳位;`x ≤ 0` 或非有限值 → **拒绝**(整窗穿透) |
//! | 面板 α | `clamp(x, 0.4, 1.0)` | **恒 1.0** | `clamp(x, 0.4, 1.0)`(auto 是探测后的择优档) | 0.4 | 1.0 | 钳位 |
//! | 玻璃 | `x` | **恒 false** | `x` | — | — | 无(布尔) |
//!
//! 「auto」按**请求值**判(`Backend::Auto` 是用户没指定、让探测决定):它不走 CPU 的强制分支,
//! 因为「强制 1.0」是 CPU 渲染档的降级规则,而探测结果可能是 GPU。真落到 tiny-skia 时
//! `probe` 会写 `ICED_BACKEND=tiny-skia`,但那时 `Options::backend` 仍是 `Auto`——这条已知
//! 张力登记在 [`crate::pages::appearance`] 的偏离表里(要收口得让探测结果回灌 `Backend`)。
//!
//! ## 两条链为什么都在本文件
//!
//! 它们共用一个**滑块映射**与一套钳位纪律,分开写迟早会出现「滑块给 0.0、钳位给 0.08、
//! 日志又打 0.0」这种三处各说一套的事故。整窗 α 的**执行**(FFI)在
//! `crate::platform::win32::transparency`,本文件只给「该下发什么」与「下发成什么字节」。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::platform::effective_window_alpha;
use crate::platform::win32::transparency::alpha_byte;
use crate::Backend;
/// 整窗 α 下限的 `f32` 形态(唯一真源仍是 `unlucky_presenter::prefs::WINDOW_ALPHA_MIN`)。
pub const WINDOW_ALPHA_FLOOR: f32 = WINDOW_ALPHA_MIN as f32;
/// 面板 α 下限(0.4)。
///
/// 与 `unlucky_core::setting::appearance::PANEL_ALPHA_MIN` **同值**,但只能在这里再写一遍:
/// `unlucky-presenter` 没有重导出它(只重导出了 `WINDOW_ALPHA_MIN`),而 `app-iced`
/// 不直接依赖 core。已报 finding,收口方式是让 presenter 重导出。
pub const PANEL_ALPHA_FLOOR: f32 = 0.4;
/// 整窗 α 滑块位置(0..=1)→ 请求值(0.08..=1.0)。
#[must_use]
pub fn window_alpha_from_fraction(fraction: f32) -> f32 {
let fraction = sanitize_fraction(fraction);
WINDOW_ALPHA_FLOOR + fraction * (1.0 - WINDOW_ALPHA_FLOOR)
}
/// 请求值 → 滑块位置([`window_alpha_from_fraction`] 的逆;钳位后落在 0..=1)。
#[must_use]
pub fn window_fraction(alpha: f32) -> f32 {
let alpha = clamp(alpha, WINDOW_ALPHA_FLOOR, 1.0);
(alpha - WINDOW_ALPHA_FLOOR) / (1.0 - WINDOW_ALPHA_FLOOR)
}
/// 面板 α 滑块位置(0..=1)→ 请求值(0.4..=1.0)。
#[must_use]
pub fn panel_alpha_from_fraction(fraction: f32) -> f32 {
let fraction = sanitize_fraction(fraction);
PANEL_ALPHA_FLOOR + fraction * (1.0 - PANEL_ALPHA_FLOOR)
}
/// 请求值 → 滑块位置。
#[must_use]
pub fn panel_fraction(alpha: f32) -> f32 {
let alpha = clamp(alpha, PANEL_ALPHA_FLOOR, 1.0);
(alpha - PANEL_ALPHA_FLOOR) / (1.0 - PANEL_ALPHA_FLOOR)
}
/// 该后端是否**强制**面板不透明(D36:CPU 档面板 α / 玻璃 / 模糊一律 1.0)。
#[must_use]
pub fn panel_is_forced_opaque(backend: Backend) -> bool {
matches!(backend, Backend::Cpu)
}
/// 面板 α 的生效值:CPU 档恒 1.0,其余钳到 `0.4..=1.0`。
#[must_use]
pub fn effective_panel_alpha(backend: Backend, requested: f32) -> f32 {
if panel_is_forced_opaque(backend) {
return 1.0;
}
clamp(requested, PANEL_ALPHA_FLOOR, 1.0)
}
/// 玻璃拟态卡片的生效值:CPU 档恒关(它要的是多层半透明 + 模糊底,那是 D27 的动机)。
#[must_use]
pub fn effective_glass(backend: Backend, requested: bool) -> bool {
requested && !panel_is_forced_opaque(backend)
}
/// 整窗 α 最终**下发到 Win32 的字节**(`None` = 不该下发:`α ≥ 1` 或整窗穿透被拒绝)。
///
/// 这条链是「配置值 → 判据 → 量化字节」的唯一定义点:
/// `effective_window_alpha`(钳位/拒绝)→ [`alpha_byte`](**向上取整**,永不低于下限)。
#[must_use]
pub fn window_alpha_byte(backend: Backend, requested: f32) -> Option<u8> {
effective_window_alpha(backend, f64::from(requested))
.filter(|alpha| *alpha < 1.0)
.map(alpha_byte)
}
fn sanitize_fraction(fraction: f32) -> f32 {
clamp(fraction, 0.0, 1.0)
}
fn clamp(value: f32, min: f32, max: f32) -> f32 {
if value.is_nan() {
min
} else {
value.clamp(min, max)
}
}
#[cfg(test)]
mod tests {
use super::{
effective_glass, effective_panel_alpha, panel_alpha_from_fraction, panel_fraction,
panel_is_forced_opaque, window_alpha_byte, window_alpha_from_fraction, window_fraction,
PANEL_ALPHA_FLOOR, WINDOW_ALPHA_FLOOR,
};
use crate::Backend;
const BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto];
#[test]
fn floor_values_are_the_ones_the_user_decided() {
// 抓得住:有人把下限改回 0(= 整窗穿透)或把面板下限降到「前景压不住背景图」。
// 抓不住:数值本身是否来自令牌(`WINDOW_ALPHA_FLOOR` 由 presenter 重导出,这条能对;
// 面板下限是本地字面量,只能钉 0.4 这个事实)。
assert_eq!(WINDOW_ALPHA_FLOOR, 0.08);
assert_eq!(PANEL_ALPHA_FLOOR, 0.4);
}
/// 任务要求的**钳位链**:配置写 0.8 → 判据放行 0.8 → 下发字节 205。
///
/// 抓得住:①`effective_window_alpha` 把 0.8 原样放行(CPU 档也放行,D36 修订);
/// ②**向上取整**这条纪律(`f32` 的 0.8 扩到 `f64` 是 0.8000000119…,×255 = 204.000003,
/// ceil = **205**;若谁改成 `round` 就会变 204 并在这里红——那正是「实际下发可能低于下限」
/// 的同一个坑);③`α ≥ 1` 时**不下发**(`None`,而不是 255)。
/// 抓不住:这 205 个字节真的被 Win32 收下(FFI 成败由 `transparency` 的单测与本机日志举证)。
#[test]
fn the_clamp_chain_reports_the_byte_that_would_be_sent() {
for backend in BACKENDS {
assert_eq!(
window_alpha_byte(backend, 0.8),
Some(205),
"{backend:?}:0.8 → 205(f32→f64 略大于 0.8,ceil 上去)"
);
assert_eq!(
window_alpha_byte(backend, 0.5),
Some(128),
"{backend:?}:0.5×255=127.5 → 128"
);
assert_eq!(
window_alpha_byte(backend, 1.0),
None,
"{backend:?}:不透明不下发"
);
assert_eq!(
window_alpha_byte(backend, 0.001),
Some(21),
"{backend:?}:低于下限先夹到 0.08,再向上取整 21(0.0824 ≥ 0.08)"
);
assert_eq!(
window_alpha_byte(backend, -1.0),
None,
"{backend:?}:整窗穿透被拒绝,不给字节"
);
assert_eq!(window_alpha_byte(backend, f32::NAN), None);
}
}
/// D36 的核心分界:**CPU 档允许整窗 α,强制面板 1.0**。
///
/// 抓得住:谁把这条写反(CPU 档整窗 α 也强制 1.0,或 CPU 档面板 α 跟着用户值走)。
/// 抓不住:真实 CPU 档下窗口是否真的半透明(观感未验,见报告「没验的部分」)。
#[test]
fn cpu_tier_allows_the_window_alpha_but_forces_the_panel_opaque() {
assert!(
window_alpha_byte(Backend::Cpu, 0.5).is_some(),
"CPU 档允许整窗 α"
);
assert!(panel_is_forced_opaque(Backend::Cpu));
assert_eq!(
effective_panel_alpha(Backend::Cpu, 0.5),
1.0,
"CPU 档面板强制 1.0"
);
assert!(!effective_glass(Backend::Cpu, true), "CPU 档玻璃强制关");
// 另外两档不做这个降级(否则「强制」就失去了意义)。
for backend in [Backend::Gpu, Backend::Auto] {
assert!(!panel_is_forced_opaque(backend), "{backend:?} 不强制");
assert_eq!(effective_panel_alpha(backend, 0.5), 0.5);
assert!(effective_glass(backend, true));
}
}
#[test]
fn panel_alpha_is_clamped_into_range() {
for backend in [Backend::Gpu, Backend::Auto] {
assert_eq!(effective_panel_alpha(backend, 0.0), PANEL_ALPHA_FLOOR);
assert_eq!(effective_panel_alpha(backend, 0.2), PANEL_ALPHA_FLOOR);
assert_eq!(effective_panel_alpha(backend, 3.0), 1.0);
assert_eq!(effective_panel_alpha(backend, f32::NAN), PANEL_ALPHA_FLOOR);
}
}
#[test]
fn slider_fractions_round_trip_without_leaving_the_range() {
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
let alpha = window_alpha_from_fraction(fraction);
assert!(
(WINDOW_ALPHA_FLOOR..=1.0).contains(&alpha),
"{fraction} → {alpha}"
);
assert!((window_fraction(alpha) - fraction).abs() < 1e-6);
}
// 越界输入(滑块自己不会给,但别让下游吃 NaN)
assert_eq!(window_alpha_from_fraction(-1.0), WINDOW_ALPHA_FLOOR);
assert_eq!(window_alpha_from_fraction(9.0), 1.0);
assert_eq!(window_alpha_from_fraction(f32::NAN), WINDOW_ALPHA_FLOOR);
for fraction in [0.0, 0.5, 1.0] {
let alpha = panel_alpha_from_fraction(fraction);
assert!((PANEL_ALPHA_FLOOR..=1.0).contains(&alpha));
assert!((panel_fraction(alpha) - fraction).abs() < 1e-6);
}
// 0.4 与 1.0 是滑块两端:端点必须精确(否则「拖到头还不是 1.0」)
assert_eq!(panel_alpha_from_fraction(1.0), 1.0);
assert_eq!(panel_fraction(1.0), 1.0);
assert_eq!(window_alpha_from_fraction(1.0), 1.0);
assert_eq!(window_fraction(WINDOW_ALPHA_FLOOR), 0.0);
}
}
@@ -0,0 +1,357 @@
//! 背景图:**读盘解码 → 填充几何 → 暗化 → 马赛克取样**。纯函数(除 [`load`] 一次 IO)。
//!
//! ## 三种填充(`appearance.bg_fill`,core 的 [`BgFill`])
//!
//! | 档 | 缩放 | 位置 | 露底色 |
//! |---|---|---|---|
//! | `cover` | `max(vw/iw, vh/ih)`(铺满、裁掉溢出) | 居中 | 否 |
//! | `contain` | `min(vw/iw, vh/ih)`(完整显示) | 居中 | **是**(上下或左右留底色) |
//! | `tile` | `1`(原始尺寸平铺) | 从视口左上角起重复 | 否 |
//!
//! 几何是**纯函数**([`placement`] / [`source_point`]),因此三种档的差异可以被数值断言,
//! 不必靠肉眼比对截图。
//!
//! ## 暗化(`appearance.bg_dim`)
//!
//! 语义是「让背景往面板底色退」:`rgb + (toward − rgb) × dim`(`toward` = 当前主题的 Canvas 色)。
//!
//! 为什么**不是**「叠一层黑」:叠黑在深色主题里正合适,但在浅色主题下会把亮图压成暗块,而压在
//! 它上面的深色正文反而更读不清。向底色退在两种主题下都是单调变好(浅色向白退、深色向黑退),
//! 也仍然是「一次乘法式的插值」——结果**不透明**,不依赖渲染器的 alpha 混合精度,
//! CPU 档与 GPU 档出图逐字节一致。
//!
//! 这条偏离(相对「暗化 = 乘黑」的字面语义)登记在 [`super`] 的偏离表 #7。
//!
//! ## 为什么是「马赛克」而不是直接画图
//!
//! iced 0.14 的 `widget::image` 需要 `iced/image` feature,而本 crate 的 iced 只开了
//! `tiny-skia / crisp / advanced / svg / thread-pool`,`Cargo.toml` 不在本 mission 的 scope
//! (已登记偏离 #1)。所以这里把解码后的像素按 [`CELL_PX`] 的网格做**块平均**,每格画一个实心
//! 方块:填充几何、暗化、取色全部是真的,只有「每格一个颜色」这一条是降级。
//! 网格上限 [`MAX_COLS`]×[`MAX_ROWS`],成本有界(1180×760 下约 33×22 个方块)。
use unlucky_presenter::prefs::BgFill;
/// 暗化上限(与 core 的 `BG_DIM_MAX` 同值 0.95):再高背景图基本看不见,等于关掉。
pub const BG_DIM_MAX: f32 = 0.95;
/// 解码后保留的最大边长:取样与取色都不需要分辨率,4K 图缩到 256 边后内存从 33MB 掉到 147KB。
pub const MAX_EDGE: u32 = 256;
/// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。
pub const MAX_SOURCE_EDGE: u32 = 16_384;
/// 马赛克网格的目标格边长(dp)。
pub const CELL_PX: f32 = 36.0;
/// 马赛克网格上限(列 / 行)——渲染成本的天花板。
pub const MAX_COLS: u32 = 48;
/// 马赛克网格上限(行)。
pub const MAX_ROWS: u32 = 32;
/// 解码失败的原因(人话在 [`LoadError::describe`])。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
/// 读文件失败:不存在 / 没权限 / 被占用。
Io(String),
/// 解码失败:格式不认识(本 build 只带 png 编解码器)/ 文件截断 / 不是图片。
Undecodable(String),
/// 图片头里的尺寸超限。
TooLarge {
/// 宽。
width: u32,
/// 高。
height: u32,
},
}
impl LoadError {
/// 可读诊断(设置页提示行与日志共用同一句)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Io(reason) => format!("Background image could not be read: {reason}"),
Self::Undecodable(reason) => format!(
"Background image could not be decoded ({reason}; this build decodes PNG only)"
),
Self::TooLarge { width, height } => format!(
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
),
}
}
}
/// 一张解码好的背景图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// **原图**尺寸(像素):填充几何按它算(`tile` = 原始尺寸平铺)。
pub source: (u32, u32),
/// 缩略图宽(≤ [`MAX_EDGE`])。
pub width: u32,
/// 缩略图高(≤ [`MAX_EDGE`])。
pub height: u32,
/// 缩略图 RGBA8(`width × height × 4`):取样与取色都用它。
pub rgba: Vec<u8>,
}
impl Image {
/// 从任意解码结果构造(测试也走这里,避免测试依赖磁盘)。
#[must_use]
pub fn from_rgba(source: (u32, u32), width: u32, height: u32, rgba: Vec<u8>) -> Self {
Self {
source,
width,
height,
rgba,
}
}
}
/// 读盘 + 解码 + 缩到 [`MAX_EDGE`] 内。
///
/// # Errors
/// 见 [`LoadError`]:读不到 / 解不开 / 头部尺寸超限。**不 return 默认色**——回落的决定在
/// [`super::palette`](它会往 `Provenance` 里留原因),这一层只管如实报告失败。
pub fn load(path: &str) -> Result<Image, LoadError> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(LoadError::Io("no path configured".to_owned()));
}
// 第一趟只读图头:先按尺寸拒掉压缩炸弹,再真解码。
let (source_w, source_h) = header_size(trimmed)?;
if source_w > MAX_SOURCE_EDGE || source_h > MAX_SOURCE_EDGE {
return Err(LoadError::TooLarge {
width: source_w,
height: source_h,
});
}
let decoded = open(trimmed)?
.decode()
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
let (width, height) = (decoded.width().max(1), decoded.height().max(1));
let scale = (f64::from(MAX_EDGE) / f64::from(width.max(height))).min(1.0);
let target_w = ((f64::from(width) * scale).round() as u32).max(1);
let target_h = ((f64::from(height) * scale).round() as u32).max(1);
let thumb = image::imageops::resize(
&decoded,
target_w,
target_h,
image::imageops::FilterType::Triangle,
);
Ok(Image::from_rgba(
(source_w, source_h),
target_w,
target_h,
thumb.into_raw(),
))
}
fn open(path: &str) -> Result<image::ImageReader<std::io::BufReader<std::fs::File>>, LoadError> {
let reader =
image::ImageReader::open(path).map_err(|error| LoadError::Io(error.to_string()))?;
reader
.with_guessed_format()
.map_err(|error| LoadError::Undecodable(error.to_string()))
}
fn header_size(path: &str) -> Result<(u32, u32), LoadError> {
let reader = open(path)?;
if reader.format().is_none() {
return Err(LoadError::Undecodable(
"unknown image format (magic bytes not recognised)".to_owned(),
));
}
reader
.into_dimensions()
.map_err(|error| LoadError::Undecodable(error.to_string()))
}
/// 填充几何:源图 → 视口 的映射。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Placement {
/// 缩放比:源图 1 像素 = `scale` dp。
pub scale: f32,
/// 源图左上角落在视口里的位置(dp;`cover` 时是负值 = 已经裁掉溢出)。
pub origin: (f32, f32),
/// 是否平铺(只有 `tile`)。
pub repeat: bool,
}
impl Placement {
/// 源图缩放后的尺寸(dp)。
#[must_use]
pub fn scaled(&self, source: (u32, u32)) -> (f32, f32) {
(source.0 as f32 * self.scale, source.1 as f32 * self.scale)
}
}
/// 按填充档算一次几何(纯函数)。
#[must_use]
pub fn placement(fill: BgFill, source: (u32, u32), viewport: (f32, f32)) -> Placement {
let source_f = (source.0.max(1) as f32, source.1.max(1) as f32);
let viewport = (viewport.0.max(1.0), viewport.1.max(1.0));
let scale = match fill {
BgFill::Cover => (viewport.0 / source_f.0).max(viewport.1 / source_f.1),
BgFill::Contain => (viewport.0 / source_f.0).min(viewport.1 / source_f.1),
BgFill::Tile => 1.0,
};
let scaled = (source_f.0 * scale, source_f.1 * scale);
let origin = match fill {
// 平铺从视口左上角起(官方 Contain/Tile 的语义:不居中,直接重复)
BgFill::Tile => (0.0, 0.0),
_ => ((viewport.0 - scaled.0) / 2.0, (viewport.1 - scaled.1) / 2.0),
};
Placement {
scale,
origin,
repeat: matches!(fill, BgFill::Tile),
}
}
/// 视口点 → 源图像素坐标(未取整)。`None` = 该点落在图像之外(`contain` 的留底色区)。
#[must_use]
pub fn source_point(
placement: &Placement,
source: (u32, u32),
point: (f32, f32),
) -> Option<(f32, f32)> {
let source_f = (source.0.max(1) as f32, source.1.max(1) as f32);
let mut x = (point.0 - placement.origin.0) / placement.scale;
let mut y = (point.1 - placement.origin.1) / placement.scale;
if placement.repeat {
x = x.rem_euclid(source_f.0);
y = y.rem_euclid(source_f.1);
} else if !(0.0..source_f.0).contains(&x) || !(0.0..source_f.1).contains(&y) {
return None;
}
Some((x, y))
}
/// 取缩略图上一个像素(最近邻;缩略图已经 ≤256 边,够用)。
#[must_use]
pub fn sample(image: &Image, point: (f32, f32)) -> [u8; 3] {
let x = (point.0 as u32).min(image.width.saturating_sub(1));
let y = (point.1 as u32).min(image.height.saturating_sub(1));
let index = ((y * image.width + x) * 4) as usize;
let rgba = &image.rgba;
[
rgba.get(index).copied().unwrap_or(0),
rgba.get(index + 1).copied().unwrap_or(0),
rgba.get(index + 2).copied().unwrap_or(0),
]
}
/// 暗化:把取样色按 `dim` **混向面板底色**(`rgb + (toward − rgb) × dim`)。
///
/// 为什么混向底色而不是乘一个「黑」:乘黑在深色主题里正合适,但浅色主题下会让一张亮图变成
/// 深色块、而压在其上的深色正文就更难读。混向 `toward`(= 当前主题的 Canvas 色)在两种主题下
/// 都是「往底色的方向退」,正文对比度因此单调变好——这与 `appearance.bg_dim` 的用途
/// (「让前景压得住背景」)一致。`dim = 0` 时逐字节不变。
#[must_use]
pub fn dimmed(rgb: [u8; 3], dim: f32, toward: [u8; 3]) -> [u8; 3] {
let keep = clamp_dim(dim);
let channel = |index: usize| {
let from = f32::from(rgb[index]);
let to = f32::from(toward[index]);
(from + (to - from) * keep).round().clamp(0.0, 255.0) as u8
};
[channel(0), channel(1), channel(2)]
}
/// 暗化系数的唯一钳位点(0 = 不暗化,0.95 = 上限)。
#[must_use]
pub fn clamp_dim(dim: f32) -> f32 {
if dim.is_nan() {
0.0
} else {
dim.clamp(0.0, BG_DIM_MAX)
}
}
/// 一次渲染要画的马赛克:按行主序铺的格子色(`None` = 露底色)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mosaic {
/// 列数。
pub cols: u32,
/// 行数。
pub rows: u32,
/// 每格颜色(`rows × cols`,行主序)。
pub cells: Vec<Option<[u8; 3]>>,
}
impl Mosaic {
/// 第 `row` 行第 `col` 列的颜色(越界给 `None`)。
#[must_use]
pub fn at(&self, row: u32, col: u32) -> Option<[u8; 3]> {
if row >= self.rows || col >= self.cols {
return None;
}
self.cells
.get((row * self.cols + col) as usize)
.copied()
.flatten()
}
/// 网格尺寸(格边长 dp = `视口 / 格数`,所以调用方可以只用 `Length::Fill` 拼行)。
#[must_use]
pub fn cell_size(&self, viewport: (f32, f32)) -> (f32, f32) {
(
viewport.0 / self.cols.max(1) as f32,
viewport.1 / self.rows.max(1) as f32,
)
}
}
/// 视口 → 网格尺寸(格边长 ≈ [`CELL_PX`],列/行各有上限)。
#[must_use]
pub fn grid(viewport: (f32, f32)) -> (u32, u32) {
let cols = div_ceil(viewport.0.max(1.0), CELL_PX).clamp(1, MAX_COLS);
let rows = div_ceil(viewport.1.max(1.0), CELL_PX).clamp(1, MAX_ROWS);
(cols, rows)
}
/// 算一整张马赛克(纯函数:同一图 + 同一视口 + 同一底色 → 同一结果)。
#[must_use]
pub fn mosaic(
image: &Image,
fill: BgFill,
viewport: (f32, f32),
dim: f32,
toward: [u8; 3],
) -> Mosaic {
let placement = placement(fill, image.source, viewport);
let (cols, rows) = grid(viewport);
let (cell_w, cell_h) = (
viewport.0.max(1.0) / cols as f32,
viewport.1.max(1.0) / rows as f32,
);
let mut cells = Vec::with_capacity((cols * rows) as usize);
for row in 0..rows {
for col in 0..cols {
let centre = ((col as f32 + 0.5) * cell_w, (row as f32 + 0.5) * cell_h);
cells.push(
source_point(&placement, image.source, centre)
.map(|point| dimmed(sample(image, point), dim, toward)),
);
}
}
Mosaic { cols, rows, cells }
}
fn div_ceil(value: f32, step: f32) -> u32 {
let count = (value / step).ceil();
if count.is_finite() && count >= 1.0 {
count as u32
} else {
1
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,200 @@
//! `background` 的单测:填充几何 / 暗化 / 马赛克 / 解码失败。
//!
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:
//! 能断言的(几何、通道值、错误分类)必须断言;观感(马赛克好不好看、真图直出)不在这里假装。
use super::{
clamp_dim, dimmed, grid, load, mosaic, placement, sample, source_point, BgFill, Image,
LoadError, BG_DIM_MAX, MAX_COLS, MAX_ROWS,
};
use std::path::{Path, PathBuf};
/// 8×4 的合成图:左半红、右半蓝(缩略图与原图同尺寸,取样结果因此可预测)。
fn halves() -> Image {
let (width, height) = (8_u32, 4_u32);
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for _row in 0..height {
for col in 0..width {
if col < width / 2 {
rgba.extend_from_slice(&[200, 40, 40, 255]);
} else {
rgba.extend_from_slice(&[40, 60, 200, 255]);
}
}
}
Image::from_rgba((width, height), width, height, rgba)
}
/// 一整块纯色的合成图(只关心几何时用)。
fn solid(size: (u32, u32), rgb: [u8; 3]) -> Image {
let rgba = [rgb[0], rgb[1], rgb[2], 255].repeat((size.0 * size.1) as usize);
Image::from_rgba(size, size.0, size.1, rgba)
}
fn temp_png(label: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("ull-bg-{label}-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(&dir).expect("临时目录");
dir.join("bg.png")
}
fn cleanup(path: &Path) {
if let Some(dir) = path.parent() {
std::fs::remove_dir_all(dir).ok();
}
}
#[test]
fn cover_fills_the_viewport_and_contain_keeps_the_aspect_ratio() {
// 抓得住:两种档的 scale 公式(铺满 vs 完整)与居中偏移的正负号。
// 抓不住:真实的裁切/留白在屏幕上的观感(那要看截图)。
let source = (8, 4); // 2:1
let viewport = (400.0, 400.0);
// cover = max(400/8 = 50, 400/4 = 100) = 100 → 缩放后 800×400,横向溢出 400 被裁。
let cover = placement(BgFill::Cover, source, viewport);
assert_eq!(cover.scale, 100.0, "cover = max(50, 100)");
assert_eq!(cover.origin, (-200.0, 0.0), "cover 居中后横向溢出被裁");
// contain = min(50, 100) = 50 → 缩放后 400×200,上下各留 100 的底色。
let contain = placement(BgFill::Contain, source, viewport);
assert_eq!(contain.scale, 50.0);
assert_eq!(contain.origin, (0.0, 100.0), "contain 上下留底色");
assert!(!contain.repeat && !cover.repeat);
}
#[test]
fn cover_never_leaves_a_hole_and_contain_does() {
let image = solid((8, 4), [10, 20, 30]);
let viewport = (360.0, 360.0);
let covered = mosaic(&image, BgFill::Cover, viewport, 0.0, [0, 0, 0]);
assert!(
covered.cells.iter().all(Option::is_some),
"cover 必须铺满(任何一格都不许是 None)"
);
let contained = mosaic(&image, BgFill::Contain, viewport, 0.0, [0, 0, 0]);
assert_eq!(contained.at(0, 0), None, "contain 的左上角是留底色");
assert_eq!(
contained.at(0, 0),
None,
"(同一格重复读:确保 at() 不是偶发给 None)"
);
let middle = (contained.rows / 2, contained.cols / 2);
assert!(contained.at(middle.0, middle.1).is_some(), "中间必须有图");
}
#[test]
fn tile_repeats_the_original_size_art_grid() {
// 抓得住:tile 的 scale 恒 1、origin 在左上角、坐标按图宽/图高取模。
// 抓不住:平铺后每格之间是否有一像素缝(那是渲染层的事)。
let image = halves();
let tile = placement(BgFill::Tile, image.source, (40.0, 40.0));
assert_eq!(tile, tile);
assert_eq!(tile.scale, 1.0);
assert_eq!(tile.origin, (0.0, 0.0));
assert!(tile.repeat);
// 同一源坐标:x 与 x + 图宽 必须取到同一像素。
let left = source_point(&tile, image.source, (3.5, 1.5)).expect("平铺内部总有图");
let wrapped = source_point(&tile, image.source, (3.5 + 8.0 * 3.0, 1.5 + 4.0 * 2.0))
.expect("取模后仍在图内");
assert!((left.0 - wrapped.0).abs() < 1e-4 && (left.1 - wrapped.1).abs() < 1e-4);
// 左半红 / 右半蓝:x=1 取红、x=6 取蓝(平铺不改变这一点)。
assert_eq!(
sample(
&image,
source_point(&tile, image.source, (1.0, 1.0)).unwrap()
),
[200, 40, 40]
);
assert_eq!(
sample(
&image,
source_point(&tile, image.source, (6.0, 1.0)).unwrap()
),
[40, 60, 200]
);
}
#[test]
fn dim_mixes_toward_the_panel_colour_and_is_clamped() {
// 抓得住:暗化 = 向底色方向插值(不是简单乘黑);dim 越界被夹到 0..=0.95。
// 语义的关键在浅色主题:向白退(而不是乘黑)才能让深色正文继续读得清。
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断,见报告「没验的部分」)。
assert_eq!(dimmed([200, 100, 50], 0.0, [255, 255, 255]), [200, 100, 50]);
assert_eq!(
dimmed([200, 100, 50], 0.35, [0, 0, 0]),
[130, 65, 33],
"深色主题:向黑退"
);
assert_eq!(
dimmed([200, 100, 50], 0.35, [255, 255, 255]),
[219, 154, 122],
"浅色主题:向白退"
);
assert_eq!(clamp_dim(-1.0), 0.0);
assert_eq!(clamp_dim(f32::NAN), 0.0);
assert_eq!(clamp_dim(9.0), BG_DIM_MAX);
assert_eq!(
dimmed([255, 255, 255], 9.0, [0, 0, 0]),
[13, 13, 13],
"夹到 0.95 → 只剩 5% 原色"
);
}
#[test]
fn grid_is_bounded_so_the_mosaic_cannot_explode() {
// 抓得住:超宽/超高视口下格数被上限截住(渲染成本有界),且至少 1×1。
// 抓不住:这个上限在真机上到底花多少毫秒(没做基准)。
assert_eq!(grid((1180.0, 760.0)), (33, 22));
let (cols, rows) = grid((100_000.0, 100_000.0));
assert_eq!((cols, rows), (MAX_COLS, MAX_ROWS));
assert_eq!(grid((0.0, 0.0)), (1, 1));
}
#[test]
fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 两条都必须是 **Err**(不许静默回一个默认色),且 describe() 里不含 panic 文案。
// 抓不住:本 build 只带 png 编解码器,jpeg/bmp 会在 Undecodable 里出现
// (「Unsigned format」/「The image format ... is not supported」)。
let missing = temp_png("missing");
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
assert!(error
.describe()
.starts_with("Background image could not be read"));
let broken = temp_png("broken");
std::fs::write(&broken, b"this is not a png").expect("写坏文件");
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
cleanup(&broken);
let empty = load(" ").expect_err("空路径必须报错");
assert!(matches!(empty, LoadError::Io(_)));
}
#[test]
fn a_real_png_decodes_and_is_scaled_down_to_the_sample_budget() {
// 抓得住:真磁盘 PNG 能读出来、原图尺寸保留、缩略图边长不超过 MAX_EDGE。
// 抓不住:解码后的颜色是否与它在屏幕上一致(色彩管理不在本 mission)。
let path = temp_png("ok");
let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90]));
source.save(&path).expect("写 png");
let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读");
assert_eq!(image.source, (300, 20), "原图尺寸按图头");
assert!(
image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE,
"缩略图边 {}×{} 超预算",
image.width,
image.height
);
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
assert_eq!(sample(&image, (0.0, 0.0)), [12, 200, 90], "纯色图取样不变");
cleanup(&path);
}
+165
View File
@@ -0,0 +1,165 @@
//! # 外观接线(M93 / APPEARWIRE):**键 → 像素**
//!
//! 本模块把 `appearance.*` 那批键接到真正的窗口与真正的像素上,并把「哪里是判据、哪里是观感」
//! 分开写清楚。底座(M91)已经给了键、钳位判据与唯一的不安全落点;这里补的是**调用方**。
//!
//! ## 两层透明度(D34 / D36)
//!
//! | 层 | 作用范围 | 实现 | CPU 档 | 下限 |
//! |---|---|---|---|---|
//! | 整窗 α | 整扇窗口(标题栏 + 导航轨 + 内容 + 卡片) | `SetLayeredWindowAttributes(LWA_ALPHA)`,DWM 常数乘法 | **允许** | 0.08(α=0 = 整窗穿透,拒绝) |
//! | 面板 α | 只有面板/卡片表面这一层(背景图能透出来) | 容器色的 alpha([`section`] / `shell`) | **强制 1.0** | 0.4 |
//!
//! 真值表在 [`alpha`] 的模块文档里(三层 × 三个后端,含「钳位还是拒绝」)。
//!
//! ## 模块分工
//!
//! | 文件 | 是什么 | 有没有 IO |
//! |---|---|---|
//! | [`alpha`] | 两层 α 的钳位链、CPU 档降级、滑块映射、下发字节 | 无(纯函数) |
//! | [`background`] | 背景图解码、三种填充几何、暗化、马赛克取样 | 读盘一次(`load`) |
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
//! | `section` | 设置页「外观」分组(真控件)+ 背景层 Element | 无 |
//!
//! ## 偏离登记(D30 要求逐条写清)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
//! |---|---|---|---|
//! | 1 | 背景图按 **36dp 网格马赛克**画,不是逐像素直出 | iced 0.14 的 `widget::image` 要 `iced/image` feature,本 crate 的 `Cargo.toml` 不在本 mission 的 scope。填充几何 / 暗化 / 取色全是真值,只有「每格一个颜色」是降级 | 开 `iced/image` feature 的 mission(一行 Cargo.toml) |
//! | 2 | 面板 α 只作用到**外壳层**(根容器 / 标题栏 / 导航轨)与本模块画的卡片 | `pages/frame/pieces.rs` 的 `card()` 是其它三页共用的实色卡片,不在本 mission 的 scope。于是「有背景图 + 面板 α<1」时,实例/下载/日志页的卡片仍是实色块 | 把 `card()` 的底色改成「面板层」(一个函数,改完三页同时生效) |
//! | 3 | 桌面壁纸取色(第二级)永远 `Absent` | 壁纸探测(M73)还没落地;优先序本身仍按 design 的三级走,测试里用合成像素证明第二级会被用到 | M73 |
//! | 4 | 色卡档**不换** `Ui` 的 scheme | `resolve_source` 的色卡档返回 `DEFAULT_CARD`(天蓝卡)的方案,与令牌 baseline 逐色不同;应用它会把全部既有验收图的像素改掉。取色档(①②)才换 | 色卡选择 UI 落地时一并决议 |
//! | 5 | `Backend::Auto` 不触发 CPU 档降级 | 「强制 1.0」是 CPU 渲染档的规则,而 `Auto` 是「探测前的请求值」;探测结果没有回灌 `Backend` | 让 `probe` 把结果回灌 `Options::backend` |
//! | 6 | 背景图解码只带 png 编解码器 | `crates/app-iced` 的 `image` 只开了 `png`(现有截图通道用),`Cargo.toml` 在 scope 外。jpeg/bmp 会走 `LoadError::Undecodable` 并给出可读提示 | 把 `image` 的 `jpeg`/`bmp` feature 打开(或改走 `unlucky-palette-extract`) |
//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dimmed`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) |
//! | 8 | 页头副标题 / 分组标签这类**直接压在画布上**的次要文字,在 `panel_alpha < 1` 时对比度下降 | 它们由 `pages/frame.rs` 的页头画,而那个文件不在本 mission 的 scope;真正该改的是「页头带显示面板底色」 | 把页头带改成面板层(`frame::header_bar` 一处) |
//!
//! ## 取证
//!
//! 每一处改动都打一行 [`state::State::log_line`]:`[appearance] window_alpha=… panel_alpha=…
//! byte=…`。数值是**下发前**的字节(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明」
//! 不可信。截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。
pub mod alpha;
pub mod background;
mod palette;
mod section;
mod state;
pub use section::{layer, panel_color, section};
pub use state::State;
/// 设置页「外观」分组在左列里的下标(0 = General,1 = Appearance …)。
pub const APPEARANCE_GROUP: usize = 1;
/// 设置页「外观」分组的显示名。
pub const GROUP_LABEL: &str = "Appearance";
/// 一次外观改动(设置页 → `App`)。
///
/// 值都是**用户请求值**(未钳位):钳位与降级只发生在 [`alpha`],这样「用户设了 0.5、CPU 档实际
/// 1.0」这种差别在状态里能看出来,日志也就不会把请求值写成生效值。
#[derive(Debug, Clone, PartialEq)]
pub enum Change {
/// 整窗透明度(请求值,0.08~1.0)。
WindowOpacity(f32),
/// 面板透明度(请求值,0.4~1.0)。
PanelAlpha(f32),
/// 背景图开关。
BgEnabled(bool),
/// 背景图路径(空串 = 清掉)。
BgImage(String),
/// 填充档。
BgFill(unlucky_presenter::prefs::BgFill),
/// 暗化强度(0~0.95)。
BgDim(f32),
/// 配色来源档。
PaletteSource(unlucky_presenter::prefs::PaletteSource),
/// 设置页切分组(左列点选)。
SelectGroup(usize),
}
impl Change {
/// 这条改动对应哪个 schema 键(写回配置用;`SelectGroup` 不落盘)。
#[must_use]
pub fn key(&self) -> Option<&'static str> {
Some(match self {
Self::WindowOpacity(_) => unlucky_presenter::prefs::keys::WINDOW_OPACITY,
Self::PanelAlpha(_) => unlucky_presenter::prefs::keys::PANEL_ALPHA,
Self::BgEnabled(_) => unlucky_presenter::prefs::keys::BG_IMAGE_ENABLED,
Self::BgImage(_) => unlucky_presenter::prefs::keys::BG_IMAGE,
Self::BgFill(_) => unlucky_presenter::prefs::keys::BG_FILL,
Self::BgDim(_) => unlucky_presenter::prefs::keys::BG_DIM,
Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE,
Self::SelectGroup(_) => return None,
})
}
/// 这条改动的落盘字符串形态(与 `schema` 的取值词表逐字一致)。
///
/// `None` = 不落盘(分组选择)。数字一律给 `f64` 的十进制字面量(`store.set` 按 schema 类型转)。
#[must_use]
pub fn value(&self) -> Option<String> {
Some(match self {
Self::WindowOpacity(alpha) => format!("{alpha}"),
Self::PanelAlpha(alpha) => format!("{alpha}"),
Self::BgEnabled(enabled) => enabled.to_string(),
Self::BgImage(path) => path.clone(),
Self::BgFill(fill) => fill.as_str().to_owned(),
Self::BgDim(dim) => format!("{dim}"),
Self::PaletteSource(source) => source.as_str().to_owned(),
Self::SelectGroup(_) => return None,
})
}
}
#[cfg(test)]
mod tests {
use super::{Change, APPEARANCE_GROUP};
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
}
@@ -0,0 +1,279 @@
//! D38 三级配色来源的**调用侧**:用户选的档 → `unlucky_design::palette::resolve_source`。
//!
//! ## 档位与优先序
//!
//! `appearance.palette_source` 的取值是「**从哪一级起**试」:① `background-image` ② `wallpaper`
//! ③ `preset`。起点的级别以下的级别**不参与**(在 `Provenance` 里记为 `Absent` = 用户没让它们
//! 参与),起点以上的每一级只要「有输入但不合格」就自动往下一级落——回落链本身由 design 实现
//! (`crates/design/src/palette/resolve/`),本文件只做两件事:
//!
//! 1. 把「没设」与「设了但坏了」**分开传**([`Source::Absent`] vs [`Source::Undecodable`])——
//! 混成一个 `None` 会让设置页说错话(「没设背景图」 vs 「你那张图读不出来」);
//! 2. 把 `Provenance::hint_key()` 翻成一句可读提示(键 → 文案是 UI 层的活,i18n 之后再统一)。
//!
//! ## 为什么不在这里做对比度
//!
//! WCAG 收敛在 `unlucky_design` 里(`converge` + `check`,任何来源的方案都跑同一套)。本文件
//! 只负责「选哪一级」,越俎代庖会让两条路径各有一套门禁。测试里仍然断言「任何一级的结果都
//! `is_clean()`」,那是**验收**,不是实现。
use unlucky_design::color::{Scheme, SchemeFlavor};
use unlucky_design::palette::{resolve_source, ImageInput, Level, Provenance};
use unlucky_presenter::prefs::PaletteSource;
use super::background::Image;
/// 某一级的输入(三态:没设 / 设了但坏了 / 解码好了)。
#[derive(Debug, Clone, Copy)]
pub enum Source<'a> {
/// 用户没设这一级(没开关 / 路径空)。
Absent,
/// 设了,但读盘或解码失败(原因在调用侧记着,这里只用来说「坏」)。
Undecodable,
/// 解码好的像素。
Pixels(&'a Image),
}
impl<'a> Source<'a> {
/// 翻成 design 的输入形态。
#[must_use]
pub fn input(self) -> Option<ImageInput<'a>> {
match self {
Self::Absent => None,
Self::Undecodable => Some(ImageInput::Undecodable),
Self::Pixels(image) => Some(ImageInput::pixels(&image.rgba, image.width, image.height)),
}
}
}
/// 一次解析的结果。
#[derive(Debug, Clone, PartialEq)]
pub struct Resolved {
/// 最终生效的方案(49 个角色)。
pub scheme: Scheme,
/// 用哪一级、前几级为什么没用。
pub provenance: Provenance,
}
impl Resolved {
/// 生效的层级(日志与设置页展示用)。
#[must_use]
pub fn level(&self) -> Level {
self.provenance.level
}
/// 可读提示(`None` = 一级成功、没什么可解释的)。
#[must_use]
pub fn note(&self) -> Option<&'static str> {
note(&self.provenance)
}
}
/// 按档位解析一套方案。
///
/// `preset` 固定传 `None`:本仓库的「用户选中的色卡」还没有 UI(M91 只落了档位),
/// 所以 design 用它自己的出厂卡(`DEFAULT_CARD` = 天蓝)。色卡选择接上后从 `state` 传进来即可。
#[must_use]
pub fn resolve(
source: PaletteSource,
background: Source<'_>,
wallpaper: Source<'_>,
flavor: SchemeFlavor,
) -> Resolved {
// 档位 = 起点:起点以下的级别不参与(传 `Source::Absent`)。
let background = if source == PaletteSource::BackgroundImage {
background
} else {
Source::Absent
};
let wallpaper =
if source == PaletteSource::BackgroundImage || source == PaletteSource::Wallpaper {
wallpaper
} else {
Source::Absent
};
let (scheme, provenance) = resolve_source(background.input(), wallpaper.input(), None, flavor);
Resolved { scheme, provenance }
}
/// `Provenance::hint_key()` → 一句可读提示(英文基线;i18n mission 之后走词表)。
///
/// 键的完整表在 `unlucky_design::palette::resolve::Provenance::hint_key` 的文档里;
/// 这里只翻「本 mission 真会产生的那几个」,未识别键返回 `None` 而不是编一句话。
#[must_use]
pub fn note(provenance: &Provenance) -> Option<&'static str> {
Some(match provenance.hint_key()? {
"palette.hint.background.undecodable" => {
"Background image could not be read; colours fall back."
}
"palette.hint.background.too-small" => {
"Background image is too small to sample; colours fall back."
}
"palette.hint.background.bad-dimensions" => {
"Background image data looks inconsistent; colours fall back."
}
"palette.hint.background.no-opaque-pixels" => {
"Background image is almost fully transparent; colours fall back."
}
"palette.hint.background.low-chroma" => {
"Background image is too monochrome to tint from; colours fall back."
}
"palette.hint.background.contrast" => {
"Background image colours would be unreadable; colours fall back."
}
"palette.hint.wallpaper.undecodable" => "Desktop wallpaper could not be read.",
"palette.hint.wallpaper.low-chroma" => "Desktop wallpaper is too monochrome to tint from.",
"palette.hint.wallpaper.too-small" => "Desktop wallpaper is too small to sample.",
"palette.hint.wallpaper.bad-dimensions" => "Desktop wallpaper data looks inconsistent.",
"palette.hint.wallpaper.no-opaque-pixels" => {
"Desktop wallpaper is almost fully transparent."
}
"palette.hint.wallpaper.contrast" => {
"Desktop wallpaper colours would be unreadable; colours fall back."
}
"palette.hint.preset.contrast" => "The built-in card needed a small contrast correction.",
"palette.hint.adjusted" => "A few colours were nudged for readability.",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::{note, resolve, Source};
use crate::pages::appearance::background::Image;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::palette::wcag::check;
use unlucky_design::palette::{Level, Outcome, Skip};
use unlucky_presenter::prefs::PaletteSource;
/// 高彩度的图(纯红):取色一定成功。
fn vivid() -> Image {
let rgba = [0xC0, 0x20, 0x20, 0xFF].repeat(64);
Image::from_rgba((8, 8), 8, 8, rgba)
}
/// 极低彩度的图(近灰):D40 的彩度门槛会拒绝它(`< 8`)。
fn grey() -> Image {
let rgba = [0x80, 0x80, 0x82, 0xFF].repeat(64);
Image::from_rgba((8, 8), 8, 8, rgba)
}
#[test]
fn background_tier_wins_when_the_image_is_usable() {
// 抓得住:档位 ① 有合格输入时就用它,且不回落、没有提示。
let image = vivid();
let resolved = resolve(
PaletteSource::BackgroundImage,
Source::Pixels(&image),
Source::Absent,
SchemeFlavor::Light,
);
assert_eq!(resolved.level(), Level::BackgroundImage);
assert!(!resolved.provenance.did_fall_back());
assert_eq!(resolved.note(), None, "一级成功时不该有提示");
assert!(check(&resolved.scheme).is_clean(), "取色结果也必须过 WCAG");
}
#[test]
fn a_broken_background_falls_back_and_says_why() {
// 抓得住:「设了但坏了」(Undecodable)与「没设」(Absent)在 Provenance 里分得开,
// 且提示文案对应的是前者;结果仍然过 WCAG(回落不静默变黑白)。
let resolved = resolve(
PaletteSource::BackgroundImage,
Source::Undecodable,
Source::Absent,
SchemeFlavor::Dark,
);
assert_eq!(resolved.level(), Level::Preset, "解码失败 → 落到色卡");
assert_eq!(
resolved.provenance.background(),
Outcome::Failed(Skip::Undecodable)
);
assert!(resolved.provenance.did_fall_back());
assert_eq!(
resolved.note(),
Some("Background image could not be read; colours fall back.")
);
assert!(check(&resolved.scheme).is_clean());
}
#[test]
fn a_monochrome_background_falls_back_instead_of_turning_grey() {
// 抓得住:彩度不足**不是**「压成灰方案」,而是回落到色卡(D40 的纪律),并留下 low-chroma。
let image = grey();
let resolved = resolve(
PaletteSource::BackgroundImage,
Source::Pixels(&image),
Source::Absent,
SchemeFlavor::Light,
);
assert_eq!(resolved.level(), Level::Preset);
match resolved.provenance.background() {
Outcome::Failed(skip) => {
assert!(matches!(skip, Skip::LowChroma { .. }), "实际 {skip:?}")
}
other => panic!("灰图必须被判不合格,实际 {other:?}"),
}
assert!(resolved.note().unwrap().contains("monochrome"));
}
#[test]
fn the_three_tiers_are_tried_in_order_and_never_out_of_order() {
// 抓得住:① 档位=wallpaper 时背景图**不参与**(即使手里有像素);② 档位=preset 时前两级
// 都不参与。这两条是「优先序」的唯一可断言形态(顺序本身在 design 里)。
let background = vivid();
let wallpaper = vivid();
let wallpaper_tier = resolve(
PaletteSource::Wallpaper,
Source::Pixels(&background),
Source::Pixels(&wallpaper),
SchemeFlavor::Light,
);
assert_eq!(wallpaper_tier.level(), Level::Wallpaper);
assert_eq!(
wallpaper_tier.provenance.background(),
Outcome::Absent,
"档位是壁纸 → 背景图那一级没参与"
);
let preset_tier = resolve(
PaletteSource::Preset,
Source::Pixels(&background),
Source::Pixels(&wallpaper),
SchemeFlavor::Light,
);
assert_eq!(preset_tier.level(), Level::Preset);
assert_eq!(preset_tier.provenance.background(), Outcome::Absent);
assert_eq!(preset_tier.provenance.wallpaper(), Outcome::Absent);
assert!(check(&preset_tier.scheme).is_clean());
// 而档位=background-image 且壁纸也在时:一级成功 ⇒ 壁纸是 NotReached(不是 Absent)。
let background_tier = resolve(
PaletteSource::BackgroundImage,
Source::Pixels(&background),
Source::Pixels(&wallpaper),
SchemeFlavor::Light,
);
assert_eq!(background_tier.level(), Level::BackgroundImage);
assert_eq!(background_tier.provenance.wallpaper(), Outcome::NotReached);
}
#[test]
fn hints_are_only_produced_for_keys_we_ship_a_sentence_for() {
// 抓得住:note() 不会为未知键编文案(返回 None 而不是 Some(""))。
assert_eq!(
note(
&resolve(
PaletteSource::Preset,
Source::Absent,
Source::Absent,
SchemeFlavor::Light,
)
.provenance
),
None
);
}
}
@@ -0,0 +1,400 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::prefs::{BgFill, PaletteSource};
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::alpha::{
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
WINDOW_ALPHA_FLOOR,
};
use super::background::BG_DIM_MAX;
use super::state::State;
use super::Change;
/// 一处面板/卡片表面色:令牌色 × 生效的面板 α。
///
/// `alpha == 1.0` 时返回**逐字节相同**的令牌色(`scale_alpha(1.0)` 不引入误差),所以没开面板
/// 透明度时不会动到任何既有验收图的像素。
#[must_use]
pub fn panel_color(ui: &Ui, layer: MaterialLayer, alpha: f32) -> Color {
let alpha = if alpha.is_finite() {
alpha.clamp(0.0, 1.0)
} else {
1.0
};
ui.layer(layer).scale_alpha(alpha)
}
/// 背景层(铺满整窗、垫在最下面的马赛克)。`None` = 没有背景图(调用方就不叠这一层)。
#[must_use]
pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option<Element<'static, Message>> {
let fallback = ui.layer(MaterialLayer::Canvas);
let mosaic = state.mosaic(viewport, rgb8(fallback))?;
let rows = (0..mosaic.rows)
.map(|row| {
let cells = (0..mosaic.cols)
.map(|col| cell(mosaic.at(row, col), fallback))
.collect::<Vec<_>>();
iced::widget::row(cells)
.width(Length::Fill)
.height(Length::Fill)
.into()
})
.collect::<Vec<_>>();
Some(column(rows).width(Length::Fill).height(Length::Fill).into())
}
/// iced 颜色 → 8 bit RGB(暗化的「底色」方向)。
fn rgb8(color: Color) -> [u8; 3] {
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
[channel(color.r), channel(color.g), channel(color.b)]
}
fn cell<'a>(color: Option<[u8; 3]>, fallback: Color) -> Element<'a, Message> {
let color = color.map_or(fallback, |[r, g, b]| Color::from_rgb8(r, g, b));
container(iced::widget::space::horizontal())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| frame::filled(color))
.into()
}
/// 「外观」分组整块(一张卡)。
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows = rows.push(slider_row(
ui,
"Window transparency",
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows = rows.push(slider_row(
ui,
"Panel transparency",
panel_percent,
panel_hint.to_owned(),
!forced,
panel_control,
));
// ③ 背景图开关
rows = rows.push(choice_row(
ui,
"Background image",
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
],
));
// ④ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows = rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
// ⑤ 填充
rows = rows.push(choice_row(
ui,
"Fill",
vec![
choice(
ui,
"Cover",
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑥ 暗化
rows = rows.push(slider_row(
ui,
"Dim",
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
// ⑦ 配色来源
rows = rows.push(choice_row(
ui,
"Colour source",
vec![
choice(
ui,
"Background",
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
// ⑧ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**
for note in state.diagnostics() {
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
let resolved = format!("Colours: {}", state.palette_level());
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
card_with_panel_alpha(ui, state, rows)
}
fn card_with_panel_alpha<'a>(
ui: &'a Ui,
state: &State,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = panel_color(ui, MaterialLayer::Card, state.effective_panel_alpha());
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}
@@ -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())
}
+31 -4
View File
@@ -92,6 +92,7 @@
//! 产品二进制的截图走 `--screenshot`(页面树由 app 层按路由装配),本 harness 只出**单页**图,
//! 用于页面级回归与宽度对照。
pub mod appearance;
pub mod frame;
pub mod harness;
pub mod layout;
@@ -135,6 +136,11 @@ pub enum Message {
Launch(String),
/// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。
Stop(String),
/// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。
///
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
/// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。
Appearance(appearance::Change),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
@@ -278,19 +284,40 @@ const NAV: [(Option<NavSection>, IconKind, &str, Route); 5] = [
/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。
///
/// 产品路径(`app/view.rs`)走这个入口:外壳由 `shell` 画(拖动/缩放/三个 caption 键、rail 88),
/// 这里只画「页头 + 内容列」。列宽仍按**整个窗口宽**算(`layout::frame_metrics` 内部会扣掉
/// rail 88 与页边距),所以外壳换了不影响版面判据。
/// 产品路径(`app/view.rs`)走 [`page_with_appearance`]:外壳由 `shell` 画(拖动/缩放/三个
/// caption 键、rail 88),这里只画「页头 + 内容列」。列宽仍按**整个窗口宽**算
/// (`layout::frame_metrics` 内部会扣掉 rail 88 与页边距),所以外壳换了不影响版面判据。
///
/// 视图模型由**调用方持有**(`App` 在状态变化时重建一次 `Model`),这里只借用:
/// `view(&self)` 不能返回一棵借用局部变量的树。
pub fn page<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
render(ui, model, None)
}
/// 同 [`page`],但带上**外观状态**(设置页的「外观」分组要读当前值与诊断)。
///
/// 为什么另开一个入口而不是给 `Model` 加字段:`pages/model.rs` 是页面与 presenter 之间的展示
/// 契约(`Model` 只装「要画什么」),外观是**运行时状态**(滑块请求值、已解码的背景图、诊断),
/// 塞进去会让演示数据(`pages::demo`)也得伪造一份外观状态。
pub fn page_with_appearance<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: &'a appearance::State,
) -> Element<'a, Message> {
render(ui, model, Some(appearance))
}
fn render<'a>(
ui: &'a Ui,
model: &'a Model,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let metrics = layout::frame_metrics(model.screen.kit(), model.window_width);
match model.screen {
Screen::Instances => instances::view(ui, metrics, model),
Screen::Download => download::view(ui, metrics),
Screen::Settings => settings::view(ui, metrics),
Screen::Settings => settings::view(ui, metrics, appearance),
Screen::Logs => logs::view(ui, metrics),
}
}
+68 -14
View File
@@ -16,7 +16,7 @@
//!
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤424。
use iced::widget::{column, container, row};
use iced::widget::{column, container, mouse_area, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
@@ -29,6 +29,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::appearance::{self, Change};
use super::frame::{self, Header};
use super::layout::{FrameMetrics, PageLayout};
use super::{Action, Message};
@@ -84,14 +85,20 @@ const GROUPS: [Group; 4] = [
},
];
/// 演示选中的分组。
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
const SELECTED: usize = 0;
/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
/// 整页。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
///
/// `appearance` 是 M93 接进来的外观运行期状态(`None` = harness / 演示路径,设置页仍画占位行)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let header = Header::new("Settings")
.subtitle("Launcher behaviour, Java runtimes and download sources")
.action(frame::text_action(
@@ -101,13 +108,16 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
))
.action(frame::text_action(ui, "Save", Action::SaveSettings));
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = appearance.map_or(SELECTED, |state| state.selected_group);
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
group_nav(ui),
frame::left_aligned(metrics.column, form(ui, Some(SELECTED))),
group_nav(ui, selected),
frame::left_aligned(metrics.column, form(ui, Some(selected), appearance)),
),
_ => frame::single_column(metrics, form(ui, None)),
_ => frame::single_column(metrics, form(ui, None, appearance)),
};
frame::page(ui, metrics, header, body)
@@ -115,12 +125,12 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
// --------------------------------------------------------------- 侧列
fn group_nav<'a>(ui: &'a Ui) -> Element<'a, Message> {
fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message> {
let items = column(
GROUPS
.iter()
.enumerate()
.map(|(index, group)| group_item(ui, group, index == SELECTED))
.map(|(index, group)| group_item(ui, group, index, index == selected))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
@@ -141,7 +151,12 @@ fn group_nav<'a>(ui: &'a Ui) -> Element<'a, Message> {
.into()
}
fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, Message> {
fn group_item<'a>(
ui: &'a Ui,
group: &'a Group,
index: usize,
selected: bool,
) -> Element<'a, Message> {
let spec = Component::NavigationItem.spec();
let height = ui.metrics().row_height;
@@ -159,7 +174,7 @@ fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, M
.width(Length::Fill)
.align_y(Alignment::Center);
container(inner)
let pill = container(inner)
.width(Length::Fill)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
@@ -171,7 +186,11 @@ fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, M
..Border::default()
},
..container::Style::default()
})
});
// 点选切分组:走页面自己的 `Message::Appearance(SelectGroup)`(只改视图状态,不落盘)。
mouse_area(pill)
.on_press(Message::Appearance(Change::SelectGroup(index)))
.into()
}
@@ -179,7 +198,11 @@ fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, M
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for (index, group) in GROUPS.iter().enumerate() {
@@ -187,6 +210,12 @@ fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
continue;
}
// 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。
let card = match appearance {
Some(state) if index == appearance::APPEARANCE_GROUP => appearance::section(ui, state),
_ => group_card(ui, group),
};
stack = stack.push(
column![
frame::text_role(
@@ -196,7 +225,7 @@ fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
group.name,
ui.color(Role::OnSurfaceVariant),
),
group_card(ui, group),
card,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill),
@@ -204,7 +233,19 @@ fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
}
stack = stack.push(overrides_empty(ui));
stack.into()
// 「外观」分组有 7 个控件 + 最多 3 行诊断:1180×760 下会超出可视区,所以表单列自己滚动
// (页头不跟着滚,与实例页列表列同一套口径)。
scrollable(stack)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
/// 一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
@@ -310,4 +351,17 @@ mod tests {
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 GROUPS 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(
GROUPS[appearance::APPEARANCE_GROUP].name,
appearance::GROUP_LABEL
);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
}