Merge branch 'feat/p5-appearance-wire-window-panel-alpha-ba'

This commit is contained in:
AskaEth committed 2026-11-02 19:13:11 +08:00
commit b3f99652bb
36 files changed
+3573 -206

No files matched your search

Generated
+18
View File
@@ -2751,6 +2751,7 @@ dependencies = [
"iced_runtime",
"iced_widget",
"iced_winit",
"image",
"thiserror 2.0.21",
]
@@ -2809,6 +2810,8 @@ dependencies = [
"half",
"iced_core",
"iced_futures",
"image",
"kamadak-exif",
"log",
"raw-window-handle",
"rustc-hash 2.1.3",
@@ -3316,6 +3319,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kamadak-exif"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
dependencies = [
"mutate_once",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -3757,6 +3769,12 @@ dependencies = [
"zbus",
]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "naga"
version = "27.0.3"
+11 -2
View File
@@ -37,12 +37,21 @@ iced = { version = "=0.14.0", default-features = false, features = [
"tiny-skia",
"crisp",
"advanced",
# M93:背景图要真图直出 ⇒ 需要渲染器的 image 支持。用 `image-without-codecs` 而不是
# `image` 是刻意的:后者会打开 `image/default` 的全套编解码器;前者的编解码能力由本 crate
# 依赖表里那条 `image` 的 features 决定(png / jpeg / bmp),不必把 webp/tiff/exr/gif 拖进来。
"image-without-codecs",
"svg",
"thread-pool",
] }
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
image = { version = "0.25", default-features = false, features = ["png"] }
# 截图落 PNG + 背景图解码(M93):png 是截图通道,jpeg/bmp 是用户真会挑的背景图/壁纸格式
# (M73 的壁纸探测不筛扩展名)。仍不开 default-features——那会拖进 avif/ravif/webp/tiff 一整套。
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"bmp",
] }
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …),
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
+118
View File
@@ -0,0 +1,118 @@
//! App 侧的外观动作:**下发整窗 α / 写回配置 / 打取证日志**。
//!
//! 三件事各自只有一条路,且都从这里出去(`update` 只负责把它们按消息串起来):
//!
//! | 动作 | 唯一落点 | 为什么 |
//! |---|---|---|
//! | 下发整窗 α | [`App::apply_window_alpha`] → `app::window_alpha::dispatch` | 判据在 `platform`、FFI 在 `platform/win32`,这里只做「取 α → 交给执行侧 → 记日志」 |
//! | 写回配置 | [`App::persist_appearance`](8 个 UI 字段)+ `AppCommand::SetSetting`(另外两个只读键) | 每个键**只有一个写者**:`AppearancePrefs` 里没有 `window_opacity` / `palette_source`,它们只能走 schema 指令 |
//! | 取证日志 | [`App::log_appearance`] | 一行里同时有请求值、生效值、下发字节与 FFI 结局 |
//!
//! ## 为什么「写回」要分两条路
//!
//! `SettingsBridge::persist` 收的是 `AppearancePrefs`(背景图四件 + 面板 α + 玻璃 + 强调色),
//! 它的实现是「先读文件当前值,再覆盖我们改的那几个字段」——所以用它写**它覆盖的键**最省事,
//! 也不会把用户的自定义强调色冲掉。而 `appearance.window_opacity` / `appearance.palette_source`
//! 不在那个结构里(它们是「只读下层键」,见 `presenter::prefs::AppearanceBase`),
//! 写它们只能走 schema 白名单指令 `AppCommand::SetSetting`(真 host 用 `SettingStore::set` 落盘)。
use unlucky_presenter::dto::AppCommand;
use crate::pages::appearance::Change;
use super::state::App;
use super::window_alpha::{self, Dispatch};
impl App {
/// 把当前整窗 α 交给执行侧(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)并记一行日志。
///
/// 没有句柄时**也打日志**(`hwnd=none`):不能让「α 没生效」这件事只体现为「屏幕上没变化」。
pub(super) fn apply_window_alpha(&self, context: &str) {
let Some(hwnd) = self.hwnd else {
self.log_appearance(context, None);
return;
};
let alpha = crate::platform::effective_window_alpha(
self.appearance.backend,
f64::from(self.appearance.window_opacity),
);
let dispatch = window_alpha::dispatch(hwnd, alpha);
self.log_appearance(context, Some(&dispatch));
}
/// 取证日志:`[appearance] <状态行> layered=… outcome=…`。
///
/// 数值来自 [`crate::pages::appearance::State::log_line`](请求值 / 生效值 / 下发字节),
/// 尾巴来自执行侧。**这一行就是「透明度到底设成了多少」的证据**——RDP 上的观感不可信。
pub(super) fn log_appearance(&self, context: &str, dispatch: Option<&Dispatch>) {
let mut line = self.appearance.log_line(context);
if let Some(dispatch) = dispatch {
line.push(' ');
line.push_str(&dispatch.describe());
} else {
line.push_str(" layered=skipped outcome=no-window");
}
eprintln!("{line}");
}
/// 设置页改了一项外观。
pub(super) fn on_appearance(&mut self, change: Change) {
let needs_window = self.appearance.apply(change.clone());
// 配色来源 / 背景图可能变了 ⇒ `Ui` 可能要改用解析出的方案。
self.rebuild_ui();
self.view_dirty = true;
self.refresh_view();
match change.key() {
// 两个只读下层键:走 schema 指令(`SettingsBridge` 的结构里没有它们)。
Some(_) if matches!(change, Change::WindowOpacity(_) | Change::PaletteSource(_)) => {
if let (Some(key), Some(value)) = (change.key(), change.value()) {
self.dispatch(AppCommand::SetSetting {
key: key.to_owned(),
value,
});
}
}
// 其余键:走 bridge(它知道怎么保留强调色这类本 mission 不管的字段)。
Some(_) => self.persist_appearance(),
None => {}
}
if needs_window {
self.apply_window_alpha("change");
} else {
self.log_appearance("change", None);
}
}
/// 重读配置后同步外观(热切:外部 CLI / AI 改完 1 秒内生效)。
pub(super) fn reload_appearance(&mut self) {
let (prefs, _theme) = self.bridge.load();
let base = self.bridge.load_base();
self.appearance.reload(&prefs, &base);
self.appearance_prefs = prefs;
self.rebuild_ui();
self.view_dirty = true;
self.apply_window_alpha("reload");
}
/// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。
fn persist_appearance(&mut self) {
let mut prefs = self.appearance_prefs.clone();
prefs.bg_image_enabled = self.appearance.bg_enabled;
prefs.bg_image = self.appearance.bg_path.clone();
prefs.bg_fill = self.appearance.bg_fill;
prefs.bg_dim = self.appearance.bg_dim;
prefs.panel_alpha = self.appearance.panel_alpha;
prefs.glass = self.appearance.glass;
self.bridge.persist(&prefs, self.theme_mode);
self.appearance_prefs = prefs;
match self.bridge.path() {
Some(path) => eprintln!("[settings] 外观已写回 {}", path.display()),
None => eprintln!("[settings] 外观未落盘(数据根解析不出来,内存态运行)"),
}
}
}
+113
View File
@@ -0,0 +1,113 @@
//! 本进程**实际**用的是哪个渲染后端(D36 的「CPU 档」判据)。
//!
//! ## 为什么不能直接看 `--renderer` 的请求值
//!
//! `Options::backend` 是**请求**(默认 `Auto`)。真正落到哪个渲染器由 `probe::apply` 决定,
//! 它把结论写进进程环境变量 `ICED_BACKEND`(iced 只认这个)。于是默认参数 + 没有独显的机器上:
//! 请求是 `Auto`,实际是 tiny-skia(CPU)——如果面板 α / 玻璃的降级判据只看请求值,
//! D36 明写的「CPU 档面板 α 强制 1.0」**恰好在它最该生效的场景失效**(复审 r1 P2-2)。
//!
//! 所以判据统一走 [`effective_backend`]:它把 `ICED_BACKEND` 的候选列表翻译回 [`Backend`]。
//! 这里是**唯一**的翻译点,别在别处再读一遍环境变量。
//!
//! ## 已知边界(诚实登记)
//!
//! `ICED_BACKEND="wgpu,tiny-skia"` 表示「优先 wgpu,拿不到适配器再回退 tiny-skia」。运行期到底
//! 选了谁只有渲染器自己知道,本 crate 拿不到(iced 0.14 没有公开的「当前后端」查询)。
//! 这种机器按 **GPU 档**判(面板 α 生效);如果 wgpu 在运行期回退到 tiny-skia,面板 α 会
//! 在 CPU 渲染器上生效——成本比 D27 的顾虑略高,但不会画错。要彻底收口得让 iced 暴露实际后端。
use crate::Backend;
/// `ICED_BACKEND` 的候选列表 → 实际档位(纯函数,好测)。
///
/// | `ICED_BACKEND` | 含义(`probe::backend_env` 的产物) | 结论 |
/// |---|---|---|
/// | `tiny-skia` | 只有 CPU 候选(`--software-renderer` / 未编 wgpu / Auto 无线程硬件) | [`Backend::Cpu`] |
/// | `wgpu,tiny-skia` | 点名要 GPU(保留回退) | [`Backend::Gpu`] |
/// | `tiny-skia,wgpu` | Auto + 有 wgpu 无硬件(CPU 起步、wgpu 兜底) | [`Backend::Cpu`] |
/// | 未设 | Auto + 有硬件(交给 iced 默认顺序) | 请求值(`Auto`) |
#[must_use]
pub(super) fn backend_from_env(request: Backend, env: Option<&str>) -> Backend {
let Some(list) = env.map(str::trim).filter(|value| !value.is_empty()) else {
return request;
};
// 候选列表的第一项就是起步渲染器:`tiny-skia` 起步 = CPU 档(D36 的降级对象)。
let first = list.split(',').next().unwrap_or(list).trim();
match first {
"tiny-skia" => Backend::Cpu,
"wgpu" => Backend::Gpu,
_ => request,
}
}
/// 本进程实际生效的后端(判据 = `probe::apply` 写下的 `ICED_BACKEND`)。
#[must_use]
pub(super) fn effective_backend(request: Backend) -> Backend {
backend_from_env(request, std::env::var("ICED_BACKEND").ok().as_deref())
}
#[cfg(test)]
mod tests {
use super::backend_from_env;
use crate::Backend;
use unlucky_presenter::prefs::{PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN};
/// 抓得住:`probe::backend_env` 的每一种产物都被翻译成正确的档位(含最容易踩的
/// 「Auto + 没独显」= `tiny-skia,wgpu` → CPU)。
/// 抓不住:运行期 wgpu 真回退到 tiny-skia 的情况(iced 没暴露实时候选,见模块文档)。
#[test]
fn every_iced_backend_list_maps_to_a_tier() {
assert_eq!(
backend_from_env(Backend::Auto, Some("tiny-skia")),
Backend::Cpu
);
assert_eq!(
backend_from_env(Backend::Auto, Some("tiny-skia,wgpu")),
Backend::Cpu,
"Auto + 无硬件:CPU 起步 ⇒ CPU 档"
);
assert_eq!(
backend_from_env(Backend::Auto, Some("wgpu,tiny-skia")),
Backend::Gpu
);
assert_eq!(
backend_from_env(Backend::Gpu, Some("wgpu,tiny-skia")),
Backend::Gpu
);
// 未设 / 空白:保持请求值(Auto + 有硬件 ⇒ 交给 iced,按 GPU 档判)。
assert_eq!(backend_from_env(Backend::Auto, None), Backend::Auto);
assert_eq!(backend_from_env(Backend::Auto, Some(" ")), Backend::Auto);
// 不认识的取值不猜:保留请求值。
assert_eq!(
backend_from_env(Backend::Auto, Some("metal")),
Backend::Auto
);
}
/// **复审 r1 P2-2 的守门**:默认档(`Auto`)在无独显机器上必须落进 CPU 档,
/// 从而让「面板 α / 玻璃强制 1.0」在默认路径上真的生效。
///
/// 抓得住:判据走 `effective_backend()` 这条链(谁把 `State::new` 改回只吃请求值,这条会红)。
/// 抓不住:整窗 α 在 CPU 档仍要生效(那是另一条判据,见 `alpha` 模块真值表)。
#[test]
fn auto_with_a_software_renderer_forces_the_panel_opaque() {
use crate::pages::appearance::alpha::{effective_glass, effective_panel_alpha};
let backend = backend_from_env(Backend::Auto, Some("tiny-skia,wgpu"));
assert_eq!(backend, Backend::Cpu);
assert_eq!(
effective_panel_alpha(backend, 0.55),
1.0,
"D36:CPU 档面板 1.0"
);
assert!(!effective_glass(backend, true), "D36:CPU 档玻璃强制关");
// 但整窗 α 不受限(D36 对 D27 的修订)。
assert!(crate::platform::effective_window_alpha(backend, 0.8).is_some());
// 反方向:GPU 档不做这个降级(否则「强制」就没意义了)。
let gpu = backend_from_env(Backend::Auto, Some("wgpu,tiny-skia"));
assert_eq!(effective_panel_alpha(gpu, 0.55), 0.55);
assert!((PANEL_ALPHA_MIN - 0.4).abs() < 1e-9 && (WINDOW_ALPHA_MIN - 0.08).abs() < 1e-9);
}
}
+9
View File
@@ -62,6 +62,15 @@ pub(crate) fn run(options: Options) -> iced::Result {
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但**这里给不了**——
// ① iced 0.14 的 `window::Settings` 没有扩展样式字段;
// ② winit 0.30 只在鼠标穿透(`IGNORE_CURSOR_EVENT`)时置这一位,`transparent: true`
// 走的是 DWM 那条路,与 `LWA_ALPHA` 无关;
// ③ 加扩展样式只有 `Get/SetWindowLongPtrW`,是 `unsafe`,而全仓 `unsafe` 只允许出现在
// `platform/win32/transparency.rs`(CI 的 `unsafe-scope` 是集合相等)。
// 所以窗口创建侧只负责开窗,样式补齐与 α 下发都在拿到句柄之后:
// `app::update` 的 `Message::WindowHandle` → `window_alpha::dispatch`(先 ensure_layered)。
transparent: false,
..window::Settings::default()
});
+2
View File
@@ -38,6 +38,8 @@ pub enum Message {
Heartbeat,
/// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。
Window(window::Event),
/// 窗口句柄(M93:`window::run` 探测出的 raw handle;`None` = 拿不到,整窗 α 无法下发)。
WindowHandle(Option<isize>),
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
Page(pages::Message),
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
+3
View File
@@ -27,12 +27,15 @@
//! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components`
//! (组件源码里的文档与测试都按这个路径写)。
mod appearance;
mod backend;
mod boot;
mod message;
mod state;
mod subscription;
mod update;
mod view;
mod window_alpha;
#[cfg(test)]
mod tests;
+73 -8
View File
@@ -110,6 +110,15 @@ pub struct App {
pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 强调色这类本 mission 不管的字段原样保留)。
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>,
}
impl App {
@@ -140,14 +149,37 @@ impl App {
bridge: SettingsBridge,
) -> Self {
let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height;
let theme_mode = bridge.load().1;
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 3 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源)。M93 之前这两个键在 app 侧**根本读不到**。
//
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
(
unlucky_presenter::prefs::AppearancePrefs::factory(),
unlucky_presenter::prefs::AppearanceBase::factory(),
)
} else {
(bridge.load().0, bridge.load_base())
};
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::backend::effective_backend(options.backend);
let appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
let view = pages::Model::new(
pages::Screen::Instances,
width,
Some(pages::Screen::Instances.nav()),
);
Self {
let mut app = Self {
ui: Ui::new(flavor, families),
families,
options,
@@ -166,7 +198,14 @@ impl App {
frames: 0,
refreshes: 0,
auto_selected: false,
}
height,
appearance,
appearance_prefs,
hwnd: None,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
app
}
/// 本次进程是否走截图通道。
@@ -174,19 +213,45 @@ impl App {
self.options.screenshot.is_some()
}
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
/// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
///
/// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。
/// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。
pub(super) fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor {
if self.ui.flavor() == flavor && self.appearance.flavor() == flavor {
return;
}
let next = Ui::new(flavor, self.families);
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
self.ui.name(),
next.name()
previous,
self.ui.name()
);
self.ui = next;
}
/// 按「配色来源是否取色成功」重建 [`Ui`]。
///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
pub(super) fn rebuild_ui(&mut self) {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
Ui::from_scheme(*scheme, self.families)
}
None => Ui::new(self.appearance.flavor(), self.families),
}
.with_panel_alpha(panel_alpha);
}
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
+2
View File
@@ -9,6 +9,8 @@ use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::router::Route;
use unlucky_presenter::settings_bridge::SettingsBridge;
#[cfg(test)]
mod appearance;
#[cfg(test)]
mod presenter;
+331
View File
@@ -0,0 +1,331 @@
//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。
//!
//! 每条都按本 mission 的验收纪律写「抓得住 / 抓不住」。真机才有的东西(`WS_EX_LAYERED` 是否补上、
//! `LWA_ALPHA` 的观感)不在这里假装——那是 `[appearance]` 日志与报告里「没验的部分」的事。
use std::path::PathBuf;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::prefs::{BgFill, PaletteSource};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::{Backend, Options};
use crate::pages::appearance::{self, Change, State};
use crate::pages::{self, Screen};
use super::{headless, FAMILIES};
use super::super::state::App;
/// 一个把配置写在临时文件里的 App(测「读回 / 写回」必须真的过文件)。
fn with_config(json: &str, backend: Backend, label: &str) -> (App, PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-appear-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, json).expect("写配置文件");
let app = App::with_presenter(
Options {
backend,
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
fn cleanup(dir: &PathBuf) {
std::fs::remove_dir_all(dir).ok();
}
/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 205(钳位链的入口那一段)。
/// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。
#[test]
fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() {
let (app, dir) = with_config(
r#"{"appearance":{"window_opacity":0.8,"palette_source":"preset"}}"#,
Backend::Cpu,
"read",
);
assert_eq!(app.appearance.window_opacity, 0.8);
assert_eq!(
app.appearance.window_alpha_byte(),
Some(205),
"f32 0.8 → f64 略大于 0.8 → ceil 205(不向下取整)"
);
// CPU 档:窗口 α 允许,面板 α 强制 1.0。
assert_eq!(app.appearance.effective_panel_alpha(), 1.0);
assert!(app.appearance.panel_is_forced());
cleanup(&dir);
}
/// 抓得住:①面板 α 滑到 0.8 → 状态是 0.8、生效值 GPU 档也是 0.8;
/// ②CPU 档下滑到 0.5 → 生效值仍是 1.0,而**请求值**仍是 0.5(界面上的「requested → forced」靠这条)。
/// 抓不住:这层半透明在屏幕上到底多透(RDP 上不可信,见报告)。
#[test]
fn panel_alpha_is_independent_of_the_window_alpha_and_forced_on_cpu() {
let mut gpu = headless(Options::default(), SchemeFlavor::Light);
gpu.appearance.apply(Change::PanelAlpha(0.8));
assert_eq!(gpu.appearance.panel_alpha, 0.8);
assert_eq!(gpu.appearance.effective_panel_alpha(), 0.8);
assert_eq!(gpu.appearance.window_opacity, 1.0, "面板 α 不许带动整窗 α");
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
assert!(
!cpu.appearance.apply(Change::PanelAlpha(0.5)),
"只有整窗 α 需要立刻下发(面板 α 只重绘)"
);
assert_eq!(cpu.appearance.panel_alpha, 0.5, "请求值保留");
assert_eq!(
cpu.appearance.effective_panel_alpha(),
1.0,
"CPU 档强制 1.0"
);
assert_eq!(cpu.appearance.window_alpha_byte(), None, "整窗 α 仍是 1.0");
}
/// 抓得住:一次滑块改动 → 落盘 → **新开一个 bridge** 能读回(= 重启后还在)。
/// 抓不住:真 host 是否也写了同一份文件(`AppCommand::SetSetting` 走的是同一份 schema)。
#[test]
fn a_panel_alpha_change_is_written_back_to_the_config_file() {
let (mut app, dir) = with_config("{}", Backend::Gpu, "write");
app.on_appearance(Change::PanelAlpha(0.8));
app.on_appearance(Change::BgFill(BgFill::Tile));
app.on_appearance(Change::BgDim(0.5));
let path = dir.join("config/launcher.json");
let reopened = SettingsBridge::with_path(&path).load().0;
assert_eq!(reopened.panel_alpha, 0.8);
assert_eq!(reopened.bg_fill, BgFill::Tile);
assert!((reopened.bg_dim - 0.5).abs() < 1e-6);
assert_eq!(
app.appearance_prefs.panel_alpha, 0.8,
"内存里的「已落盘快照」也要跟上,否则下一次写回会拿旧基底覆盖"
);
cleanup(&dir);
}
/// 抓得住:路径读不出来时 ①不 panic ②`background_active()` 为假(静默回落)
/// ③诊断里有一句人话(不是空串)④配色回落到色卡档(不换 scheme)。
/// 抓不住:这张图在别的机器上路径存在时会怎样(那是成功路径,另一条测试管)。
#[test]
fn a_broken_background_path_falls_back_silently_but_says_why() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance
.apply(Change::BgImage("D:/definitely/not/here.png".to_owned()));
assert!(!app.appearance.background_active());
assert!(app.appearance.image().is_none(), "解码失败 ⇒ 没有可用图");
assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断");
assert!(
app.appearance.diagnostics()[0].contains("could not be read"),
"诊断要是人话:{:?}",
app.appearance.diagnostics()
);
assert_eq!(app.appearance.palette_level(), "preset", "回落色卡");
assert!(
app.appearance.derived_scheme().is_none(),
"色卡档不换 scheme"
);
}
/// 抓得住:真 PNG → 背景真的生效(解出原图尺寸、路径留给渲染器、配色走「背景图取色」这一级)。
/// 抓不住:渲染出来的像素(由截图举证),以及三种填充的覆盖范围(由 `background::tiles` 的
/// 「覆盖 ≥ 视口」断言守)。
#[test]
fn a_decodable_png_makes_the_background_and_the_palette_active() {
let dir = std::env::temp_dir().join(format!("ull-appear-png-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("临时目录");
let path = dir.join("bg.png");
let image = image::RgbImage::from_fn(64, 32, |x, _y| {
if x < 32 {
image::Rgb([200, 30, 30])
} else {
image::Rgb([20, 60, 200])
}
});
image.save(&path).expect("写 png");
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance
.apply(Change::PaletteSource(PaletteSource::BackgroundImage));
app.appearance
.apply(Change::BgImage(path.to_string_lossy().to_string()));
app.rebuild_ui();
assert!(app.appearance.background_active());
assert!(app.appearance.image().is_some(), "解码成功 ⇒ 有可用图");
assert!(
!app.appearance.bg_path.trim().is_empty(),
"渲染器按路径解码(见 picture::image_handle)"
);
assert_eq!(app.appearance.diagnostics().len(), 0, "成功时没有诊断");
assert_eq!(app.appearance.palette_level(), "background-image");
assert!(
app.appearance.derived_scheme().is_some(),
"取色成功后 Ui 要换成解析出的方案"
);
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
assert_eq!(app.ui().name(), "ull-m3-light");
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
assert_ne!(
app.ui().color(unlucky_design::color::Role::Primary),
baseline.color(unlucky_design::color::Role::Primary)
);
std::fs::remove_dir_all(&dir).ok();
}
/// 抓得住:设置页在有外观状态时能真的构图(真控件树,含文本框/滑块/互斥按钮),并且
/// 「外观」分组的下标与 `appearance::APPEARANCE_GROUP` 一致(点左列能切过去)。
/// 抓不住:这些控件点下去之后的交互(`Message` 的落点由 `on_appearance` 的其它测试管)。
#[test]
fn the_settings_page_renders_the_real_appearance_group() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state
.navigate(unlucky_presenter::router::Route::Settings);
app.refresh_view();
assert_eq!(app.view.screen, Screen::Settings);
assert_eq!(app.appearance.selected_group, appearance::APPEARANCE_GROUP);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
drop(_tree);
// 切分组:只改视图状态,不落盘(`Change::SelectGroup` 的 key() == None)。
app.appearance.apply(Change::SelectGroup(0));
assert_eq!(app.appearance.selected_group, 0);
let _tree = pages::page_with_appearance(&app.ui, &app.view, &app.appearance);
}
/// **P2-2 的守门(复审 r1)**:面板 α 只作用到允许半透明的层。
///
/// 抓得住:`Ui::layer(Canvas)` 在 α=0.55 时仍是不透明(标题栏/导航轨/页头带都取 Canvas,
/// 它们一旦透底,三个窗口键与导航文字就压在背景照片上);`Card` / `Overlay` 必须真的吃到 α。
/// 抓不住:真实合成后的观感(那由截图看)。
#[test]
fn the_canvas_layer_stays_opaque_while_panels_take_the_alpha() {
use unlucky_design::elevation::MaterialLayer;
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.appearance.apply(Change::PanelAlpha(0.55));
app.rebuild_ui();
let ui = app.ui();
assert_eq!(ui.panel_alpha(), 0.55, "生效值进了 Ui");
assert_eq!(
ui.layer(MaterialLayer::Canvas).a,
1.0,
"窗口底(标题栏/导航轨/页头带)永远不透明——令牌的不变量"
);
for layer in [MaterialLayer::Card, MaterialLayer::Overlay] {
assert!(
(ui.layer(layer).a - 0.55).abs() < 1e-6,
"{layer:?} 必须吃面板 α(实际 {})",
ui.layer(layer).a
);
}
// CPU 档:α 被强制 1.0 ⇒ 连卡片也不透明。
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
cpu.appearance.apply(Change::PanelAlpha(0.55));
cpu.rebuild_ui();
assert_eq!(cpu.ui().layer(MaterialLayer::Card).a, 1.0, "CPU 档强制 1.0");
}
/// 抓得住:**把滑块从头拖到尾**(0.00 → 1.00 的 11 个档)时,状态里的整窗 α 与下发字节逐档单调
/// 上升、且最低一档就是下限字节 21(= 0.0824 ≥ 0.08)、最后一档不透明(不给字节)。
/// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。
/// 抓不住:拖动的手感与真实观感(RDP 上不可信)。
#[test]
fn a_slider_sweep_moves_the_alpha_and_the_byte_monotonically() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let mut bytes = Vec::new();
for step in 0..=10 {
let fraction = step as f32 / 10.0;
let alpha = crate::pages::appearance::alpha::window_alpha_from_fraction(fraction);
app.appearance.apply(Change::WindowOpacity(alpha));
let byte = app.appearance.window_alpha_byte();
bytes.push(byte);
// 每一档都要能打出一行(数值而不是观感)。
assert!(app.appearance.log_line("sweep").contains("window_alpha="));
}
assert_eq!(bytes[0], Some(21), "0% 一档 = 下限 0.08 → 21");
assert_eq!(bytes[10], None, "100% 一档 = 不透明 → 不下发");
let sent: Vec<u8> = bytes.iter().flatten().copied().collect();
assert!(
sent.windows(2).all(|pair| pair[0] <= pair[1]),
"字节必须单调不减:{sent:?}"
);
assert_eq!(sent.len(), 10, "除最后一档(不透明)之外每档都有字节");
assert!(
sent.iter().all(|byte| *byte >= 21),
"任何一档都不许低于下限字节:{sent:?}"
);
}
/// 抓得住:日志行里有请求值、生效值与下发字节(RDP 上唯一可信的证据形态)。
/// 抓不住:这行日志是否真的被写进了 stderr(本测试只钉格式,真机日志由跑起来的二进制举证)。
#[test]
fn the_log_line_carries_requests_effective_values_and_the_byte() {
let mut cpu = headless(
Options {
backend: Backend::Cpu,
..Options::default()
},
SchemeFlavor::Light,
);
cpu.appearance.apply(Change::WindowOpacity(0.5));
cpu.appearance.apply(Change::PanelAlpha(0.4));
let line = cpu.appearance.log_line("change");
assert!(line.starts_with("[appearance] change "), "{line}");
assert!(line.contains("window_alpha=0.500"), "{line}");
assert!(line.contains("panel_alpha=1.000"), "CPU 档生效值:{line}");
assert!(line.contains("(requested 0.400)"), "请求值也要在:{line}");
assert!(
line.contains("byte=128"),
"0.5×255=127.5 → 向上取整 128:{line}"
);
// 不透明时不给字节(而不是给 255)。
let opaque = headless(Options::default(), SchemeFlavor::Light);
assert!(opaque.appearance.log_line("boot").contains("byte=none"));
}
/// 抓得住:`State::new` 与 `apply_env` 之外,`State` 的构造不会偷偷读环境(测试之间不串味)。
/// 抓不住:`ULL_APPEARANCE_*` 在真机上的解析(`apply_env` 由截图矩阵覆盖,见报告)。
#[test]
fn a_fresh_state_is_the_factory_appearance() {
let state = State::new(
&unlucky_presenter::prefs::AppearancePrefs::factory(),
&unlucky_presenter::prefs::AppearanceBase::factory(),
Backend::Gpu,
SchemeFlavor::Light,
);
assert_eq!(state.window_opacity, 1.0);
assert_eq!(state.effective_panel_alpha(), 1.0);
assert!(!state.background_active());
assert!(state.diagnostics().is_empty());
assert!(state.image().is_none());
}
+55 -2
View File
@@ -48,6 +48,24 @@ impl App {
Message::Boot => self.on_boot(),
Message::WindowReady(id) => {
self.window = id;
// M93:整窗 α 要 Win32 句柄,而 iced 只给 `window::Id`。`window::run` 是唯一能把
// `&dyn Window`(实现了 `HasWindowHandle`)拿出来的入口。
match id {
Some(id) => {
window::run(id, super::window_alpha::probe_hwnd).map(Message::WindowHandle)
}
None => Task::none(),
}
}
Message::WindowHandle(hwnd) => {
self.hwnd = hwnd;
match hwnd {
Some(_) => self.apply_window_alpha("boot"),
None => {
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
self.log_appearance("boot", None);
}
}
Task::none()
}
Message::SystemTheme(mode) => {
@@ -69,6 +87,8 @@ impl App {
Message::Window(event) => {
if let window::Event::Resized(size) = event {
self.width = size.width;
// 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。
self.height = size.height;
self.view_dirty = true;
self.refresh_view();
}
@@ -122,6 +142,12 @@ impl App {
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
self.apply_config_theme();
// M93 外观取证开关(`ULL_APPEARANCE_*`)+ 启动那一行数值日志。
// 必须在 `refresh_view` 之前:设置页要画的就是这份状态。
self.appearance.apply_env();
self.rebuild_ui();
self.log_appearance("boot", None);
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
if self.options.wants_system_theme() {
tasks.push(iced::system::theme().map(Message::SystemTheme));
@@ -129,6 +155,16 @@ impl App {
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
self.dispatch(AppCommand::RefreshAll);
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
if let Some(route) = env_start_route() {
eprintln!("[boot] ULL_START_ROUTE → {route:?}");
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
}
self.auto_select();
self.view_dirty = true;
self.refresh_view();
@@ -209,8 +245,10 @@ impl App {
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
eprintln!("[settings] 配置文件变了,重读主题");
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance();
}
if self.view_dirty {
@@ -245,6 +283,7 @@ impl App {
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
pages::Message::Appearance(change) => self.on_appearance(change),
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
pages::Message::Action(action) => {
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
@@ -253,7 +292,7 @@ impl App {
}
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
fn dispatch(&mut self, command: AppCommand) {
pub(super) fn dispatch(&mut self, command: AppCommand) {
self.backend.dispatch(command);
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
@@ -330,3 +369,17 @@ fn heartbeat() -> Task<Message> {
Message::Heartbeat
})
}
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `logs`)。
///
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
fn env_start_route() -> Option<Route> {
match std::env::var("ULL_START_ROUTE").ok()?.trim() {
"instances" => Some(Route::Instances),
"download" => Some(Route::Download),
"logs" => Some(Route::Logs),
"settings" => Some(Route::Settings),
_ => None,
}
}
+18 -7
View File
@@ -42,14 +42,25 @@ impl App {
// `shell` 画,中间那块才是页面。M90 r2 之前这里用的是 `pages::app`(自带一条**没有任何
// 控件**的标题栏留白带),而 `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` /
// `Close` 五个消息只在外壳里构造 ⇒ 产品页「拖不动、缩不了、点不到关闭」。
//
// M93:三样东西从外观状态进来——①背景层(垫在最底下)②面板 α(外壳层的底色)③设置页要读
// 的那份状态本身(`page_with_appearance`)。
match pages::Screen::of(self.state.route()) {
Some(_) => shell::view_with_content(
&self.ui,
nav_item_of(self.state.route()),
APP_TITLE,
pages::page(&self.ui, &self.view).map(Message::Page),
self.options.force_state,
),
Some(_) => {
let background =
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
.map(|element| element.map(Message::Page));
shell::view_with_background(
&self.ui,
nav_item_of(self.state.route()),
APP_TITLE,
background,
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
.map(Message::Page),
self.options.force_state,
)
}
// `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。
None => shell::view(
&self.ui,
+134
View File
@@ -0,0 +1,134 @@
//! 整窗 α 的**执行侧**:句柄探测 → `WS_EX_LAYERED` → `SetLayeredWindowAttributes` → 一行日志。
//!
//! ## 为什么句柄要绕一圈 iced
//!
//! iced 0.14 只给 `window::Id`(它自己的编号),没有「给我 HWND」的公开 API;唯一的入口是
//! `window::run(id, callback)`——回调里拿到的 `&dyn Window` 实现了 `HasWindowHandle`
//! (raw-window-handle 0.6),于是能取到 `RawWindowHandle::Win32(hwnd)`。
//!
//! ## 三层责任(顺序不能换)
//!
//! 1. **探测句柄**:[`probe_hwnd`],纯取;拿不到就整条链跳过(并如实打日志,不许假装成功);
//! 2. **补分层样式**:`ensure_layered`(唯一 `unsafe` 落点,`platform/win32/transparency.rs`);
//! `SetLayeredWindowAttributes` 在没有 `WS_EX_LAYERED` 的窗口上**必然失败**(Win32 返回 0,
//! 本机 `GetLastError` 常见 87),所以这一步是硬前提;
//! 3. **下发 α**:`apply_window_alpha`。`α ≥ 1` 时它返回 `Opaque` 并且**不调 FFI**,那时也就不必
//! 改扩展样式(1、2 步都是白改),[`dispatch`] 里按这个条件短路。
//!
//! ## 为什么日志要记「下发字节」而不是「看起来多透明」
//!
//! 本机是 RDP + 4 个虚拟显示适配器:`LWA_ALPHA` 的**观感**在这里不可信。所以证据链是
//! `配置值 → 判据 → 字节 → Win32 返回值`,全部经 [`super::App::log_appearance`] 落成一行文本。
use iced::window;
use crate::platform::win32::transparency::{apply_window_alpha, ensure_layered, AlphaOutcome};
/// 一次「补样式 + 下发」的结果(日志与单测共用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Dispatch {
/// `WS_EX_LAYERED` 是否就位(`α ≥ 1` 或 `α = None` 时为 `false`:那次根本没动扩展样式)。
pub layered: bool,
/// 下发的结局。
pub alpha: AlphaOutcome,
}
impl Dispatch {
/// 日志尾巴:`layered=ok outcome=Applied { byte: 205 }`。
///
/// `layered` 有三个值,刻意区分「没用到」与「失败」:
/// - `n/a`:α ≥ 1 或被拒绝 —— 那次**根本不需要**分层窗口,不是错误;
/// - `ok`:这一位在(原本就有或本条补上);
/// - `FAILED`:需要补但补不上(无效句柄等)—— 只有这一种是坏消息。
pub(super) fn describe(&self) -> String {
let layered = match self.alpha {
AlphaOutcome::Opaque | AlphaOutcome::Rejected(_) => "n/a",
AlphaOutcome::Applied { .. } | AlphaOutcome::Failed { .. } if !self.layered => "FAILED",
_ => "ok",
};
format!("layered={layered} outcome={:?}", self.alpha)
}
}
/// 把 iced 的窗口句柄翻成 Win32 的 `HWND`(`isize`)。非 Windows 平台返回 `None`。
pub(super) fn probe_hwnd(handle: &dyn window::Window) -> Option<isize> {
use window::raw_window_handle::RawWindowHandle;
let handle = handle.window_handle().ok()?;
match handle.as_raw() {
#[cfg(target_os = "windows")]
RawWindowHandle::Win32(win32) => Some(win32.hwnd.get()),
#[cfg(not(target_os = "windows"))]
_ => None,
#[cfg(target_os = "windows")]
_ => None,
}
}
/// 补样式 + 下发(**幂等**:`α ≥ 1` 时什么都不做,返回 `Opaque`)。
pub(super) fn dispatch(hwnd: isize, alpha: Option<f64>) -> Dispatch {
// α 不透明 / 被拒绝时不需要分层窗口:`SetLayeredWindowAttributes` 那次调用也不会发生,
// 改扩展样式纯属副作用(还会让 DWM 换一条合成路径),所以短路掉。
let needs_layered = alpha.is_some_and(|alpha| alpha < 1.0);
let layered = needs_layered && ensure_layered(hwnd);
Dispatch {
layered,
alpha: apply_window_alpha(hwnd, alpha),
}
}
#[cfg(test)]
mod tests {
use super::dispatch;
use crate::platform::win32::transparency::{AlphaOutcome, AlphaRejection};
/// 抓得住:①`α = None` / `α ≥ 1` / `α = 0` 时**不去碰扩展样式**(短路条件写反会在这里红);
/// ②拒绝路径的返回值与 `transparency` 的判据一致。
/// 抓不住:真实窗口(有效句柄)上 `WS_EX_LAYERED` 是否真的加上了——那要靠本机日志的 `layered=ok`。
#[test]
fn opaque_or_refused_alpha_never_touches_the_window_style() {
for none in [None, Some(1.0), Some(1.5), Some(0.0), Some(-1.0)] {
let result = dispatch(1234, none);
assert!(!result.layered, "{none:?} 不该改扩展样式");
}
assert_eq!(
dispatch(1234, None).alpha,
AlphaOutcome::Rejected(AlphaRejection::NoAlpha)
);
assert_eq!(dispatch(1234, Some(1.0)).alpha, AlphaOutcome::Opaque);
// 无效句柄 + 合法 α:样式补不上(layered=false),FFI 也不会成功——两层都如实报告。
let invalid = dispatch(1, Some(0.5));
assert!(!invalid.layered, "无效句柄补不上 WS_EX_LAYERED");
assert!(
matches!(invalid.alpha, AlphaOutcome::Failed { .. }),
"{:?}",
invalid.alpha
);
}
/// 本机真窗口的取证由 `App` 启动日志给(见 `app::appearance::log_appearance`),
/// 这里只钉日志格式:`Applied { byte: 205 }` 必须能一眼读出字节,且「没用到样式」不等于失败。
#[test]
fn the_log_tail_carries_the_byte_and_distinguishes_skipped_from_failed() {
let applied = super::Dispatch {
layered: true,
alpha: AlphaOutcome::Applied { byte: 205 },
};
assert!(applied.describe().starts_with("layered=ok outcome=Applied"));
assert!(applied.describe().contains("205"));
// α = 1.0:没补样式是**正常**的,不许写成 FAILED(否则报告里会出现假红)。
let opaque = super::Dispatch {
layered: false,
alpha: AlphaOutcome::Opaque,
};
assert!(opaque.describe().starts_with("layered=n/a"));
// 真的要补却没补上:这才是 FAILED。
let failed = super::Dispatch {
layered: false,
alpha: AlphaOutcome::Failed { code: 87 },
};
assert!(failed.describe().starts_with("layered=FAILED"));
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
//! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 |
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI —— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 |
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 |
//!
//! ## 消费纪律
//!
@@ -0,0 +1,235 @@
//! 两层透明度的**判据层**:整窗 α / 面板 α 的钳位链、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;
/// 面板 α 下限(core 的 `PANEL_ALPHA_MIN` = 0.4,经 `unlucky_presenter::prefs` 重导出)。
pub const PANEL_ALPHA_FLOOR: f32 = unlucky_presenter::prefs::PANEL_ALPHA_MIN as f32;
/// 整窗 α 滑块位置(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(= 整窗穿透)或把面板下限降到「前景压不住背景图」。
// 两个下限都来自 `unlucky_presenter::prefs` 的重导出(M93 r2 起不再有本地字面量),
// 所以这条同时钉住「presenter 的重导出没被改坏」。
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,294 @@
//! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。
//!
//! ## 真图(M93 r2 起)
//!
//! 渲染走 iced 的 `image`(`crates/app-iced/Cargo.toml` 的 `image-without-codecs`):`Cover` /
//! `Contain` 交给 `ContentFit`,`Tile` 用本模块的 [`tiles`] 算网格后由 `picture` 真铺图块。
//! 本模块只负责「解码一次(取色 + 诊断 + 尺寸)」与「网格/暗化的纯数学」。
//!
//! | 档 | 渲染 | 露底色 |
//! |---|---|---|
//! | `cover` | `ContentFit::Cover`(铺满、裁掉溢出) | 否 |
//! | `contain` | `ContentFit::Contain`(完整显示、等比) | **是**(上下或左右) |
//! | `tile` | 原图尺寸平铺([`tiles`] 给行列数,上限 [`MAX_TILES`]) | 否(图比视口大时=裁切) |
//!
//! ## 解码只在这里(一次)
//!
//! 一张图会被两处用到:①渲染器(`image::Handle::from_path`,由 iced 自己解码;r2 实测
//! `from_bytes` 的异步纹理分配赶不上截图第 3 帧,画面会是空的);②取色(`unlucky_design` 要 RGBA
//! 像素,本模块把解码结果缩到 [`MAX_EDGE`] 内交给它)。本模块的解码只为「取色 + 诊断 + 尺寸」。
//!
//! ## 暗化(`appearance.bg_dim`)
//!
//! 语义是「让背景往面板底色退」,即把版面底色(`Role::Surface`)以 α = `dim` 叠在图上——
//! 与 `rgb + (toward − rgb) × dim` **在数学上等价**(图不透明时),所以这里只给一个钳位函数
//! [`dim_alpha`],真正的合成交给渲染器(CPU/GPU 两档都支持普通 alpha 混合)。
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
//! (偏离登记在 [`super`] 的偏离表 #7)。
use unlucky_presenter::prefs::BgFill;
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
pub const BG_DIM_MAX: f32 = unlucky_presenter::prefs::BG_DIM_MAX as f32;
/// 解码后保留给取色的最大边长:取色只需要色彩分布,4K 图缩到 256 边后内存从 33MB 掉到 147KB。
pub const MAX_EDGE: u32 = 256;
/// 解码**之前**允许的原始边长上限。
///
/// 复审 r1 P2-4:原来写 16_384,等于允许一张 16384×16384 的 PNG 被**整幅解成 RGBA**
/// (≈1.07 GB)再缩到 256——那是解压炸弹。现在按「真实会遇到的壁纸尺寸」定:8K(7680×4320)
/// 留一倍余量 ⇒ **8192**。再大的图直接 [`LoadError::TooLarge`] + 可读提示(不静默)。
pub const MAX_SOURCE_EDGE: u32 = 8_192;
/// 解码时允许的最大内存占用(`image::Limits::max_alloc`)。
///
/// 8192×8192 的 RGBA 是 256 MB;给到 320 MB 足够正常图,同时把「边长合法但通道数/位深
/// 变态」的图挡在分配之前(第二道闸,第一道是 [`MAX_SOURCE_EDGE`])。
pub const MAX_DECODE_BYTES: u64 = 320 * 1024 * 1024;
/// 平铺档「小图」的判据(dp):短边低于它就一定会被放大铺,值得给一句提示。
pub const SMALL_TILE_EDGE: f32 = 200.0;
/// 平铺档一次最多铺多少个图块(列 × 行)。超了**放大图块**而不是少铺——见 [`tiles`] 的不变量。
pub const MAX_TILES: u32 = 64;
/// 图块放大步长(每次乘这个数,直到块数落进预算)。
pub const TILE_SCALE_STEP: f32 = 1.15;
/// 图块缩放上限(**只是安全阀**,正常情况下远达不到)。
///
/// 取 1024 而不是 64:`cols·rows ≤ MAX_TILES` 与「覆盖 ≥ 视口」在**极小图**上要求很大的缩放
/// (1×1 的图在 1180×760 上要 ~118 倍才能用 64 块盖满)。取小了就等于两者不可兼得——那一侧
/// 会退化成「留缝」,正是 r1 的 P2-1。真实图片不会走到这个上限。
pub const MAX_TILE_SCALE: f32 = 1024.0;
/// 解码失败的原因(人话在 [`LoadError::describe`])。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
/// 读文件失败:不存在 / 没权限 / 被占用。
Io(String),
/// 解码失败:格式不认识(webp/tiff 等没开编码器)/ 文件截断 / 不是图片。
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, JPEG and BMP)"
),
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 bytes = std::fs::read(trimmed).map_err(|error| LoadError::Io(error.to_string()))?;
// 第一趟只读图头:先按尺寸拒掉压缩炸弹,再真解码。
let (source_w, source_h) = header_size(&bytes)?;
if source_w > MAX_SOURCE_EDGE || source_h > MAX_SOURCE_EDGE {
return Err(LoadError::TooLarge {
width: source_w,
height: source_h,
});
}
// 第二道闸(复审 r1 P2-4):把解码器也关进同样的上限里,别只靠自己先读图头。
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bytes))
.with_guessed_format()
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_SOURCE_EDGE);
limits.max_image_height = Some(MAX_SOURCE_EDGE);
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
let decoded = reader
.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 {
source: (source_w, source_h),
width: target_w,
height: target_h,
rgba: thumb.into_raw(),
})
}
/// 原图尺寸(只读图头,不解像素)。
fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
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()))
}
/// 平铺档的**诊断**(复审 r1 P2-1):小图会被放大图块,必须说出来而不是静默。
///
/// 判据只看图片本身(短边 < [`SMALL_TILE_EDGE`])——视口尺寸在渲染层才知道,而这里要的是
/// 「这张图小到要放大」这个与视口无关的事实。`scale` 条数上限由 [`tiles`] 保证覆盖 ≥ 视口。
#[must_use]
pub fn small_tile_note(image: &Image, fill: BgFill) -> Option<String> {
if !matches!(fill, BgFill::Tile) {
return None;
}
let (width, height) = (image.source.0 as f32, image.source.1 as f32);
if width.min(height) >= SMALL_TILE_EDGE {
return None;
}
Some(format!(
"Small image ({}×{}): tiles are scaled up so the window stays covered (max {MAX_TILES} tiles, no gaps).",
image.source.0, image.source.1
))
}
/// 暗化系数(叠在图上那层底色的 α):`0` = 不暗化,上限 [`BG_DIM_MAX`]。
#[must_use]
pub fn dim_alpha(dim: f32) -> f32 {
if dim.is_nan() {
0.0
} else {
dim.clamp(0.0, BG_DIM_MAX)
}
}
/// 平铺档的网格(列 × 行 × 图块缩放)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tiles {
/// 列数。
pub cols: u32,
/// 行数。
pub rows: u32,
/// 单块图相对原尺寸的缩放(`1.0` = 原尺寸;超预算时**只放大图块**,不减少覆盖)。
pub scale: f32,
}
impl Tiles {
/// 总块数。
#[must_use]
pub fn count(self) -> u32 {
self.cols * self.rows
}
/// 一块图的实际边长(dp):`源尺寸 × scale`。
#[must_use]
pub fn tile_size(self, source: (u32, u32)) -> (f32, f32) {
(
source.0.max(1) as f32 * self.scale,
source.1.max(1) as f32 * self.scale,
)
}
/// 铺满后覆盖的宽高(dp):**恒 ≥ 视口**([`tiles`] 的硬不变量,单测钉住)。
#[must_use]
pub fn covered(self, source: (u32, u32)) -> (f32, f32) {
let (w, h) = self.tile_size(source);
(self.cols as f32 * w, self.rows as f32 * h)
}
}
/// 平铺档的网格(原图 1 像素 = 1 dp)。
///
/// **硬不变量:`covered >= viewport`**——任何情况都不许留缝(复审 r1 P2-1 的返工点)。
///
/// r1 的写法是「超预算就减列/减行」,于是 120×120 的图需要 10×7=70 块 > [`MAX_TILES`],
/// 列数被砍到 9 ⇒ 覆盖宽 1080 < 1180,右缘 100px 裸白。现在的做法是**放大图块步长**:
/// `scale` 从 1.0 起按 [`TILE_SCALE_STEP`] 逐级变大,直到 `ceil(视口/图块) 的乘积 ≤ 预算`。
/// 图块按同一比例放大 ⇒ 图不变形;`ceil` 保证每行/每列都盖过视口边界(多出来的部分由渲染器裁掉)。
///
/// 图比视口大时是 `1×1, scale=1.0`(= 以原尺寸裁切),与小图平铺是同一套算式。
#[must_use]
pub fn tiles(source: (u32, u32), viewport: (f32, f32)) -> Tiles {
let mut tiles = Tiles {
cols: 1,
rows: 1,
scale: 1.0,
};
loop {
let (width, height) = tiles.tile_size(source);
tiles.cols = ceil_div(viewport.0.max(1.0), width);
tiles.rows = ceil_div(viewport.1.max(1.0), height);
if tiles.count() <= MAX_TILES || tiles.scale >= MAX_TILE_SCALE {
return tiles;
}
tiles.scale = (tiles.scale * TILE_SCALE_STEP).min(MAX_TILE_SCALE);
}
}
fn ceil_div(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` 的单测:解码(png + jpeg)、失败分类、暗化钳位、平铺网格。
//!
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。
use super::{dim_alpha, load, small_tile_note, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
use std::path::{Path, PathBuf};
use unlucky_presenter::prefs::BgFill;
fn temp_file(label: &str, name: &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(name)
}
fn cleanup(path: &Path) {
if let Some(dir) = path.parent() {
std::fs::remove_dir_all(dir).ok();
}
}
#[test]
fn a_real_png_decodes_with_its_original_size_and_raw_bytes() {
// 抓得住:真磁盘 PNG 能读、原图尺寸按图头、缩略图不超预算、原始字节留着给渲染器。
// 抓不住:渲染器用这些字节画出来的颜色(那要看截图)。
let path = temp_file("png", "bg.png");
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!(image.rgba.len(), (image.width * image.height * 4) as usize);
cleanup(&path);
}
/// M93 r2 的硬要求:`.jpg` 必须能加载(用户挑的背景图/壁纸多半是 jpeg)。
///
/// 抓得住:`image` 的 jpeg 编码器真的开了(没开时 `load` 会返回 `Undecodable`)。
/// 抓不住:渲染器那边(`iced/image`)解码 jpeg 的能力——那条由截图矩阵里那张 jpg 出图举证。
#[test]
fn a_real_jpeg_decodes_too() {
let path = temp_file("jpeg", "bg.jpg");
let source =
image::RgbImage::from_fn(64, 32, |x, y| image::Rgb([x as u8 * 4, y as u8 * 8, 90]));
source
.save_with_format(&path, image::ImageFormat::Jpeg)
.expect("写 jpeg");
let image = load(path.to_str().unwrap()).expect("jpeg 必须能读(M93 r2)");
assert_eq!(image.source, (64, 32));
assert_eq!(
image.rgba.len(),
(image.width * image.height * 4) as usize,
"像素解出来了"
);
cleanup(&path);
}
#[test]
fn a_missing_or_broken_file_reports_a_readable_reason() {
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
let missing = temp_file("missing", "bg.png");
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_file("broken", "bg.png");
std::fs::write(&broken, b"this is not an image").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 dim_is_clamped_to_the_supported_range() {
// 抓得住:暗化系数夹在 0..=0.95(下限允许 0 = 不暗化,上限防「背景看不见」),NaN 取 0。
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断)。
assert_eq!(dim_alpha(0.0), 0.0);
assert_eq!(dim_alpha(0.35), 0.35);
assert_eq!(dim_alpha(-1.0), 0.0);
assert_eq!(dim_alpha(f32::NAN), 0.0);
assert_eq!(dim_alpha(9.0), BG_DIM_MAX);
assert_eq!(BG_DIM_MAX, 0.95, "与 core 同源(presenter 重导出)");
}
/// **P2-1 的守门**:任何尺寸/视口组合下,平铺网格铺出来的覆盖宽高都必须 ≥ 视口(不许留缝),
/// 同时块数不超预算(不留缝的代价只能是「放大图块」,不能是「少铺」)。
///
/// 抓得住:谁把 `tiles()` 改回「超预算就减列」——120×120 @ 1180×760 那条会立刻红
/// (r1 实测右缘裸白 100px)。
/// 抓不住:渲染器把单块图铺进格子时的实际像素对齐(有没有半像素缝由截图看)。
#[test]
fn tile_grid_always_covers_the_viewport_within_budget() {
let cases = [
((120, 120), (1180.0, 760.0)),
((300, 200), (1180.0, 760.0)),
((16, 16), (1920.0, 1080.0)),
((8, 8), (3840.0, 2160.0)),
((1600, 900), (1180.0, 760.0)),
((1, 1), (1180.0, 760.0)),
];
for (source, viewport) in cases {
let grid = tiles(source, viewport);
let (covered_w, covered_h) = grid.covered(source);
assert!(
covered_w >= viewport.0 - 0.01 && covered_h >= viewport.1 - 0.01,
"{source:?} @ {viewport:?} 覆盖 {covered_w}×{covered_h} 小于视口(会留缝):{grid:?}"
);
assert!(
grid.count() <= MAX_TILES,
"{source:?} @ {viewport:?} 块数 {} 超预算",
grid.count()
);
assert!(grid.scale >= 1.0, "图块只许放大,不许缩小:{grid:?}");
}
// 图比视口大 ⇒ 1×1 原尺寸裁切(scale 必须还是 1.0,否则等于缩小了原图)。
let big = tiles((1600, 900), (1180.0, 760.0));
assert_eq!((big.cols, big.rows, big.scale), (1, 1, 1.0));
// 小图按原尺寸铺够(不放大时)。
let small = tiles((300, 200), (1180.0, 760.0));
assert_eq!((small.cols, small.rows, small.scale), (4, 4, 1.0));
}
/// 编译期钉住:填充档解析走 core 的词表(`cover`/`contain`/`tile`)。
#[test]
fn fill_keys_are_the_schema_vocabulary() {
// 抓得住:有人改了落盘词表却没改这里(截图矩阵的 env 也按这三个词传)。
assert_eq!(BgFill::from_key_value("cover"), BgFill::Cover);
assert_eq!(BgFill::from_key_value("contain"), BgFill::Contain);
assert_eq!(BgFill::from_key_value("tile"), BgFill::Tile);
}
/// **复审 r1 P2-4**:解压炸弹的第二道闸——图头就超上限时要走 `TooLarge`,而且这条分支
/// 以前**没有任何测试**(三态诊断里唯一没被钉住的一态)。
///
/// 抓得住:`header_size` 的尺寸判据(构造一张 9000×4 的真 PNG,只花 ~100 KB 就能造出来)。
/// 抓不住:内存分配真的被 `image::Limits` 挡住(那要真喂一张巨图跑到 OOM,本机不做)。
#[test]
fn an_over_sized_image_reports_too_large_instead_of_trying_to_decode() {
let path = temp_file("huge", "huge.png");
let source = image::RgbImage::new(9_000, 4);
source.save(&path).expect("写大尺寸图");
let error = load(path.to_str().unwrap()).expect_err("超过 MAX_SOURCE_EDGE 必须报错");
assert!(
matches!(
error,
LoadError::TooLarge {
width: 9_000,
height: 4
}
),
"实际 {error:?}"
);
assert!(
error.describe().contains("too large"),
"提示要人话:{}",
error.describe()
);
// 上限不许再放宽(r1 的 16384 等于允许 1 GB 的中间缓冲):常量级约束放在测试里钉。
const _: () = assert!(super::MAX_SOURCE_EDGE <= 8_192);
cleanup(&path);
}
/// **复审 r1 P2-1 的诊断面**:小图平铺会被放大(图块步长放大,见 `tiles`),必须说出来。
///
/// 抓得住:短边 < `SMALL_TILE_EDGE` 的图在 tile 档下一定有提示;cover/contain 档不提示;
/// 大图不提示。
/// 抓不住:提示文案在真实界面上的位置(由截图看)。
#[test]
fn small_images_get_a_tile_diagnostic_instead_of_silently_degrading() {
let small = super::Image::from_rgba((32, 32), 4, 4, vec![0; 64]);
let note = small_tile_note(&small, BgFill::Tile).expect("小图 tile 必须有提示");
assert!(note.contains("32×32"), "{note}");
assert!(note.contains("scaled up"), "{note}");
// 非 tile 档不提示(cover/contain 会等比铺满,不存在「小图被放大铺」这件事)。
assert!(small_tile_note(&small, BgFill::Cover).is_none());
assert!(small_tile_note(&small, BgFill::Contain).is_none());
// 大图不提示。
let big = super::Image::from_rgba((1200, 800), 4, 4, vec![0; 64]);
assert!(small_tile_note(&big, BgFill::Tile).is_none());
}
+168
View File
@@ -0,0 +1,168 @@
//! # 外观接线(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`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
//! | `section` | 设置页「外观」分组(真控件) | 无 |
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
//!
//! ## 偏离登记(D30 要求逐条写清)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
//! |---|---|---|---|
//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:iced 的 `image-without-codecs` 打开,真图直出(`ContentFit` + 平铺网格) | r1 时 `Cargo.toml` 不在 scope;M93 r2 由 tower 扩 scope 解决 | ✅ |
//! | 2 | ~~面板 α 只覆盖外壳层~~ → **r2 已销**:α 收敛到 `Ui::layer`(唯一施加点),`frame::card` / 页头带 / 外壳 / 外观卡片自动同源 | M93 r2 扩了 `pages/frame.rs` + `theme.rs` 的 scope | ✅ |
//! | 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~~ → **r2 已销**:`image` 补了 `jpeg`/`bmp`,单测与截图都用 `.jpg` 举证 | 同上 | ✅ |
//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dim_alpha`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) |
//! | 8 | ~~页头文字压在画布上~~ → **r2 已销**:`frame::header_bar` 加了面板底色 | M93 r2 扩了 `pages/frame.rs` 的 scope | ✅ |
//!
//! ## 取证
//!
//! 每一处改动都打一行 [`state::State::log_line`]:`[appearance] window_alpha=… panel_alpha=…
//! byte=…`。数值是**下发前**的字节(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明」
//! 不可信。截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。
pub mod alpha;
pub mod background;
mod palette;
mod picture;
mod section;
mod state;
pub use picture::layer;
pub use section::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,113 @@
//! 背景层的**像素**:真图直出(`Cover` / `Contain` / `Tile`)+ 暗化叠层。
//!
//! 与 [`super::section`] 拆开只为单文件 ≤400 行的纪律;对外入口仍是
//! `pages::appearance::layer`(本文件 [`layer`] 的再导出)。
//!
//! ## 三种填充
//!
//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做);`Tile` 用
//! [`background::tiles`] 算行列后铺 N 个图块(单块尺寸 = 原尺寸 × `tiles.scale`)。
//!
//! 暗化不是逐像素改色,而是**盖一层「版面底色 @ α=dim」**:图不透明时它与「向底色插值」
//! 数学等价([`background::dim_alpha`] 有推导),而普通 alpha 混合 CPU/GPU 两档都支持。
use iced::widget::image;
use iced::widget::{column, container, image as image_widget, space};
use iced::{ContentFit, Element, Length};
use unlucky_design::color::Role;
use unlucky_presenter::prefs::BgFill;
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::background;
use super::state::State;
/// 背景层(铺满整窗、垫在最下层)。`None` = 没有可用的背景图(调用方就不叠这一层)。
///
/// 真图直出(M93 r2):`Cover` / `Contain` 交给 iced 的 `ContentFit`,`Tile` 用
/// [`background::tiles`] 算网格铺 N 个图块。暗化 = 一层「版面底色 @ α=dim」盖在图上
/// (见 [`background::dim_alpha`] 的等价性说明)。
#[must_use]
pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option<Element<'static, Message>> {
let picture = state.image()?;
if !state.bg_enabled {
return None;
}
let handle = image_handle(state.bg_path.trim());
let art: Element<'static, Message> = match state.bg_fill {
BgFill::Cover => fitted(handle, ContentFit::Cover),
BgFill::Contain => fitted(handle, ContentFit::Contain),
BgFill::Tile => tiled(handle, picture.source, viewport),
};
// 暗化:底色 @ α=dim 盖在图上(等价于逐像素向底色插值,见 `background::dim_alpha`)。
let scrim_color = ui
.color(Role::Surface)
.scale_alpha(background::dim_alpha(state.bg_dim));
let scrim: Element<'static, Message> = container(space::horizontal())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| frame::filled(scrim_color))
.into();
Some(
iced::widget::stack![art, scrim]
.width(Length::Fill)
.height(Length::Fill)
.into(),
)
}
/// 文件字节 → iced 的图片句柄(渲染器自己解复用,不再读盘)。
fn image_handle(path: &str) -> image::Handle {
image::Handle::from_path(path)
}
/// `Cover` / `Contain`:交给渲染器的等比缩放档。
fn fitted(handle: image::Handle, fit: ContentFit) -> Element<'static, Message> {
image_widget(handle)
.content_fit(fit)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// `Tile`:按 [`background::tiles`] 的网格平铺。
///
/// 三个细节都是为了「不留缝」(复审 r1 P2-1):
/// 1. 单块边长 = `源尺寸 × tiles.scale`——超预算时图块**等比例放大**(不是少铺),
/// 所以 `cols × 边长 >= 视口宽`、`rows × 边长 >= 视口高` 由 [`background::tiles`] 保证;
/// 2. `ContentFit::Fill`:`None` 会按**图片原生尺寸**画,比格子小就留空——那道缝正是它造成的;
/// 这里格子尺寸恰好等于「等比放大后的尺寸」,`Fill` 就是等比缩放本身(不变形);
/// 3. 行高显式写死(`Fixed`),列整体 `Fill`:不靠子控件撑,布局结果与算式一致。
fn tiled(
handle: image::Handle,
source: (u32, u32),
viewport: (f32, f32),
) -> Element<'static, Message> {
let tiles = background::tiles(source, viewport);
let (width, height) = tiles.tile_size(source);
let rows = (0..tiles.rows)
.map(|_| {
let cells = (0..tiles.cols)
.map(|_| -> Element<'static, Message> {
image_widget(handle.clone())
.content_fit(ContentFit::Fill)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.into()
})
.collect::<Vec<_>>();
iced::widget::row(cells)
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
})
.collect::<Vec<_>>();
column(rows).width(Length::Fill).height(Length::Fill).into()
}
@@ -0,0 +1,354 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 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::{self, BG_DIM_MAX};
use super::state::State;
use super::Change;
/// 「外观」分组整块(一张卡)。
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),
);
// ⑤ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
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
.image()
.and_then(|image| background::small_tile_note(image, state.bg_fill))
{
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)));
// ⑥ 填充
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),
),
],
));
card(ui, rows)
}
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
let background = ui.layer(MaterialLayer::Card);
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,368 @@
//! 外观的**运行期状态**:配置值 → 生效值(钳位 / 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};
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()
}
/// 取色得到的方案(`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())
}
+4
View File
@@ -32,6 +32,7 @@ use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
@@ -122,6 +123,8 @@ fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Elem
.spacing(HEADER_ACTION_GAP)
.align_y(Alignment::Center);
// 页头带也要有面板底色(M93):否则「开背景图 + 面板 α<1」时标题/副标题直接压在图上,
// 浅色主题的副标题(OnSurfaceVariant)对比度掉得厉害。底色同源于 `Ui::layer`(含面板 α)。
container(
row![titles.width(Length::Fill), actions]
.width(Length::Fill)
@@ -131,6 +134,7 @@ fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Elem
.height(ui.metrics().header_height(header.subtitle.is_some()))
.align_y(Alignment::Center)
.padding(Padding::from([0.0, metrics.gutter]))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
+1 -1
View File
@@ -78,7 +78,7 @@ pub fn empty_state<'a>(
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerHigh))),
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(spec.shape),
..Border::default()
+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(), "演示下标仍在范围内");
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` + `SetLayeredWindowAttributes`)。
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
//!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
@@ -11,8 +11,30 @@
//! ## 只做 LWA_ALPHA
//!
//! `LWA_COLORKEY` / `DwmEnableBlurBehindWindow` 一律不用:色键会把某个颜色整块变透明(视觉
//! 事故),模糊是另一条路线(D36 已排除)。`WS_EX_LAYERED` 是调用前提,由窗口创建侧设置,
//! 本文件不负责——它只做「给定句柄,把 α 下发下去」。
//! 事故),模糊是另一条路线(D36 已排除)。
//!
//! `WS_EX_LAYERED`(`SetLayeredWindowAttributes` 的调用前提)**也归本文件**:加扩展位只有
//! `Get/SetWindowLongPtrW` 能走,必须 `unsafe`,而全仓放行 unsafe 的文件集合恰好只有本文件。
//! 所以窗口创建侧(`app::boot`)不碰扩展样式,它只负责拿到句柄后先调 [`ensure_layered`]
//! 再调 [`apply_window_alpha`]——两件事都在同一个 unsafe 落点里,门禁仍是「集合相等」。
//!
//! ## 为什么它不影响命中测试与自绘标题栏拖动(D36 任务书要求写清)
//!
//! `LWA_ALPHA` 只改**合成**(DWM 把整窗乘一个常数),不动窗口的命中测试:
//!
//! | 机制 | 会不会让鼠标穿透 | 本项目用不用 |
//! |---|---|---|
//! | `WS_EX_TRANSPARENT`(分层窗口的「点击穿透」位) | **会**(鼠标事件直接发给下层的窗口) | 不用 |
//! | `LWA_COLORKEY`(色键:某种颜色整块变透明) | **会**(命中测试按颜色挖洞) | 不用 |
//! | `WS_EX_LAYERED` + `LWA_ALPHA` | **不会**(只是合成时乘 α,整个矩形仍然接收命中) | 用这一条 |
//!
//! 所以自绘标题栏的拖动照旧:`Message::DragWindow` → `window::drag(id)` 走 winit 的
//! `WM_NCLBUTTONDOWN(HTCAPTION)`,它依赖的是**窗口矩形内的鼠标事件**,与 α 无关。
//! 唯一会「点不动」的情形是 α = 0(整窗不可见,用户以为程序消失)——这就是
//! [`unlucky_presenter::prefs::WINDOW_ALPHA_MIN`](0.08)作为硬底、且 `α ≤ 0` 直接拒绝的原因。
//!
//! D36 已在真机上实测「α ≥ 1 时分层样式不破坏命中测试与拖动」;本仓库的 RDP 环境无法复核
//! 观感,所以这条结论的**行为**部分归真机验收(见 M93 报告「没验的部分」)。
//!
//! ## 非 Windows
//!
@@ -29,6 +51,12 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
pub const LWA_ALPHA: u32 = 0x0000_0002;
/// `WS_EX_LAYERED`:分层窗口扩展样式(`0x0008_0000`)。**整窗 α 的调用前提**。
pub const WS_EX_LAYERED: isize = 0x0008_0000;
/// `GWL_EXSTYLE`:扩展样式在 `Get/SetWindowLongPtrW` 里的槽位号(`-20`)。
pub const GWL_EXSTYLE: i32 = -20;
/// 一次整窗 α 应用的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaOutcome {
@@ -77,11 +105,96 @@ pub fn alpha_byte(alpha: f64) -> u8 {
(clamped * 255.0).ceil() as u8
}
/// 给窗口补上 `WS_EX_LAYERED`(幂等)。
///
/// ## 为什么这件事必须在这里(而不是「窗口创建侧」)
///
/// 加扩展样式只有 `GetWindowLongPtrW` / `SetWindowLongPtrW` 一条路,都是 `unsafe`;而
/// [`super::super`] 的 CI 门禁(`.github/workflows/guards.yml` 的 `unsafe-scope`)要求
/// 全仓显式放行 `unsafe_code` 的文件集合**恰好等于本文件**。所以窗口创建侧的
/// `app/boot.rs` 只负责「拿句柄 → 调本函数 → 调 [`apply_window_alpha`]」,
/// 不新增第二个放行文件。
///
/// ## 为什么创建侧无法预先带上这一位
///
/// iced 0.14 的 `window::Settings` 没有扩展样式入口;winit 0.30 只在
/// `IGNORE_CURSOR_EVENT`(鼠标穿透,`WS_EX_TRANSPARENT | WS_EX_LAYERED`)时置位,
/// `transparent: true` 走的是 DWM 那条路(`window.rs` 的 `ENABLE BLUR BEHIND WINDOW`),
/// 与 `LWA_ALPHA` 无关。所以只能拿到句柄后补。
///
/// ## 返回值
///
/// `true` = 这一位**已就位**(原本就有,或本次加上了)。判据是**再加读一次**验证,
/// 而不是信 `SetWindowLongPtrW` 的返回值:Win32 的它「失败返回 0 且成功也可能返回 0」
/// (前值就是 0 时),只能靠回读取证。句柄为 0 / 无效 → `false`(`GetWindowLongPtrW`
/// 也读不到这一位)。
pub fn ensure_layered(hwnd: isize) -> bool {
if hwnd == 0 {
return false;
}
if has_layered(hwnd) {
return true;
}
raw_set_ex_style(hwnd, raw_ex_style(hwnd) | WS_EX_LAYERED);
has_layered(hwnd)
}
/// 读扩展样式(跨位宽的唯一落点:64 位走 `GetWindowLongPtrW`,32 位走 `GetWindowLongW`)。
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
fn raw_ex_style(hwnd: isize) -> isize {
// SAFETY: 只读一个窗口属性,按值传参、不持有指针、无所有权转移;无效句柄只会返回 0。
unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }
}
/// 32 位目标:符号是 `GetWindowLongW`(`link_name` 已映射),返回 `i32`,扩到 `isize`。
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
fn raw_ex_style(hwnd: isize) -> isize {
// SAFETY: 同 64 位分支。
unsafe { isize::from(GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) }
}
/// 非 Windows:没有扩展样式这个概念,读 0(`ensure_layered` 因此恒 `false`)。
#[cfg(not(target_os = "windows"))]
fn raw_ex_style(_hwnd: isize) -> isize {
0
}
/// 写扩展样式(位宽分档同上)。
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
fn raw_set_ex_style(hwnd: isize, style: isize) {
// SAFETY: 同 `raw_ex_style`:按值传参、不持有指针;返回值刻意不用(改完靠回读取证)。
unsafe {
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
}
}
/// 32 位目标:符号是 `SetWindowLongW`;扩展样式本身就是 32 位量,收窄无损。
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
fn raw_set_ex_style(hwnd: isize, style: isize) {
let style = i32::try_from(style).unwrap_or(i32::MAX);
// SAFETY: 同 64 位分支。
unsafe {
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
}
}
/// 非 Windows:空实现。
#[cfg(not(target_os = "windows"))]
fn raw_set_ex_style(_hwnd: isize, _style: isize) {}
/// 回读判据:这一位在不在扩展样式里。
fn has_layered(hwnd: isize) -> bool {
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
}
/// 把整窗 α 下发到窗口。
///
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
///
/// 调用前提是窗口已带 `WS_EX_LAYERED`(见 [`ensure_layered`]):没有它 `SetLayeredWindowAttributes`
/// 一定失败(Win32 返回 0,本机实测 `GetLastError` 常见 87 / `ERROR_INVALID_PARAMETER`)。
pub fn apply_window_alpha(hwnd: isize, alpha: Option<f64>) -> AlphaOutcome {
let Some(alpha) = alpha.filter(|value| value.is_finite() && *value > 0.0) else {
return AlphaOutcome::Rejected(AlphaRejection::NoAlpha);
@@ -102,6 +215,26 @@ extern "system" {
///
/// 签名照 Win32 头文件:`BOOL SetLayeredWindowAttributes(HWND, COLORREF, BYTE, DWORD)`。
fn SetLayeredWindowAttributes(hwnd: isize, color_key: u32, alpha: u8, flags: u32) -> i32;
/// `GetWindowLongPtrW`:读窗口属性(`GWL_EXSTYLE` = `-20`)。
///
/// 头文件里 `GetWindowLongPtrW` 是宏:64 位映射到 `GetWindowLongPtrW`、32 位映射到
/// `GetWindowLongW`。user32 在 32 位导出里**没有** `...PtrW`,所以这里按指针宽度分档声明,
/// 否则 i686 目标会在链接期失败(本仓的 Win7 冒烟是 x64,但编译不该只在一个位宽上成立)。
#[cfg(target_pointer_width = "64")]
fn GetWindowLongPtrW(hwnd: isize, index: i32) -> isize;
/// `SetWindowLongPtrW`:写窗口属性(同上,32 位映射到 `SetWindowLongW`)。
#[cfg(target_pointer_width = "64")]
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: isize) -> isize;
/// 32 位目标的实际符号(见上)。
#[cfg(target_pointer_width = "32")]
#[link_name = "GetWindowLongW"]
fn GetWindowLongPtrW(hwnd: isize, index: i32) -> i32;
/// 32 位目标的实际符号(见上)。
#[cfg(target_pointer_width = "32")]
#[link_name = "SetWindowLongW"]
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: i32) -> i32;
}
#[cfg(target_os = "windows")]
@@ -127,9 +260,28 @@ fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
#[cfg(test)]
mod tests {
use super::{alpha_byte, apply_window_alpha, AlphaOutcome, AlphaRejection, LWA_ALPHA};
use super::{
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
LWA_ALPHA, WS_EX_LAYERED,
};
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
#[test]
fn layered_constants_match_the_win32_header() {
// 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。
// 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。
assert_eq!(WS_EX_LAYERED, 0x0008_0000);
assert_eq!(GWL_EXSTYLE, -20);
}
#[test]
fn ensure_layered_refuses_a_missing_or_invalid_window() {
// 句柄 0 = 还没拿到窗口:不许「假设成功」。
assert!(!ensure_layered(0), "句柄 0 必须返回 false");
// 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。
assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)");
}
#[test]
fn alpha_byte_quantizes_upward_never_below_the_floor() {
assert_eq!(alpha_byte(1.0), 255);
+41 -7
View File
@@ -51,13 +51,29 @@ use crate::theme::Ui;
/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距),
/// 两边的账不会算串。
///
/// [`view`] 是它的兼容包装:`content` 换成 M68 的「页头 + 一张卡」。
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
pub fn view_with_content<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
content: Element<'a, Message>,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
view_with_background(ui, selected, app_title, None, content, forced)
}
/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。
///
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
pub fn view_with_background<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
background: Option<Element<'a, Message>>,
content: Element<'a, Message>,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let work_area = column![
titlebar(ui, app_title, forced),
@@ -68,7 +84,7 @@ pub fn view_with_content<'a>(
.width(Length::Fill)
.height(Length::Fill);
window_frame(ui, work_area.into())
window_frame(ui, background, work_area.into())
}
/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。
@@ -92,8 +108,13 @@ pub fn view<'a>(
)
}
/// 无边框窗口的「外壳壳子」:工作区 + 两条缩放热区 + 根容器底色。
fn window_frame<'a>(ui: &'a Ui, work_area: Element<'a, Message>) -> Element<'a, Message> {
/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。
///
fn window_frame<'a>(
ui: &'a Ui,
background: Option<Element<'a, Message>>,
work_area: Element<'a, Message>,
) -> Element<'a, Message> {
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
// 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。
@@ -124,12 +145,25 @@ fn window_frame<'a>(ui: &'a Ui, work_area: Element<'a, Message>) -> Element<'a,
.height(Length::Fill)
.align_y(Alignment::End);
let shell = stack![work_area, east, south];
// 背景在最底层(M93 / D34)。有背景层时**根容器不再盖实色画布**:否则那张不透明的
// `Canvas` 会把背景图整块挡住(r2 实测:panel α=1 时背景完全看不见)。
// 各面板(标题栏 / 导航轨 / 卡片 / 页头带)自己画底色,空隙里露出的就是背景图。
let has_background = background.is_some();
let shell = match background {
Some(layer) => stack![layer, work_area, east, south],
None => stack![work_area, east, south],
};
container(shell)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.style(move |_theme| {
if has_background {
container::Style::default()
} else {
filled(ui.layer(MaterialLayer::Canvas))
}
})
.into()
}
@@ -184,7 +218,7 @@ fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Elem
container(bar)
.width(Length::Fill)
.height(metrics.titlebar_height)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
+3 -1
View File
@@ -92,11 +92,13 @@ pub(super) fn rail<'a>(
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
//
// 轨底色与其它面板同源(M93):`ui.layer` 已含面板 α,导航轨是面板层的一部分。
container(column(items).spacing(metrics.nav_item_gap))
.width(nav_rail_width(metrics))
.height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
+100 -34
View File
@@ -1,23 +1,13 @@
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
//!
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方(M61 §0):组件层只调
//! [`Ui`],绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**`)。
//!
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
//! 会扫 `src/**` 做守门。
//! ## 为什么不用 `iced::theme::Base` 承载 49 个角色
//!
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
//!
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
//!
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
//!
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
//! iced 的 `Palette` 只有 6 色,M3 有 49 个角色(M60 Q5):真源是本模块的 [`Scheme`],`Theme`
//! 只用来「换名字」(名字变了 iced 才重算缓存),控件外观由各自的 `Style` 注入。
//! `success`/`warning` 没有对应角色,按邻近语义映射(→ `Secondary` / `Tertiary`),不许读。
use iced::border::Radius;
use iced::theme::Palette;
@@ -29,10 +19,7 @@ use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。
///
/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用),
/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。
/// 全站描边宽度(rubric G7:1px)。令牌层的 `stroke::HAIRLINE` 是唯一定义点,这里只再导出。
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
@@ -48,15 +35,16 @@ pub struct Families {
pub mono: &'static str,
}
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
///
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。热切 = 重建 `Ui` 并整树重绘,
/// 控件里不要缓存颜色。
#[derive(Debug, Clone)]
pub struct Ui {
scheme: Scheme,
platform: Platform,
metrics: LayoutMetrics,
families: Families,
/// 面板层不透明度(D36 / M93):**唯一的 α 来源**,由 [`Ui::layer`] 统一施加。
panel_alpha: f32,
}
impl Ui {
@@ -72,14 +60,29 @@ impl Ui {
platform,
metrics: LayoutMetrics::DESKTOP,
families,
panel_alpha: 1.0,
}
}
/// 当前深浅模式。
/// 当前深浅。
pub fn flavor(&self) -> SchemeFlavor {
self.scheme.flavor()
}
/// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉 baseline)。
///
/// 追加式入口:`new` / `with_platform` 的行为一字未动(默认仍是 [`Scheme::baseline`]);
/// 平台与骨架尺寸仍按当前环境求值——方案只带颜色。
pub fn from_scheme(scheme: Scheme, families: Families) -> Self {
Self {
scheme,
platform: Platform::current(),
metrics: LayoutMetrics::DESKTOP,
families,
panel_alpha: 1.0,
}
}
/// 当前平台(决定字体族与字重降级)。
pub fn platform(&self) -> Platform {
self.platform
@@ -105,25 +108,49 @@ impl Ui {
to_iced(self.scheme.get(role))
}
/// 材质层(Canvas/Card/Overlay)→ 容器色。
/// 材质层(Canvas/Card/Overlay)→ 容器色;**只有令牌允许半透明的层才乘面板 α**(M93 唯一来源)。
///
/// α 乘在这里而不是调用点:所有表面色都从这里出去(根容器/标题栏/导航轨/页头带/卡片),
/// 一次同源、调用点无从「各自自乘」。Canvas 层恒不透明(r1 P2-2):令牌
/// [`MaterialLayer::allows_translucency`] 写死 `Canvas => false`(窗口底),而三个窗口键与
/// 导航轨、页头带都取 Canvas——给它们乘 α 会让文字直接压在背景照片上(α=0.55 实测不可读)。
pub fn layer(&self, layer: MaterialLayer) -> Color {
self.color(layer.surface_role())
let base = self.color(layer.surface_role());
if layer.allows_translucency() {
base.scale_alpha(self.panel_alpha)
} else {
base
}
}
/// 状态层叠在容器色上,返回**不透明**结果。
///
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
/// 当前面板 α(CPU 档由 `app` 侧强制成 1.0)。
pub fn panel_alpha(&self) -> f32 {
self.panel_alpha
}
/// 换一个面板 α(只影响 `layer()` 出的表面色,不动角色色 `color()`;默认 `1.0`)。
#[must_use]
pub fn with_panel_alpha(mut self, alpha: f32) -> Self {
self.panel_alpha = if alpha.is_finite() {
alpha.clamp(0.0, 1.0)
} else {
1.0
};
self
}
/// 状态层叠在容器色上,返回**不透明**结果(D27:GPU/CPU 两档状态色逐像素一致;
/// 只有材质/面板才允许两档不同)。
pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color {
to_iced(state.over(self.scheme.get(content), self.scheme.get(container)))
}
/// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
/// 圆角(`side` = 控件在该轴上的边长,`Full` 要按它折算)。
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
Radius::new(shape.radius_px(side))
}
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
/// 固定档圆角。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
debug_assert!(
!matches!(shape, ShapeScale::Full),
@@ -155,7 +182,7 @@ impl Ui {
role.emphasized()
}
/// 某个排版档对应的 iced 字体(族 + 字重)。
/// 排版档 → iced 字体(族 + 字重)。
pub fn font_of(&self, style: TypeStyle) -> Font {
let family = match style.family {
FontFamily::Display => self.families.display,
@@ -180,7 +207,7 @@ impl Ui {
self.font_of(self.emphasized(role))
}
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
/// iced 的 6 色 `Palette`(只给 devtools;M3 的 49 角色在 [`Ui::color`])。
pub fn palette(&self) -> Palette {
Palette {
background: self.color(Role::Surface),
@@ -331,4 +358,43 @@ mod tests {
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
assert_ne!(hovered, ui.color(Role::Surface));
}
/// D38:配色方案必须真的换色;默认构造逐字段等于 baseline(防回流的那一半)。
/// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。
#[test]
fn a_resolved_scheme_replaces_the_baseline_colours() {
use unlucky_design::palette::{resolve_source, CardKey, ImageInput};
// 4×4 的纯红图:种子彩度远高于门槛,必然走到「背景图取色」这一级。
let pixels = [0xC0_u8, 0x20, 0x20, 0xFF].repeat(16);
let input = ImageInput::pixels(&pixels, 4, 4);
let (scheme, provenance) =
resolve_source(Some(input), None, Some(CardKey::Sky), SchemeFlavor::Light);
assert_eq!(
provenance.level,
unlucky_design::palette::Level::BackgroundImage,
"纯红图必须取色成功(否则本测试的输入不合格)"
);
let tinted = Ui::from_scheme(scheme, FAMILIES);
let baseline = ui(SchemeFlavor::Light);
assert_ne!(
tinted.color(Role::Primary),
baseline.color(Role::Primary),
"取色方案必须换掉 primary"
);
// 深浅仍跟着方案走(方案自带 flavor),不会把 Light 变成 Dark。
assert_eq!(tinted.flavor(), SchemeFlavor::Light);
assert_eq!(tinted.name(), baseline.name());
// 默认构造不变:`with_platform` 与 baseline 逐块相等。
let explicit = Ui::from_scheme(Scheme::baseline(SchemeFlavor::Light), FAMILIES);
for role in Role::ALL {
assert_eq!(
explicit.color(role),
baseline.color(role),
"{role:?}:不传 scheme 时默认行为必须与改动前一致"
);
}
}
}
+30 -29
View File
@@ -9,6 +9,7 @@
use serde_json::Value;
use unlucky_core::game::java::JdkVendor;
use unlucky_core::setting::spec::ValueKind;
use crate::dto::{SettingGroupView, SettingRowView};
@@ -336,37 +337,37 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
}
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
///
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):原先是「硬编码 Int/Bool
/// 键名,其余当字符串」,于是 `appearance.window_opacity`(Float)被当 String 送进 `store.set`,
/// schema 的 `as_f64()` 返回 `None` ⇒ `InvalidValue` ⇒ **`save()` 不执行**(重启回旧值 + 弹 toast)。
/// 现在新增任何 Float/Int/Bool 键都自动正确;集合级守门是
/// `every_float_key_in_the_schema_can_be_written_back`。
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
if matches!(key, "download.concurrency" | "download.retry_limit") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
let schema = unlucky_core::setting::keys::launcher_schema();
let Some(spec) = schema.spec(key) else {
// 未知键:原样交下去,由 `store.set` 用 schema 报「未知键」(这里不猜类型)。
return Ok(Value::String(value.to_string()));
};
let trimmed = value.trim();
match &spec.kind {
ValueKind::Bool => match trimmed.to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => Ok(Value::Bool(true)),
"0" | "false" | "off" | "no" => Ok(Value::Bool(false)),
_ => Err(format!("{key} 需要布尔值:{value}")),
},
ValueKind::Int { .. } => trimmed
.parse::<i64>()
.map(Value::from)
.map_err(|_| format!("{key} 需要整数:{value}")),
ValueKind::Float { .. } => match trimmed.parse::<f64>() {
Ok(number) if number.is_finite() => Ok(Value::from(number)),
_ => Err(format!("{key} 需要有限数字:{value}")),
},
// 文本 / 枚举 / 路径 / 列表 / 任意 JSON:字符串原样交下去(越界与词表由 schema 判)。
_ => Ok(Value::String(value.to_string())),
}
if matches!(key, "java.default_memory_mb" | "agent.mcp.port") {
let number: u32 = value
.trim()
.parse()
.map_err(|_| format!("{key} 需要整数:{value}"))?;
return Ok(Value::from(number));
}
if matches!(
key,
"java.auto_install"
| "ai.enabled"
| "ai.yolo"
| "agent.mcp.enabled"
| "agent.mcp.allow_write"
) {
let flag = match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "on" | "yes" => true,
"0" | "false" | "off" | "no" => false,
_ => return Err(format!("{key} 需要布尔值:{value}")),
};
return Ok(Value::Bool(flag));
}
Ok(Value::String(value.to_string()))
}
/// 清掉 24h 地区缓存(下次建引擎重新判定)。
@@ -75,3 +75,89 @@ fn detect_region_action_removes_cache() {
assert!(!path.exists());
std::fs::remove_dir_all(&dir).ok();
}
/// **P1-1 的回归**:整窗透明度是 `ValueKind::Float` 的键,`SetSetting` 的载荷是字符串。
///
/// 历史(复审 r1 P1-1):`parse_value` 只认硬编码的 Int/Bool 键名,Float 键被当成字符串交给
/// `store.set`,schema 的 `as_f64()` 对 String 返回 None ⇒ `InvalidValue` ⇒ **`save()` 不执行**,
/// 于是 α 只在内存生效、重启回旧值,且每次拖动弹一条 Error toast。
///
/// 抓得住:`set` → 落盘 → **新开一个 store(= 模拟重启)** 读回同一值。
/// 抓不住:UI 侧是否真的把滑块接到 `SetSetting`(由 `app` 侧的 `on_appearance` 测试管)。
#[test]
fn float_key_round_trips_through_set_setting() {
let dir = std::env::temp_dir().join(format!("ull-host-settings-float-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
set(&env, "appearance.window_opacity", "0.8").expect("写整窗透明度");
set(&env, "appearance.panel_alpha", "0.55").expect("写面板透明度");
// 新开一个 store:从磁盘重新加载(等价「重启进程」)。
let reopened = env.store();
assert_eq!(
reopened.doc().effective_f64("appearance.window_opacity"),
Some(0.8)
);
assert_eq!(
reopened.doc().effective_f64("appearance.panel_alpha"),
Some(0.55)
);
// 越界仍然被 schema 拒绝(钳位在 core 的读侧,写侧是拒绝)。
assert!(set(&env, "appearance.window_opacity", "0.0").is_err());
// 非数字报人话,而不是「期望数字,实际是 \"abc\"」这种 schema 原话。
assert!(set(&env, "appearance.window_opacity", "abc").is_err());
assert!(set(&env, "appearance.window_opacity", "NaN").is_err());
std::fs::remove_dir_all(&dir).ok();
}
/// **集合级守门(复审 P1-1 要求的那条)**:schema 里**每一个** `ValueKind::Float` 的键都必须
/// 能被 `SetSetting` 写回,而不只是 `window_opacity`。
///
/// 为什么这条比单键测试重要:下次谁再加一个 Float 键(比如玻璃强度、模糊半径),
/// 只要 `parse_value` 又退化成「硬编码表」,这里立刻红——单键测试抓不住新键。
///
/// 抓得住:类型解析与 `save()` 是否真的执行(读回是**新开 store**,走磁盘)。
/// 抓不住:UI 控件是否接上了这些键(那是各页面的接线测试)。
#[test]
fn every_float_key_in_the_schema_can_be_written_back() {
let dir =
std::env::temp_dir().join(format!("ull-host-settings-allfloat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = env_in(&dir);
let schema = unlucky_core::setting::keys::launcher_schema();
let mut checked = 0_usize;
for spec in schema.specs() {
let unlucky_core::setting::spec::ValueKind::Float { min, max } = spec.kind else {
continue;
};
// 取一个落在 [min, max] 内的值(0.5 是多数区间的好选择,落不进时取中点)。
let target = if (min..=max).contains(&0.5) {
0.5
} else {
(min + max) / 2.0
};
set(&env, spec.key, &format!("{target}"))
.unwrap_or_else(|error| panic!("Float 键 {} 写不回:{error}", spec.key));
let reread = env
.store()
.doc()
.effective_f64(spec.key)
.unwrap_or_else(|| panic!("Float 键 {} 写完后读不到", spec.key));
assert!(
(reread - target).abs() < 1e-9,
"Float 键 {} 写 {target} 读回 {reread}",
spec.key
);
checked += 1;
}
assert!(
checked >= 3,
"schema 里 Float 键只剩 {checked} 个:本断言要求 ≥3(window_opacity / panel_alpha / bg_dim / ui.scale)。\
若是有意删减,请同步更新这条断言"
);
std::fs::remove_dir_all(&dir).ok();
}
+7 -91
View File
@@ -13,7 +13,12 @@ pub use unlucky_core::setting::appearance::PALETTE_SOURCES;
///
/// [`WINDOW_ALPHA_MIN`] 还要给 `unlucky-app-iced` 的 win32 α 层用(它只依赖本 crate,不直接依赖
/// core):窗口 α 的硬底必须与设置侧同源,两边各写一个 `0.08` 迟早会漂。
pub use unlucky_core::setting::appearance::{PaletteSource, TitlebarMode, WINDOW_ALPHA_MIN};
///
/// 同理 [`PANEL_ALPHA_MIN`](0.4)与 [`BG_DIM_MAX`](0.95)也在这里重导出:M93 的 UI 侧要按它们
/// 钳位滑块与暗化,而 `app-iced` 不直接依赖 core——不重导出就只能各抄一份字面量。
pub use unlucky_core::setting::appearance::{
PaletteSource, TitlebarMode, BG_DIM_MAX, PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN,
};
use crate::dto::VersionCategory;
@@ -307,93 +312,4 @@ pub mod keys {
}
#[cfg(test)]
mod tests {
use super::{
keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode,
UiPrefs, WINDOW_ALPHA_MIN,
};
#[test]
fn defaults_match_d16() {
let prefs = UiPrefs::default();
assert_eq!(prefs.tile_size, TileSize::Medium);
assert_eq!(prefs.tile_size.columns(), 4);
assert_eq!(prefs.instance_view, InstanceView::Tiles);
assert_eq!(prefs.theme, ThemeMode::Auto);
assert_eq!(prefs.theme.as_str(), "auto");
assert!(!prefs.nav_collapsed);
assert!(!prefs.ai_collapsed);
}
#[test]
fn tile_size_columns_follow_q24() {
assert_eq!(TileSize::Small.columns(), 6);
assert_eq!(TileSize::Medium.columns(), 4);
assert_eq!(TileSize::Large.columns(), 3);
}
#[test]
fn theme_mode_round_trips_through_key_values() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode);
}
// 坏值兜底
assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto);
}
#[test]
fn pref_keys_are_unique() {
let mut keys = keys::ALL.to_vec();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total);
}
fn base_of(raw: serde_json::Value) -> AppearanceBase {
let schema = unlucky_core::setting::keys::launcher_schema();
let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw);
AppearanceBase::from_document(&doc)
}
#[test]
fn appearance_base_missing_keys_use_factory() {
let base = base_of(serde_json::json!({}));
assert_eq!(
base,
AppearanceBase::factory(),
"缺键 → 出厂态(不透明 / auto / preset)"
);
assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α");
}
#[test]
fn appearance_base_clamps_out_of_range_opacity() {
for raw in [0.0, 0.02, -1.0] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(
base.window_opacity, WINDOW_ALPHA_MIN as f32,
"raw={raw} 应夹到下限"
);
}
for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(base.window_opacity, want, "raw={raw}");
}
}
#[test]
fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() {
let bad = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "allways", "palette_source": "desktop" }
}));
assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto");
assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset");
// 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。
let good = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" }
}));
assert_eq!(good.titlebar_mode, TitlebarMode::Never);
assert_eq!(good.palette_source, PaletteSource::Wallpaper);
}
}
mod tests;
+90
View File
@@ -0,0 +1,90 @@
//! `prefs` 的单测(拆文件守 400 行上限;结构体与 re-export 一律留在 `prefs.rs`)。
use super::{
keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode, UiPrefs,
WINDOW_ALPHA_MIN,
};
#[test]
fn defaults_match_d16() {
let prefs = UiPrefs::default();
assert_eq!(prefs.tile_size, TileSize::Medium);
assert_eq!(prefs.tile_size.columns(), 4);
assert_eq!(prefs.instance_view, InstanceView::Tiles);
assert_eq!(prefs.theme, ThemeMode::Auto);
assert_eq!(prefs.theme.as_str(), "auto");
assert!(!prefs.nav_collapsed);
assert!(!prefs.ai_collapsed);
}
#[test]
fn tile_size_columns_follow_q24() {
assert_eq!(TileSize::Small.columns(), 6);
assert_eq!(TileSize::Medium.columns(), 4);
assert_eq!(TileSize::Large.columns(), 3);
}
#[test]
fn theme_mode_round_trips_through_key_values() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode);
}
// 坏值兜底
assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto);
}
#[test]
fn pref_keys_are_unique() {
let mut keys = keys::ALL.to_vec();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total);
}
fn base_of(raw: serde_json::Value) -> AppearanceBase {
let schema = unlucky_core::setting::keys::launcher_schema();
let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw);
AppearanceBase::from_document(&doc)
}
#[test]
fn appearance_base_missing_keys_use_factory() {
let base = base_of(serde_json::json!({}));
assert_eq!(
base,
AppearanceBase::factory(),
"缺键 → 出厂态(不透明 / auto / preset)"
);
assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α");
}
#[test]
fn appearance_base_clamps_out_of_range_opacity() {
for raw in [0.0, 0.02, -1.0] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(
base.window_opacity, WINDOW_ALPHA_MIN as f32,
"raw={raw} 应夹到下限"
);
}
for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] {
let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } }));
assert_eq!(base.window_opacity, want, "raw={raw}");
}
}
#[test]
fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() {
let bad = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "allways", "palette_source": "desktop" }
}));
assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto");
assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset");
// 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。
let good = base_of(serde_json::json!({
"appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" }
}));
assert_eq!(good.titlebar_mode, TitlebarMode::Never);
assert_eq!(good.palette_source, PaletteSource::Wallpaper);
}
+57 -1
View File
@@ -19,7 +19,7 @@ use unlucky_core::setting::appearance::Appearance;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::prefs::{AppearancePrefs, ThemeMode};
use crate::prefs::{AppearanceBase, AppearancePrefs, ThemeMode};
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
const DATA_ROOT_KEYS: [&str; 4] = [
@@ -100,6 +100,23 @@ impl SettingsBridge {
)
}
/// 外观**下层**的只读快照(D36·D37·D38):整窗 α / 标题栏三态 / 配色来源档。
///
/// 为什么不并进 [`SettingsBridge::load`] 的返回值:那个结构体(`AppearancePrefs`)是
/// **UI 写入用**的(每个字段都会被 `persist` 回写),而这三个键**只读不写**——
/// 落盘走 core 的 `Appearance::write`。并进去会让每个 `AppearancePrefs` 构造点都要补字段,
/// 还会让「这三项只读」这件事在类型上消失。
///
/// UI 侧(`crates/app-iced`)拿它读启动值:`app-iced` 不直接依赖 `unlucky-core`,
/// 所以「把 core 的 `SettingDocument` 翻译成只读快照」这一步必须留在本 crate。
#[must_use]
pub fn load_base(&self) -> AppearanceBase {
match &self.store {
Some(store) => AppearanceBase::from_document(store.doc()),
None => AppearanceBase::factory(),
}
}
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
let Some(store) = self.store.as_mut() else {
@@ -193,6 +210,7 @@ fn mtime(path: &Path) -> Option<SystemTime> {
#[cfg(test)]
mod tests {
use super::*;
use crate::prefs::PaletteSource;
use unlucky_core::setting::appearance::BgFill;
fn temp_config(label: &str) -> PathBuf {
@@ -291,4 +309,42 @@ mod tests {
appearance.bg_image.clear();
assert!(!appearance.bg_active(), "路径空也不算生效");
}
/// `load_base()` 是「重启后读回整窗 α / 配色来源」的唯一通道(M93)。
///
/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的**
/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。
/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。
#[test]
fn load_base_reads_the_three_read_only_keys_and_defaults_when_missing() {
let path = temp_config("base");
// 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。
std::fs::write(
&path,
r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#,
)
.expect("写配置文件");
let bridge = SettingsBridge::with_path(&path);
let base = bridge.load_base();
assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α");
assert_eq!(base.palette_source, PaletteSource::Wallpaper);
assert!(base.is_transparent());
// 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。
let empty = path.with_file_name("launcher-empty.json");
std::fs::write(&empty, "{}").expect("写空配置");
let factory = SettingsBridge::with_path(&empty).load_base();
assert_eq!(factory, AppearanceBase::factory());
assert_eq!(factory.window_opacity, 1.0);
assert_eq!(factory.palette_source, PaletteSource::Preset);
// 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。
assert_eq!(
SettingsBridge::memory_only().load_base(),
AppearanceBase::factory()
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
}