From b8ab46662c3134a0eccc7843f3e12b071219c6aa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 16:41:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(app-iced):=20M93=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=A4=96=E8=A7=82=E6=8E=A5=E7=BA=BF=EF=BC=9A=E6=95=B4?= =?UTF-8?q?=E7=AA=97=20=CE=B1=20/=20=E9=9D=A2=E6=9D=BF=20=CE=B1=20/=20?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E5=9B=BE=20/=20=E9=85=8D=E8=89=B2=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。 整窗 α(D34/D36) - `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs` (`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等); `app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch` 先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。 - 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。 - `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。 面板 α 与 CPU 档(D36) - 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片; CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。 - 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。 背景图(D38) - 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克; iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。 - 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。 设置页「外观」分组 - 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/ 背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回: bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source` 走 `AppCommand::SetSetting`(每个键只有一个写者)。 - `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回 `window_opacity` / `palette_source`。 取证 - `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE), 截图通道不读用户配置(D30)。 --- crates/app-iced/src/app/appearance.rs | 118 ++++++ crates/app-iced/src/app/boot.rs | 9 + crates/app-iced/src/app/message.rs | 2 + crates/app-iced/src/app/mod.rs | 2 + crates/app-iced/src/app/state.rs | 74 +++- crates/app-iced/src/app/tests.rs | 2 + crates/app-iced/src/app/tests/appearance.rs | 290 +++++++++++++ crates/app-iced/src/app/update.rs | 57 ++- crates/app-iced/src/app/view.rs | 26 +- crates/app-iced/src/app/window_alpha.rs | 134 ++++++ crates/app-iced/src/pages/appearance/alpha.rs | 239 +++++++++++ .../src/pages/appearance/background.rs | 357 ++++++++++++++++ .../src/pages/appearance/background/tests.rs | 200 +++++++++ crates/app-iced/src/pages/appearance/mod.rs | 165 ++++++++ .../app-iced/src/pages/appearance/palette.rs | 279 ++++++++++++ .../app-iced/src/pages/appearance/section.rs | 400 ++++++++++++++++++ crates/app-iced/src/pages/appearance/state.rs | 386 +++++++++++++++++ crates/app-iced/src/pages/mod.rs | 35 +- crates/app-iced/src/pages/settings.rs | 82 +++- .../src/platform/win32/transparency.rs | 132 +++++- crates/app-iced/src/shell/mod.rs | 55 ++- crates/app-iced/src/shell/nav.rs | 6 +- crates/app-iced/src/theme.rs | 59 +++ crates/presenter/src/settings_bridge.rs | 58 ++- 24 files changed, 3119 insertions(+), 48 deletions(-) create mode 100644 crates/app-iced/src/app/appearance.rs create mode 100644 crates/app-iced/src/app/tests/appearance.rs create mode 100644 crates/app-iced/src/app/window_alpha.rs create mode 100644 crates/app-iced/src/pages/appearance/alpha.rs create mode 100644 crates/app-iced/src/pages/appearance/background.rs create mode 100644 crates/app-iced/src/pages/appearance/background/tests.rs create mode 100644 crates/app-iced/src/pages/appearance/mod.rs create mode 100644 crates/app-iced/src/pages/appearance/palette.rs create mode 100644 crates/app-iced/src/pages/appearance/section.rs create mode 100644 crates/app-iced/src/pages/appearance/state.rs diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs new file mode 100644 index 0000000..e3e59e8 --- /dev/null +++ b/crates/app-iced/src/app/appearance.rs @@ -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] 外观未落盘(数据根解析不出来,内存态运行)"), + } + } +} diff --git a/crates/app-iced/src/app/boot.rs b/crates/app-iced/src/app/boot.rs index 164a4d6..ae5b7db 100644 --- a/crates/app-iced/src/app/boot.rs +++ b/crates/app-iced/src/app/boot.rs @@ -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() }); diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index 9ac2a06..4975ec4 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -38,6 +38,8 @@ pub enum Message { Heartbeat, /// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。 Window(window::Event), + /// 窗口句柄(M93:`window::run` 探测出的 raw handle;`None` = 拿不到,整窗 α 无法下发)。 + WindowHandle(Option), /// 页面消息(导航 / 启动 / 停止 / 占位动作)。 Page(pages::Message), /// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。 diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index 9a2a80f..f0201e8 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -27,12 +27,14 @@ //! `src/components/`(组件目录自己的归属规则不变),逻辑路径仍是 `crate::app::components` //! (组件源码里的文档与测试都按这个路径写)。 +mod appearance; mod boot; mod message; mod state; mod subscription; mod update; mod view; +mod window_alpha; #[cfg(test)] mod tests; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index e7c491f..d660719 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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, } impl App { @@ -140,14 +149,34 @@ 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()) + }; + let appearance = pages::appearance::State::new( + &appearance_prefs, + &appearance_base, + options.backend, + 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 +195,14 @@ impl App { frames: 0, refreshes: 0, auto_selected: false, - } + height, + appearance, + appearance_prefs, + hwnd: None, + }; + // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 + app.rebuild_ui(); + app } /// 本次进程是否走截图通道。 @@ -174,19 +210,41 @@ 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) { + 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), + }; } /// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。 diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index 37823ed..7630022 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -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; diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs new file mode 100644 index 0000000..8ff9ee8 --- /dev/null +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -0,0 +1,290 @@ +//! 外观接线的单测:**配置 → 状态 → 下发字节 / 像素 / 落盘**。 +//! +//! 每条都按本 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 → 下发字节 204(钳位链的入口那一段)。 +/// 抓不住: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.mosaic((1180.0, 760.0), [0, 0, 0]).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 → 背景真的生效(有马赛克、有格数、配色走「背景图取色」这一级)。 +/// 抓不住:马赛克的观感(36dp 网格是已登记的降级),以及填充三种档的视觉差异(几何由 +/// `background` 的纯函数测试守)。 +#[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()); + let mosaic = app + .appearance + .mosaic((1180.0, 760.0), [0, 0, 0]) + .expect("有背景层"); + assert!(mosaic.cols > 1 && mosaic.rows > 1); + 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); +} + +/// 抓得住:**把滑块从头拖到尾**(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 = 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.mosaic((1180.0, 760.0), [0, 0, 0]).is_none()); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 8ee14fc..dcfec9b 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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::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 { + 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, + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 85fff4c..b94b47b 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -42,14 +42,26 @@ 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, + self.appearance.effective_panel_alpha(), + 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, diff --git a/crates/app-iced/src/app/window_alpha.rs b/crates/app-iced/src/app/window_alpha.rs new file mode 100644 index 0000000..e3366d9 --- /dev/null +++ b/crates/app-iced/src/app/window_alpha.rs @@ -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 { + 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) -> 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")); + } +} diff --git a/crates/app-iced/src/pages/appearance/alpha.rs b/crates/app-iced/src/pages/appearance/alpha.rs new file mode 100644 index 0000000..ebfd50a --- /dev/null +++ b/crates/app-iced/src/pages/appearance/alpha.rs @@ -0,0 +1,239 @@ +//! 两层透明度的**判据层**:整窗 α / 面板 α 的钳位链、CPU 档降级、滑块映射。纯函数、零 iced。 +//! +//! ## 为什么要分成「两层」 +//! +//! 用户定案(D34 / D36): +//! +//! - **整窗 α**:整扇窗口(含自绘标题栏、导航轨、所有卡片)作为一个整体乘上 α。Windows 上唯一 +//! 实现是 `SetLayeredWindowAttributes(LWA_ALPHA)`,DWM 常数乘法、成本≈0,所以**CPU 档也允许**; +//! - **面板 α**:只有面板/卡片这一层半透明,让背景图透出来。CPU 档**强制 1.0**(D36 对 D27 的 +//! 修订:CPU 档被限制的是「多层半透明的合成成本」,不是窗口级常数乘法)。 +//! +//! ## 真值表(三层 × 三个后端;`x` = 用户设置值) +//! +//! | 项 | GPU | CPU | auto | 下限 | 上限 | 钳位还是拒绝 | +//! |---|---|---|---|---|---|---| +//! | 整窗 α | `clamp(x, 0.08, 1.0)` | **同左**(D36 修订:CPU 也允许) | 同左 | 0.08 | 1.0 | 钳位;`x ≤ 0` 或非有限值 → **拒绝**(整窗穿透) | +//! | 面板 α | `clamp(x, 0.4, 1.0)` | **恒 1.0** | `clamp(x, 0.4, 1.0)`(auto 是探测后的择优档) | 0.4 | 1.0 | 钳位 | +//! | 玻璃 | `x` | **恒 false** | `x` | — | — | 无(布尔) | +//! +//! 「auto」按**请求值**判(`Backend::Auto` 是用户没指定、让探测决定):它不走 CPU 的强制分支, +//! 因为「强制 1.0」是 CPU 渲染档的降级规则,而探测结果可能是 GPU。真落到 tiny-skia 时 +//! `probe` 会写 `ICED_BACKEND=tiny-skia`,但那时 `Options::backend` 仍是 `Auto`——这条已知 +//! 张力登记在 [`crate::pages::appearance`] 的偏离表里(要收口得让探测结果回灌 `Backend`)。 +//! +//! ## 两条链为什么都在本文件 +//! +//! 它们共用一个**滑块映射**与一套钳位纪律,分开写迟早会出现「滑块给 0.0、钳位给 0.08、 +//! 日志又打 0.0」这种三处各说一套的事故。整窗 α 的**执行**(FFI)在 +//! `crate::platform::win32::transparency`,本文件只给「该下发什么」与「下发成什么字节」。 + +use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; + +use crate::platform::effective_window_alpha; +use crate::platform::win32::transparency::alpha_byte; +use crate::Backend; + +/// 整窗 α 下限的 `f32` 形态(唯一真源仍是 `unlucky_presenter::prefs::WINDOW_ALPHA_MIN`)。 +pub const WINDOW_ALPHA_FLOOR: f32 = WINDOW_ALPHA_MIN as f32; + +/// 面板 α 下限(0.4)。 +/// +/// 与 `unlucky_core::setting::appearance::PANEL_ALPHA_MIN` **同值**,但只能在这里再写一遍: +/// `unlucky-presenter` 没有重导出它(只重导出了 `WINDOW_ALPHA_MIN`),而 `app-iced` +/// 不直接依赖 core。已报 finding,收口方式是让 presenter 重导出。 +pub const PANEL_ALPHA_FLOOR: f32 = 0.4; + +/// 整窗 α 滑块位置(0..=1)→ 请求值(0.08..=1.0)。 +#[must_use] +pub fn window_alpha_from_fraction(fraction: f32) -> f32 { + let fraction = sanitize_fraction(fraction); + WINDOW_ALPHA_FLOOR + fraction * (1.0 - WINDOW_ALPHA_FLOOR) +} + +/// 请求值 → 滑块位置([`window_alpha_from_fraction`] 的逆;钳位后落在 0..=1)。 +#[must_use] +pub fn window_fraction(alpha: f32) -> f32 { + let alpha = clamp(alpha, WINDOW_ALPHA_FLOOR, 1.0); + (alpha - WINDOW_ALPHA_FLOOR) / (1.0 - WINDOW_ALPHA_FLOOR) +} + +/// 面板 α 滑块位置(0..=1)→ 请求值(0.4..=1.0)。 +#[must_use] +pub fn panel_alpha_from_fraction(fraction: f32) -> f32 { + let fraction = sanitize_fraction(fraction); + PANEL_ALPHA_FLOOR + fraction * (1.0 - PANEL_ALPHA_FLOOR) +} + +/// 请求值 → 滑块位置。 +#[must_use] +pub fn panel_fraction(alpha: f32) -> f32 { + let alpha = clamp(alpha, PANEL_ALPHA_FLOOR, 1.0); + (alpha - PANEL_ALPHA_FLOOR) / (1.0 - PANEL_ALPHA_FLOOR) +} + +/// 该后端是否**强制**面板不透明(D36:CPU 档面板 α / 玻璃 / 模糊一律 1.0)。 +#[must_use] +pub fn panel_is_forced_opaque(backend: Backend) -> bool { + matches!(backend, Backend::Cpu) +} + +/// 面板 α 的生效值:CPU 档恒 1.0,其余钳到 `0.4..=1.0`。 +#[must_use] +pub fn effective_panel_alpha(backend: Backend, requested: f32) -> f32 { + if panel_is_forced_opaque(backend) { + return 1.0; + } + clamp(requested, PANEL_ALPHA_FLOOR, 1.0) +} + +/// 玻璃拟态卡片的生效值:CPU 档恒关(它要的是多层半透明 + 模糊底,那是 D27 的动机)。 +#[must_use] +pub fn effective_glass(backend: Backend, requested: bool) -> bool { + requested && !panel_is_forced_opaque(backend) +} + +/// 整窗 α 最终**下发到 Win32 的字节**(`None` = 不该下发:`α ≥ 1` 或整窗穿透被拒绝)。 +/// +/// 这条链是「配置值 → 判据 → 量化字节」的唯一定义点: +/// `effective_window_alpha`(钳位/拒绝)→ [`alpha_byte`](**向上取整**,永不低于下限)。 +#[must_use] +pub fn window_alpha_byte(backend: Backend, requested: f32) -> Option { + effective_window_alpha(backend, f64::from(requested)) + .filter(|alpha| *alpha < 1.0) + .map(alpha_byte) +} + +fn sanitize_fraction(fraction: f32) -> f32 { + clamp(fraction, 0.0, 1.0) +} + +fn clamp(value: f32, min: f32, max: f32) -> f32 { + if value.is_nan() { + min + } else { + value.clamp(min, max) + } +} + +#[cfg(test)] +mod tests { + use super::{ + effective_glass, effective_panel_alpha, panel_alpha_from_fraction, panel_fraction, + panel_is_forced_opaque, window_alpha_byte, window_alpha_from_fraction, window_fraction, + PANEL_ALPHA_FLOOR, WINDOW_ALPHA_FLOOR, + }; + use crate::Backend; + + const BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto]; + + #[test] + fn floor_values_are_the_ones_the_user_decided() { + // 抓得住:有人把下限改回 0(= 整窗穿透)或把面板下限降到「前景压不住背景图」。 + // 抓不住:数值本身是否来自令牌(`WINDOW_ALPHA_FLOOR` 由 presenter 重导出,这条能对; + // 面板下限是本地字面量,只能钉 0.4 这个事实)。 + assert_eq!(WINDOW_ALPHA_FLOOR, 0.08); + assert_eq!(PANEL_ALPHA_FLOOR, 0.4); + } + + /// 任务要求的**钳位链**:配置写 0.8 → 判据放行 0.8 → 下发字节 205。 + /// + /// 抓得住:①`effective_window_alpha` 把 0.8 原样放行(CPU 档也放行,D36 修订); + /// ②**向上取整**这条纪律(`f32` 的 0.8 扩到 `f64` 是 0.8000000119…,×255 = 204.000003, + /// ceil = **205**;若谁改成 `round` 就会变 204 并在这里红——那正是「实际下发可能低于下限」 + /// 的同一个坑);③`α ≥ 1` 时**不下发**(`None`,而不是 255)。 + /// 抓不住:这 205 个字节真的被 Win32 收下(FFI 成败由 `transparency` 的单测与本机日志举证)。 + #[test] + fn the_clamp_chain_reports_the_byte_that_would_be_sent() { + for backend in BACKENDS { + assert_eq!( + window_alpha_byte(backend, 0.8), + Some(205), + "{backend:?}:0.8 → 205(f32→f64 略大于 0.8,ceil 上去)" + ); + assert_eq!( + window_alpha_byte(backend, 0.5), + Some(128), + "{backend:?}:0.5×255=127.5 → 128" + ); + assert_eq!( + window_alpha_byte(backend, 1.0), + None, + "{backend:?}:不透明不下发" + ); + assert_eq!( + window_alpha_byte(backend, 0.001), + Some(21), + "{backend:?}:低于下限先夹到 0.08,再向上取整 21(0.0824 ≥ 0.08)" + ); + assert_eq!( + window_alpha_byte(backend, -1.0), + None, + "{backend:?}:整窗穿透被拒绝,不给字节" + ); + assert_eq!(window_alpha_byte(backend, f32::NAN), None); + } + } + + /// D36 的核心分界:**CPU 档允许整窗 α,强制面板 1.0**。 + /// + /// 抓得住:谁把这条写反(CPU 档整窗 α 也强制 1.0,或 CPU 档面板 α 跟着用户值走)。 + /// 抓不住:真实 CPU 档下窗口是否真的半透明(观感未验,见报告「没验的部分」)。 + #[test] + fn cpu_tier_allows_the_window_alpha_but_forces_the_panel_opaque() { + assert!( + window_alpha_byte(Backend::Cpu, 0.5).is_some(), + "CPU 档允许整窗 α" + ); + assert!(panel_is_forced_opaque(Backend::Cpu)); + assert_eq!( + effective_panel_alpha(Backend::Cpu, 0.5), + 1.0, + "CPU 档面板强制 1.0" + ); + assert!(!effective_glass(Backend::Cpu, true), "CPU 档玻璃强制关"); + // 另外两档不做这个降级(否则「强制」就失去了意义)。 + for backend in [Backend::Gpu, Backend::Auto] { + assert!(!panel_is_forced_opaque(backend), "{backend:?} 不强制"); + assert_eq!(effective_panel_alpha(backend, 0.5), 0.5); + assert!(effective_glass(backend, true)); + } + } + + #[test] + fn panel_alpha_is_clamped_into_range() { + for backend in [Backend::Gpu, Backend::Auto] { + assert_eq!(effective_panel_alpha(backend, 0.0), PANEL_ALPHA_FLOOR); + assert_eq!(effective_panel_alpha(backend, 0.2), PANEL_ALPHA_FLOOR); + assert_eq!(effective_panel_alpha(backend, 3.0), 1.0); + assert_eq!(effective_panel_alpha(backend, f32::NAN), PANEL_ALPHA_FLOOR); + } + } + + #[test] + fn slider_fractions_round_trip_without_leaving_the_range() { + for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] { + let alpha = window_alpha_from_fraction(fraction); + assert!( + (WINDOW_ALPHA_FLOOR..=1.0).contains(&alpha), + "{fraction} → {alpha}" + ); + assert!((window_fraction(alpha) - fraction).abs() < 1e-6); + } + // 越界输入(滑块自己不会给,但别让下游吃 NaN) + assert_eq!(window_alpha_from_fraction(-1.0), WINDOW_ALPHA_FLOOR); + assert_eq!(window_alpha_from_fraction(9.0), 1.0); + assert_eq!(window_alpha_from_fraction(f32::NAN), WINDOW_ALPHA_FLOOR); + + for fraction in [0.0, 0.5, 1.0] { + let alpha = panel_alpha_from_fraction(fraction); + assert!((PANEL_ALPHA_FLOOR..=1.0).contains(&alpha)); + assert!((panel_fraction(alpha) - fraction).abs() < 1e-6); + } + // 0.4 与 1.0 是滑块两端:端点必须精确(否则「拖到头还不是 1.0」) + assert_eq!(panel_alpha_from_fraction(1.0), 1.0); + assert_eq!(panel_fraction(1.0), 1.0); + assert_eq!(window_alpha_from_fraction(1.0), 1.0); + assert_eq!(window_fraction(WINDOW_ALPHA_FLOOR), 0.0); + } +} diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs new file mode 100644 index 0000000..08cb321 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -0,0 +1,357 @@ +//! 背景图:**读盘解码 → 填充几何 → 暗化 → 马赛克取样**。纯函数(除 [`load`] 一次 IO)。 +//! +//! ## 三种填充(`appearance.bg_fill`,core 的 [`BgFill`]) +//! +//! | 档 | 缩放 | 位置 | 露底色 | +//! |---|---|---|---| +//! | `cover` | `max(vw/iw, vh/ih)`(铺满、裁掉溢出) | 居中 | 否 | +//! | `contain` | `min(vw/iw, vh/ih)`(完整显示) | 居中 | **是**(上下或左右留底色) | +//! | `tile` | `1`(原始尺寸平铺) | 从视口左上角起重复 | 否 | +//! +//! 几何是**纯函数**([`placement`] / [`source_point`]),因此三种档的差异可以被数值断言, +//! 不必靠肉眼比对截图。 +//! +//! ## 暗化(`appearance.bg_dim`) +//! +//! 语义是「让背景往面板底色退」:`rgb + (toward − rgb) × dim`(`toward` = 当前主题的 Canvas 色)。 +//! +//! 为什么**不是**「叠一层黑」:叠黑在深色主题里正合适,但在浅色主题下会把亮图压成暗块,而压在 +//! 它上面的深色正文反而更读不清。向底色退在两种主题下都是单调变好(浅色向白退、深色向黑退), +//! 也仍然是「一次乘法式的插值」——结果**不透明**,不依赖渲染器的 alpha 混合精度, +//! CPU 档与 GPU 档出图逐字节一致。 +//! +//! 这条偏离(相对「暗化 = 乘黑」的字面语义)登记在 [`super`] 的偏离表 #7。 +//! +//! ## 为什么是「马赛克」而不是直接画图 +//! +//! iced 0.14 的 `widget::image` 需要 `iced/image` feature,而本 crate 的 iced 只开了 +//! `tiny-skia / crisp / advanced / svg / thread-pool`,`Cargo.toml` 不在本 mission 的 scope +//! (已登记偏离 #1)。所以这里把解码后的像素按 [`CELL_PX`] 的网格做**块平均**,每格画一个实心 +//! 方块:填充几何、暗化、取色全部是真的,只有「每格一个颜色」这一条是降级。 +//! 网格上限 [`MAX_COLS`]×[`MAX_ROWS`],成本有界(1180×760 下约 33×22 个方块)。 + +use unlucky_presenter::prefs::BgFill; + +/// 暗化上限(与 core 的 `BG_DIM_MAX` 同值 0.95):再高背景图基本看不见,等于关掉。 +pub const BG_DIM_MAX: f32 = 0.95; + +/// 解码后保留的最大边长:取样与取色都不需要分辨率,4K 图缩到 256 边后内存从 33MB 掉到 147KB。 +pub const MAX_EDGE: u32 = 256; + +/// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。 +pub const MAX_SOURCE_EDGE: u32 = 16_384; + +/// 马赛克网格的目标格边长(dp)。 +pub const CELL_PX: f32 = 36.0; + +/// 马赛克网格上限(列 / 行)——渲染成本的天花板。 +pub const MAX_COLS: u32 = 48; +/// 马赛克网格上限(行)。 +pub const MAX_ROWS: u32 = 32; + +/// 解码失败的原因(人话在 [`LoadError::describe`])。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadError { + /// 读文件失败:不存在 / 没权限 / 被占用。 + Io(String), + /// 解码失败:格式不认识(本 build 只带 png 编解码器)/ 文件截断 / 不是图片。 + Undecodable(String), + /// 图片头里的尺寸超限。 + TooLarge { + /// 宽。 + width: u32, + /// 高。 + height: u32, + }, +} + +impl LoadError { + /// 可读诊断(设置页提示行与日志共用同一句)。 + #[must_use] + pub fn describe(&self) -> String { + match self { + Self::Io(reason) => format!("Background image could not be read: {reason}"), + Self::Undecodable(reason) => format!( + "Background image could not be decoded ({reason}; this build decodes PNG only)" + ), + Self::TooLarge { width, height } => format!( + "Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)" + ), + } + } +} + +/// 一张解码好的背景图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Image { + /// **原图**尺寸(像素):填充几何按它算(`tile` = 原始尺寸平铺)。 + pub source: (u32, u32), + /// 缩略图宽(≤ [`MAX_EDGE`])。 + pub width: u32, + /// 缩略图高(≤ [`MAX_EDGE`])。 + pub height: u32, + /// 缩略图 RGBA8(`width × height × 4`):取样与取色都用它。 + pub rgba: Vec, +} + +impl Image { + /// 从任意解码结果构造(测试也走这里,避免测试依赖磁盘)。 + #[must_use] + pub fn from_rgba(source: (u32, u32), width: u32, height: u32, rgba: Vec) -> Self { + Self { + source, + width, + height, + rgba, + } + } +} + +/// 读盘 + 解码 + 缩到 [`MAX_EDGE`] 内。 +/// +/// # Errors +/// 见 [`LoadError`]:读不到 / 解不开 / 头部尺寸超限。**不 return 默认色**——回落的决定在 +/// [`super::palette`](它会往 `Provenance` 里留原因),这一层只管如实报告失败。 +pub fn load(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(LoadError::Io("no path configured".to_owned())); + } + // 第一趟只读图头:先按尺寸拒掉压缩炸弹,再真解码。 + let (source_w, source_h) = header_size(trimmed)?; + if source_w > MAX_SOURCE_EDGE || source_h > MAX_SOURCE_EDGE { + return Err(LoadError::TooLarge { + width: source_w, + height: source_h, + }); + } + + let decoded = open(trimmed)? + .decode() + .map_err(|error| LoadError::Undecodable(error.to_string()))?; + let (width, height) = (decoded.width().max(1), decoded.height().max(1)); + let scale = (f64::from(MAX_EDGE) / f64::from(width.max(height))).min(1.0); + let target_w = ((f64::from(width) * scale).round() as u32).max(1); + let target_h = ((f64::from(height) * scale).round() as u32).max(1); + let thumb = image::imageops::resize( + &decoded, + target_w, + target_h, + image::imageops::FilterType::Triangle, + ); + + Ok(Image::from_rgba( + (source_w, source_h), + target_w, + target_h, + thumb.into_raw(), + )) +} + +fn open(path: &str) -> Result>, LoadError> { + let reader = + image::ImageReader::open(path).map_err(|error| LoadError::Io(error.to_string()))?; + reader + .with_guessed_format() + .map_err(|error| LoadError::Undecodable(error.to_string())) +} + +fn header_size(path: &str) -> Result<(u32, u32), LoadError> { + let reader = open(path)?; + if reader.format().is_none() { + return Err(LoadError::Undecodable( + "unknown image format (magic bytes not recognised)".to_owned(), + )); + } + reader + .into_dimensions() + .map_err(|error| LoadError::Undecodable(error.to_string())) +} + +/// 填充几何:源图 → 视口 的映射。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Placement { + /// 缩放比:源图 1 像素 = `scale` dp。 + pub scale: f32, + /// 源图左上角落在视口里的位置(dp;`cover` 时是负值 = 已经裁掉溢出)。 + pub origin: (f32, f32), + /// 是否平铺(只有 `tile`)。 + pub repeat: bool, +} + +impl Placement { + /// 源图缩放后的尺寸(dp)。 + #[must_use] + pub fn scaled(&self, source: (u32, u32)) -> (f32, f32) { + (source.0 as f32 * self.scale, source.1 as f32 * self.scale) + } +} + +/// 按填充档算一次几何(纯函数)。 +#[must_use] +pub fn placement(fill: BgFill, source: (u32, u32), viewport: (f32, f32)) -> Placement { + let source_f = (source.0.max(1) as f32, source.1.max(1) as f32); + let viewport = (viewport.0.max(1.0), viewport.1.max(1.0)); + + let scale = match fill { + BgFill::Cover => (viewport.0 / source_f.0).max(viewport.1 / source_f.1), + BgFill::Contain => (viewport.0 / source_f.0).min(viewport.1 / source_f.1), + BgFill::Tile => 1.0, + }; + let scaled = (source_f.0 * scale, source_f.1 * scale); + let origin = match fill { + // 平铺从视口左上角起(官方 Contain/Tile 的语义:不居中,直接重复) + BgFill::Tile => (0.0, 0.0), + _ => ((viewport.0 - scaled.0) / 2.0, (viewport.1 - scaled.1) / 2.0), + }; + + Placement { + scale, + origin, + repeat: matches!(fill, BgFill::Tile), + } +} + +/// 视口点 → 源图像素坐标(未取整)。`None` = 该点落在图像之外(`contain` 的留底色区)。 +#[must_use] +pub fn source_point( + placement: &Placement, + source: (u32, u32), + point: (f32, f32), +) -> Option<(f32, f32)> { + let source_f = (source.0.max(1) as f32, source.1.max(1) as f32); + let mut x = (point.0 - placement.origin.0) / placement.scale; + let mut y = (point.1 - placement.origin.1) / placement.scale; + if placement.repeat { + x = x.rem_euclid(source_f.0); + y = y.rem_euclid(source_f.1); + } else if !(0.0..source_f.0).contains(&x) || !(0.0..source_f.1).contains(&y) { + return None; + } + Some((x, y)) +} + +/// 取缩略图上一个像素(最近邻;缩略图已经 ≤256 边,够用)。 +#[must_use] +pub fn sample(image: &Image, point: (f32, f32)) -> [u8; 3] { + let x = (point.0 as u32).min(image.width.saturating_sub(1)); + let y = (point.1 as u32).min(image.height.saturating_sub(1)); + let index = ((y * image.width + x) * 4) as usize; + let rgba = &image.rgba; + [ + rgba.get(index).copied().unwrap_or(0), + rgba.get(index + 1).copied().unwrap_or(0), + rgba.get(index + 2).copied().unwrap_or(0), + ] +} + +/// 暗化:把取样色按 `dim` **混向面板底色**(`rgb + (toward − rgb) × dim`)。 +/// +/// 为什么混向底色而不是乘一个「黑」:乘黑在深色主题里正合适,但浅色主题下会让一张亮图变成 +/// 深色块、而压在其上的深色正文就更难读。混向 `toward`(= 当前主题的 Canvas 色)在两种主题下 +/// 都是「往底色的方向退」,正文对比度因此单调变好——这与 `appearance.bg_dim` 的用途 +/// (「让前景压得住背景」)一致。`dim = 0` 时逐字节不变。 +#[must_use] +pub fn dimmed(rgb: [u8; 3], dim: f32, toward: [u8; 3]) -> [u8; 3] { + let keep = clamp_dim(dim); + let channel = |index: usize| { + let from = f32::from(rgb[index]); + let to = f32::from(toward[index]); + (from + (to - from) * keep).round().clamp(0.0, 255.0) as u8 + }; + [channel(0), channel(1), channel(2)] +} + +/// 暗化系数的唯一钳位点(0 = 不暗化,0.95 = 上限)。 +#[must_use] +pub fn clamp_dim(dim: f32) -> f32 { + if dim.is_nan() { + 0.0 + } else { + dim.clamp(0.0, BG_DIM_MAX) + } +} + +/// 一次渲染要画的马赛克:按行主序铺的格子色(`None` = 露底色)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mosaic { + /// 列数。 + pub cols: u32, + /// 行数。 + pub rows: u32, + /// 每格颜色(`rows × cols`,行主序)。 + pub cells: Vec>, +} + +impl Mosaic { + /// 第 `row` 行第 `col` 列的颜色(越界给 `None`)。 + #[must_use] + pub fn at(&self, row: u32, col: u32) -> Option<[u8; 3]> { + if row >= self.rows || col >= self.cols { + return None; + } + self.cells + .get((row * self.cols + col) as usize) + .copied() + .flatten() + } + + /// 网格尺寸(格边长 dp = `视口 / 格数`,所以调用方可以只用 `Length::Fill` 拼行)。 + #[must_use] + pub fn cell_size(&self, viewport: (f32, f32)) -> (f32, f32) { + ( + viewport.0 / self.cols.max(1) as f32, + viewport.1 / self.rows.max(1) as f32, + ) + } +} + +/// 视口 → 网格尺寸(格边长 ≈ [`CELL_PX`],列/行各有上限)。 +#[must_use] +pub fn grid(viewport: (f32, f32)) -> (u32, u32) { + let cols = div_ceil(viewport.0.max(1.0), CELL_PX).clamp(1, MAX_COLS); + let rows = div_ceil(viewport.1.max(1.0), CELL_PX).clamp(1, MAX_ROWS); + (cols, rows) +} + +/// 算一整张马赛克(纯函数:同一图 + 同一视口 + 同一底色 → 同一结果)。 +#[must_use] +pub fn mosaic( + image: &Image, + fill: BgFill, + viewport: (f32, f32), + dim: f32, + toward: [u8; 3], +) -> Mosaic { + let placement = placement(fill, image.source, viewport); + let (cols, rows) = grid(viewport); + let (cell_w, cell_h) = ( + viewport.0.max(1.0) / cols as f32, + viewport.1.max(1.0) / rows as f32, + ); + + let mut cells = Vec::with_capacity((cols * rows) as usize); + for row in 0..rows { + for col in 0..cols { + let centre = ((col as f32 + 0.5) * cell_w, (row as f32 + 0.5) * cell_h); + cells.push( + source_point(&placement, image.source, centre) + .map(|point| dimmed(sample(image, point), dim, toward)), + ); + } + } + + Mosaic { cols, rows, cells } +} + +fn div_ceil(value: f32, step: f32) -> u32 { + let count = (value / step).ceil(); + if count.is_finite() && count >= 1.0 { + count as u32 + } else { + 1 + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs new file mode 100644 index 0000000..f020e4b --- /dev/null +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -0,0 +1,200 @@ +//! `background` 的单测:填充几何 / 暗化 / 马赛克 / 解码失败。 +//! +//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律: +//! 能断言的(几何、通道值、错误分类)必须断言;观感(马赛克好不好看、真图直出)不在这里假装。 + +use super::{ + clamp_dim, dimmed, grid, load, mosaic, placement, sample, source_point, BgFill, Image, + LoadError, BG_DIM_MAX, MAX_COLS, MAX_ROWS, +}; +use std::path::{Path, PathBuf}; + +/// 8×4 的合成图:左半红、右半蓝(缩略图与原图同尺寸,取样结果因此可预测)。 +fn halves() -> Image { + let (width, height) = (8_u32, 4_u32); + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + for _row in 0..height { + for col in 0..width { + if col < width / 2 { + rgba.extend_from_slice(&[200, 40, 40, 255]); + } else { + rgba.extend_from_slice(&[40, 60, 200, 255]); + } + } + } + Image::from_rgba((width, height), width, height, rgba) +} + +/// 一整块纯色的合成图(只关心几何时用)。 +fn solid(size: (u32, u32), rgb: [u8; 3]) -> Image { + let rgba = [rgb[0], rgb[1], rgb[2], 255].repeat((size.0 * size.1) as usize); + Image::from_rgba(size, size.0, size.1, rgba) +} + +fn temp_png(label: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("ull-bg-{label}-{}-{}", std::process::id(), line!())); + std::fs::create_dir_all(&dir).expect("临时目录"); + dir.join("bg.png") +} + +fn cleanup(path: &Path) { + if let Some(dir) = path.parent() { + std::fs::remove_dir_all(dir).ok(); + } +} + +#[test] +fn cover_fills_the_viewport_and_contain_keeps_the_aspect_ratio() { + // 抓得住:两种档的 scale 公式(铺满 vs 完整)与居中偏移的正负号。 + // 抓不住:真实的裁切/留白在屏幕上的观感(那要看截图)。 + let source = (8, 4); // 2:1 + let viewport = (400.0, 400.0); + + // cover = max(400/8 = 50, 400/4 = 100) = 100 → 缩放后 800×400,横向溢出 400 被裁。 + let cover = placement(BgFill::Cover, source, viewport); + assert_eq!(cover.scale, 100.0, "cover = max(50, 100)"); + assert_eq!(cover.origin, (-200.0, 0.0), "cover 居中后横向溢出被裁"); + + // contain = min(50, 100) = 50 → 缩放后 400×200,上下各留 100 的底色。 + let contain = placement(BgFill::Contain, source, viewport); + assert_eq!(contain.scale, 50.0); + assert_eq!(contain.origin, (0.0, 100.0), "contain 上下留底色"); + assert!(!contain.repeat && !cover.repeat); +} + +#[test] +fn cover_never_leaves_a_hole_and_contain_does() { + let image = solid((8, 4), [10, 20, 30]); + let viewport = (360.0, 360.0); + + let covered = mosaic(&image, BgFill::Cover, viewport, 0.0, [0, 0, 0]); + assert!( + covered.cells.iter().all(Option::is_some), + "cover 必须铺满(任何一格都不许是 None)" + ); + + let contained = mosaic(&image, BgFill::Contain, viewport, 0.0, [0, 0, 0]); + assert_eq!(contained.at(0, 0), None, "contain 的左上角是留底色"); + assert_eq!( + contained.at(0, 0), + None, + "(同一格重复读:确保 at() 不是偶发给 None)" + ); + let middle = (contained.rows / 2, contained.cols / 2); + assert!(contained.at(middle.0, middle.1).is_some(), "中间必须有图"); +} + +#[test] +fn tile_repeats_the_original_size_art_grid() { + // 抓得住:tile 的 scale 恒 1、origin 在左上角、坐标按图宽/图高取模。 + // 抓不住:平铺后每格之间是否有一像素缝(那是渲染层的事)。 + let image = halves(); + let tile = placement(BgFill::Tile, image.source, (40.0, 40.0)); + assert_eq!(tile, tile); + assert_eq!(tile.scale, 1.0); + assert_eq!(tile.origin, (0.0, 0.0)); + assert!(tile.repeat); + + // 同一源坐标:x 与 x + 图宽 必须取到同一像素。 + let left = source_point(&tile, image.source, (3.5, 1.5)).expect("平铺内部总有图"); + let wrapped = source_point(&tile, image.source, (3.5 + 8.0 * 3.0, 1.5 + 4.0 * 2.0)) + .expect("取模后仍在图内"); + assert!((left.0 - wrapped.0).abs() < 1e-4 && (left.1 - wrapped.1).abs() < 1e-4); + + // 左半红 / 右半蓝:x=1 取红、x=6 取蓝(平铺不改变这一点)。 + assert_eq!( + sample( + &image, + source_point(&tile, image.source, (1.0, 1.0)).unwrap() + ), + [200, 40, 40] + ); + assert_eq!( + sample( + &image, + source_point(&tile, image.source, (6.0, 1.0)).unwrap() + ), + [40, 60, 200] + ); +} + +#[test] +fn dim_mixes_toward_the_panel_colour_and_is_clamped() { + // 抓得住:暗化 = 向底色方向插值(不是简单乘黑);dim 越界被夹到 0..=0.95。 + // 语义的关键在浅色主题:向白退(而不是乘黑)才能让深色正文继续读得清。 + // 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断,见报告「没验的部分」)。 + assert_eq!(dimmed([200, 100, 50], 0.0, [255, 255, 255]), [200, 100, 50]); + assert_eq!( + dimmed([200, 100, 50], 0.35, [0, 0, 0]), + [130, 65, 33], + "深色主题:向黑退" + ); + assert_eq!( + dimmed([200, 100, 50], 0.35, [255, 255, 255]), + [219, 154, 122], + "浅色主题:向白退" + ); + assert_eq!(clamp_dim(-1.0), 0.0); + assert_eq!(clamp_dim(f32::NAN), 0.0); + assert_eq!(clamp_dim(9.0), BG_DIM_MAX); + assert_eq!( + dimmed([255, 255, 255], 9.0, [0, 0, 0]), + [13, 13, 13], + "夹到 0.95 → 只剩 5% 原色" + ); +} + +#[test] +fn grid_is_bounded_so_the_mosaic_cannot_explode() { + // 抓得住:超宽/超高视口下格数被上限截住(渲染成本有界),且至少 1×1。 + // 抓不住:这个上限在真机上到底花多少毫秒(没做基准)。 + assert_eq!(grid((1180.0, 760.0)), (33, 22)); + let (cols, rows) = grid((100_000.0, 100_000.0)); + assert_eq!((cols, rows), (MAX_COLS, MAX_ROWS)); + assert_eq!(grid((0.0, 0.0)), (1, 1)); +} + +#[test] +fn a_missing_or_broken_file_reports_a_readable_reason() { + // 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。 + // 两条都必须是 **Err**(不许静默回一个默认色),且 describe() 里不含 panic 文案。 + // 抓不住:本 build 只带 png 编解码器,jpeg/bmp 会在 Undecodable 里出现 + // (「Unsigned format」/「The image format ... is not supported」)。 + let missing = temp_png("missing"); + let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错"); + assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}"); + assert!(error + .describe() + .starts_with("Background image could not be read")); + + let broken = temp_png("broken"); + std::fs::write(&broken, b"this is not a png").expect("写坏文件"); + let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错"); + assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}"); + cleanup(&broken); + + let empty = load(" ").expect_err("空路径必须报错"); + assert!(matches!(empty, LoadError::Io(_))); +} + +#[test] +fn a_real_png_decodes_and_is_scaled_down_to_the_sample_budget() { + // 抓得住:真磁盘 PNG 能读出来、原图尺寸保留、缩略图边长不超过 MAX_EDGE。 + // 抓不住:解码后的颜色是否与它在屏幕上一致(色彩管理不在本 mission)。 + let path = temp_png("ok"); + let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90])); + source.save(&path).expect("写 png"); + + let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读"); + assert_eq!(image.source, (300, 20), "原图尺寸按图头"); + assert!( + image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE, + "缩略图边 {}×{} 超预算", + image.width, + image.height + ); + assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize); + assert_eq!(sample(&image, (0.0, 0.0)), [12, 200, 90], "纯色图取样不变"); + cleanup(&path); +} diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs new file mode 100644 index 0000000..9f486db --- /dev/null +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -0,0 +1,165 @@ +//! # 外观接线(M93 / APPEARWIRE):**键 → 像素** +//! +//! 本模块把 `appearance.*` 那批键接到真正的窗口与真正的像素上,并把「哪里是判据、哪里是观感」 +//! 分开写清楚。底座(M91)已经给了键、钳位判据与唯一的不安全落点;这里补的是**调用方**。 +//! +//! ## 两层透明度(D34 / D36) +//! +//! | 层 | 作用范围 | 实现 | CPU 档 | 下限 | +//! |---|---|---|---|---| +//! | 整窗 α | 整扇窗口(标题栏 + 导航轨 + 内容 + 卡片) | `SetLayeredWindowAttributes(LWA_ALPHA)`,DWM 常数乘法 | **允许** | 0.08(α=0 = 整窗穿透,拒绝) | +//! | 面板 α | 只有面板/卡片表面这一层(背景图能透出来) | 容器色的 alpha([`section`] / `shell`) | **强制 1.0** | 0.4 | +//! +//! 真值表在 [`alpha`] 的模块文档里(三层 × 三个后端,含「钳位还是拒绝」)。 +//! +//! ## 模块分工 +//! +//! | 文件 | 是什么 | 有没有 IO | +//! |---|---|---| +//! | [`alpha`] | 两层 α 的钳位链、CPU 档降级、滑块映射、下发字节 | 无(纯函数) | +//! | [`background`] | 背景图解码、三种填充几何、暗化、马赛克取样 | 读盘一次(`load`) | +//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | +//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | +//! | `section` | 设置页「外观」分组(真控件)+ 背景层 Element | 无 | +//! +//! ## 偏离登记(D30 要求逐条写清) +//! +//! | # | 偏离 | 为什么 | 什么时候收口 | +//! |---|---|---|---| +//! | 1 | 背景图按 **36dp 网格马赛克**画,不是逐像素直出 | iced 0.14 的 `widget::image` 要 `iced/image` feature,本 crate 的 `Cargo.toml` 不在本 mission 的 scope。填充几何 / 暗化 / 取色全是真值,只有「每格一个颜色」是降级 | 开 `iced/image` feature 的 mission(一行 Cargo.toml) | +//! | 2 | 面板 α 只作用到**外壳层**(根容器 / 标题栏 / 导航轨)与本模块画的卡片 | `pages/frame/pieces.rs` 的 `card()` 是其它三页共用的实色卡片,不在本 mission 的 scope。于是「有背景图 + 面板 α<1」时,实例/下载/日志页的卡片仍是实色块 | 把 `card()` 的底色改成「面板层」(一个函数,改完三页同时生效) | +//! | 3 | 桌面壁纸取色(第二级)永远 `Absent` | 壁纸探测(M73)还没落地;优先序本身仍按 design 的三级走,测试里用合成像素证明第二级会被用到 | M73 | +//! | 4 | 色卡档**不换** `Ui` 的 scheme | `resolve_source` 的色卡档返回 `DEFAULT_CARD`(天蓝卡)的方案,与令牌 baseline 逐色不同;应用它会把全部既有验收图的像素改掉。取色档(①②)才换 | 色卡选择 UI 落地时一并决议 | +//! | 5 | `Backend::Auto` 不触发 CPU 档降级 | 「强制 1.0」是 CPU 渲染档的规则,而 `Auto` 是「探测前的请求值」;探测结果没有回灌 `Backend` | 让 `probe` 把结果回灌 `Options::backend` | +//! | 6 | 背景图解码只带 png 编解码器 | `crates/app-iced` 的 `image` 只开了 `png`(现有截图通道用),`Cargo.toml` 在 scope 外。jpeg/bmp 会走 `LoadError::Undecodable` 并给出可读提示 | 把 `image` 的 `jpeg`/`bmp` feature 打开(或改走 `unlucky-palette-extract`) | +//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dimmed`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) | +//! | 8 | 页头副标题 / 分组标签这类**直接压在画布上**的次要文字,在 `panel_alpha < 1` 时对比度下降 | 它们由 `pages/frame.rs` 的页头画,而那个文件不在本 mission 的 scope;真正该改的是「页头带显示面板底色」 | 把页头带改成面板层(`frame::header_bar` 一处) | +//! +//! ## 取证 +//! +//! 每一处改动都打一行 [`state::State::log_line`]:`[appearance] window_alpha=… panel_alpha=… +//! byte=…`。数值是**下发前**的字节(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明」 +//! 不可信。截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。 + +pub mod alpha; +pub mod background; + +mod palette; +mod section; +mod state; + +pub use section::{layer, panel_color, section}; +pub use state::State; + +/// 设置页「外观」分组在左列里的下标(0 = General,1 = Appearance …)。 +pub const APPEARANCE_GROUP: usize = 1; + +/// 设置页「外观」分组的显示名。 +pub const GROUP_LABEL: &str = "Appearance"; + +/// 一次外观改动(设置页 → `App`)。 +/// +/// 值都是**用户请求值**(未钳位):钳位与降级只发生在 [`alpha`],这样「用户设了 0.5、CPU 档实际 +/// 1.0」这种差别在状态里能看出来,日志也就不会把请求值写成生效值。 +#[derive(Debug, Clone, PartialEq)] +pub enum Change { + /// 整窗透明度(请求值,0.08~1.0)。 + WindowOpacity(f32), + /// 面板透明度(请求值,0.4~1.0)。 + PanelAlpha(f32), + /// 背景图开关。 + BgEnabled(bool), + /// 背景图路径(空串 = 清掉)。 + BgImage(String), + /// 填充档。 + BgFill(unlucky_presenter::prefs::BgFill), + /// 暗化强度(0~0.95)。 + BgDim(f32), + /// 配色来源档。 + PaletteSource(unlucky_presenter::prefs::PaletteSource), + /// 设置页切分组(左列点选)。 + SelectGroup(usize), +} + +impl Change { + /// 这条改动对应哪个 schema 键(写回配置用;`SelectGroup` 不落盘)。 + #[must_use] + pub fn key(&self) -> Option<&'static str> { + Some(match self { + Self::WindowOpacity(_) => unlucky_presenter::prefs::keys::WINDOW_OPACITY, + Self::PanelAlpha(_) => unlucky_presenter::prefs::keys::PANEL_ALPHA, + Self::BgEnabled(_) => unlucky_presenter::prefs::keys::BG_IMAGE_ENABLED, + Self::BgImage(_) => unlucky_presenter::prefs::keys::BG_IMAGE, + Self::BgFill(_) => unlucky_presenter::prefs::keys::BG_FILL, + Self::BgDim(_) => unlucky_presenter::prefs::keys::BG_DIM, + Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE, + Self::SelectGroup(_) => return None, + }) + } + + /// 这条改动的落盘字符串形态(与 `schema` 的取值词表逐字一致)。 + /// + /// `None` = 不落盘(分组选择)。数字一律给 `f64` 的十进制字面量(`store.set` 按 schema 类型转)。 + #[must_use] + pub fn value(&self) -> Option { + 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")); + } +} diff --git a/crates/app-iced/src/pages/appearance/palette.rs b/crates/app-iced/src/pages/appearance/palette.rs new file mode 100644 index 0000000..b611027 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/palette.rs @@ -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> { + 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 + ); + } +} diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs new file mode 100644 index 0000000..57c5621 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -0,0 +1,400 @@ +//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。 +//! +//! ## 控件与键的对应 +//! +//! | 控件 | 键 | 改动消息 | +//! |---|---|---| +//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] | +//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | +//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] | +//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] | +//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] | +//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] | +//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] | +//! +//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。 +//! +//! ## 为什么「值」与「生效值」分开显示 +//! +//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 +//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 + +use iced::widget::{button, column, container, row}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_presenter::prefs::{BgFill, PaletteSource}; + +use crate::app::components::inputs::{slider, text_field, Field, Slider}; +use crate::pages::{frame, Message}; +use crate::theme::Ui; + +use super::alpha::{ + panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction, + WINDOW_ALPHA_FLOOR, +}; +use super::background::BG_DIM_MAX; +use super::state::State; +use super::Change; + +/// 一处面板/卡片表面色:令牌色 × 生效的面板 α。 +/// +/// `alpha == 1.0` 时返回**逐字节相同**的令牌色(`scale_alpha(1.0)` 不引入误差),所以没开面板 +/// 透明度时不会动到任何既有验收图的像素。 +#[must_use] +pub fn panel_color(ui: &Ui, layer: MaterialLayer, alpha: f32) -> Color { + let alpha = if alpha.is_finite() { + alpha.clamp(0.0, 1.0) + } else { + 1.0 + }; + ui.layer(layer).scale_alpha(alpha) +} + +/// 背景层(铺满整窗、垫在最下面的马赛克)。`None` = 没有背景图(调用方就不叠这一层)。 +#[must_use] +pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option> { + let fallback = ui.layer(MaterialLayer::Canvas); + let mosaic = state.mosaic(viewport, rgb8(fallback))?; + + let rows = (0..mosaic.rows) + .map(|row| { + let cells = (0..mosaic.cols) + .map(|col| cell(mosaic.at(row, col), fallback)) + .collect::>(); + iced::widget::row(cells) + .width(Length::Fill) + .height(Length::Fill) + .into() + }) + .collect::>(); + + Some(column(rows).width(Length::Fill).height(Length::Fill).into()) +} + +/// iced 颜色 → 8 bit RGB(暗化的「底色」方向)。 +fn rgb8(color: Color) -> [u8; 3] { + let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; + [channel(color.r), channel(color.g), channel(color.b)] +} + +fn cell<'a>(color: Option<[u8; 3]>, fallback: Color) -> Element<'a, Message> { + let color = color.map_or(fallback, |[r, g, b]| Color::from_rgb8(r, g, b)); + container(iced::widget::space::horizontal()) + .width(Length::Fill) + .height(Length::Fill) + .style(move |_theme| frame::filled(color)) + .into() +} + +/// 「外观」分组整块(一张卡)。 +pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { + let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill); + + // ① 整窗透明度 + let window_percent = format!("{:.0}%", state.window_opacity * 100.0); + rows = rows.push(slider_row( + ui, + "Window transparency", + window_percent, + format!( + "Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.", + WINDOW_ALPHA_FLOOR + ), + state.window_opacity < 1.0, + slider( + Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| { + Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction))) + }), + ), + )); + + // ② 面板透明度(CPU 档强制 1.0) + let forced = state.panel_is_forced(); + let panel_percent = if forced { + format!("100% (forced from {:.2})", state.panel_alpha) + } else { + format!("{:.0}%", state.effective_panel_alpha() * 100.0) + }; + let panel_hint = if forced { + "Software renderer: panels stay fully opaque (window transparency still works)." + } else { + "Only the panel/card layer. Floor 0.40; independent of window transparency." + }; + let panel_control = if forced { + slider(Slider::new(ui, panel_fraction(1.0))) + } else { + slider( + Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| { + Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction))) + }), + ) + }; + rows = rows.push(slider_row( + ui, + "Panel transparency", + panel_percent, + panel_hint.to_owned(), + !forced, + panel_control, + )); + + // ③ 背景图开关 + rows = rows.push(choice_row( + ui, + "Background image", + vec![ + choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)), + choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)), + ], + )); + + // ④ 路径(文本框:受控,输入即生效) + let path_field: Element<'a, Message> = text_field( + ui, + Field::new(&state.bg_path) + .label("Image path") + .placeholder("D:/Pictures/background.png"), + |value| Message::Appearance(Change::BgImage(value)), + ); + rows = rows.push( + column![label(ui, "Image path"), path_field] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + + // ⑤ 填充 + rows = rows.push(choice_row( + ui, + "Fill", + vec![ + choice( + ui, + "Cover", + state.bg_fill == BgFill::Cover, + Change::BgFill(BgFill::Cover), + ), + choice( + ui, + "Contain", + state.bg_fill == BgFill::Contain, + Change::BgFill(BgFill::Contain), + ), + choice( + ui, + "Tile", + state.bg_fill == BgFill::Tile, + Change::BgFill(BgFill::Tile), + ), + ], + )); + + // ⑥ 暗化 + rows = rows.push(slider_row( + ui, + "Dim", + format!("{:.0}%", state.bg_dim * 100.0), + format!( + "Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}." + ), + state.bg_dim > 0.0, + slider( + Slider::new(ui, state.bg_dim / BG_DIM_MAX) + .on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))), + ), + )); + + // ⑦ 配色来源 + rows = rows.push(choice_row( + ui, + "Colour source", + vec![ + choice( + ui, + "Background", + state.palette_source == PaletteSource::BackgroundImage, + Change::PaletteSource(PaletteSource::BackgroundImage), + ), + choice( + ui, + "Wallpaper", + state.palette_source == PaletteSource::Wallpaper, + Change::PaletteSource(PaletteSource::Wallpaper), + ), + choice( + ui, + "Preset", + state.palette_source == PaletteSource::Preset, + Change::PaletteSource(PaletteSource::Preset), + ), + ], + )); + + // ⑧ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默** + for note in state.diagnostics() { + rows = rows.push(note_line(ui, note, ui.color(Role::Error))); + } + if let Some(note) = state.backend_note() { + rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + if let Some(note) = state.palette_note() { + rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); + } + let resolved = format!("Colours: {}", state.palette_level()); + rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); + + card_with_panel_alpha(ui, state, rows) +} + +fn card_with_panel_alpha<'a>( + ui: &'a Ui, + state: &State, + content: impl Into>, +) -> Element<'a, Message> { + let spec = Component::OutlinedCard.spec(); + let background = panel_color(ui, MaterialLayer::Card, state.effective_panel_alpha()); + + container(content) + .width(Length::Fill) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(background)), + border: ui.outlined(spec.shape), + ..container::Style::default() + }) + .into() +} + +// --------------------------------------------------------------- 行与控件 + +fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> { + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + content, + ui.color(Role::OnSurface), + ) +} + +fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> { + // 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。 + frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color) +} + +/// 一行「标签 + 值 + 滑块 + 说明」。 +fn slider_row<'a>( + ui: &'a Ui, + title: &'a str, + value: String, + hint: String, + enabled: bool, + control: Element<'a, Message>, +) -> Element<'a, Message> { + let value_color = if enabled { + ui.color(Role::OnSurface) + } else { + ui.color(Role::OnSurfaceVariant) + }; + + let head = row![ + container(label(ui, title)) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + + column![ + head, + control, + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + hint, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一行「标签 + 一排互斥按钮」。 +fn choice_row<'a>( + ui: &'a Ui, + title: &'a str, + choices: Vec>, +) -> Element<'a, Message> { + 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() +} diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs new file mode 100644 index 0000000..51fae76 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -0,0 +1,386 @@ +//! 外观的**运行期状态**:配置值 → 生效值(钳位 / CPU 档降级)→ 要下发的字节与要画的格子。 +//! +//! 这一层是「键 → 像素」的中间站,App 持有一份(`crate::app::state::App::appearance`): +//! +//! ```text +//! launcher.json ──SettingsBridge::load/load_base──┐ +//! ▼ +//! State(本文件) +//! ┌────────────┴────────────┐ +//! 整窗 α → 字节 │ │ 面板 α / 背景图 → Element +//! ▼ ▼ +//! app::window_alpha(FFI) shell / 设置页(像素) +//! ``` +//! +//! **纯的与不纯的**分界刻意写在这里:钳位链([`super::alpha`])、几何([`super::background`])、 +//! 优先序([`super::palette`])都是纯函数;本文件只把它们的输入输出连起来,并把「读盘失败」 +//! 变成一条**可读诊断**而不是一个 `unwrap`。 + +use unlucky_design::color::{Scheme, SchemeFlavor}; +use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs, BgFill, PaletteSource}; + +use crate::Backend; + +use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque}; +use super::background::{self, Image, Mosaic}; +use super::palette::{self, Resolved, Source}; + +/// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。 +pub const ENV_WINDOW_ALPHA: &str = "ULL_APPEARANCE_WINDOW_ALPHA"; +/// 面板 α。 +pub const ENV_PANEL_ALPHA: &str = "ULL_APPEARANCE_PANEL_ALPHA"; +/// 背景图路径。 +pub const ENV_BG_IMAGE: &str = "ULL_APPEARANCE_BG_IMAGE"; +/// 填充档(`cover` / `contain` / `tile`)。 +pub const ENV_BG_FILL: &str = "ULL_APPEARANCE_BG_FILL"; +/// 暗化强度。 +pub const ENV_BG_DIM: &str = "ULL_APPEARANCE_BG_DIM"; +/// 配色来源档(`background-image` / `wallpaper` / `preset`)。 +pub const ENV_PALETTE: &str = "ULL_APPEARANCE_PALETTE"; + +/// 外观的运行期状态。 +#[derive(Debug, Clone, PartialEq)] +pub struct State { + /// 用户请求的整窗 α(未钳位原值;下发前过 [`super::alpha`])。 + pub window_opacity: f32, + /// 用户请求的面板 α(CPU 档会被强制成 1.0,见 [`State::effective_panel_alpha`])。 + pub panel_alpha: f32, + /// 背景图总开关。 + pub bg_enabled: bool, + /// 背景图路径(空 = 未设置)。 + pub bg_path: String, + /// 填充档。 + pub bg_fill: BgFill, + /// 暗化强度。 + pub bg_dim: f32, + /// 配色来源档。 + pub palette_source: PaletteSource, + /// 玻璃拟态开关(CPU 档强制关)。 + pub glass: bool, + /// 渲染后端(决定 CPU 档的两条降级)。 + pub backend: Backend, + /// 设置页当前分组(0 = General … 1 = Appearance;见 [`super::APPEARANCE_GROUP`])。 + pub selected_group: usize, + /// 已解码的背景图(`None` = 没设 / 读失败)。 + image: Option, + /// 读盘/解码失败的**可读诊断**(按发生顺序追加)。 + diagnostics: Vec, + /// 上一次的配色解析结果。 + resolved: Option, + /// 当前深浅(配色解析的输入)。 + 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 { + alpha::window_alpha_byte(self.backend, self.window_opacity) + } + + /// 背景图是否真的会画(开关开 + 路径非空 + 解码成功)。 + #[must_use] + pub fn background_active(&self) -> bool { + self.bg_enabled && self.image.is_some() + } + + /// 已解码的背景图。 + #[must_use] + pub fn image(&self) -> Option<&Image> { + self.image.as_ref() + } + + /// 算一次马赛克(渲染层每帧调一次;纯函数,成本 = 格数)。 + /// + /// `toward` 是当前主题的面板底色(暗化往它退,见 [`background::dimmed`])。 + #[must_use] + pub fn mosaic(&self, viewport: (f32, f32), toward: [u8; 3]) -> Option { + let image = self.image.as_ref()?; + if !self.bg_enabled { + return None; + } + Some(background::mosaic( + image, + self.bg_fill, + viewport, + self.bg_dim, + toward, + )) + } + + /// 取色得到的方案(`None` = 色卡档,继续用令牌 baseline)。 + /// + /// 为什么色卡档不换 scheme:`resolve_source` 的色卡档走的是 `DEFAULT_CARD`(天蓝卡), + /// 与令牌 baseline 逐色不同;直接应用会把**全部既有验收图的像素**改掉。色卡档的视觉基线 + /// 归令牌层(`Scheme::baseline`),这里只接管「取色真的成功」的那两档。 + #[must_use] + pub fn derived_scheme(&self) -> Option<&Scheme> { + let resolved = self.resolved.as_ref()?; + if resolved.level() == unlucky_design::palette::Level::Preset { + return None; + } + Some(&resolved.scheme) + } + + /// 读盘/解码失败的诊断(设置页逐条显示)。 + #[must_use] + pub fn diagnostics(&self) -> &[String] { + &self.diagnostics + } + + /// CPU 档降级的说明(`None` = 没有降级)。 + #[must_use] + pub fn backend_note(&self) -> Option<&'static str> { + self.panel_is_forced().then_some( + "Software renderer: panel transparency and glass stay fully opaque (window \ + transparency still works).", + ) + } + + /// 配色来源的提示(`None` = 一级成功)。 + #[must_use] + pub fn palette_note(&self) -> Option<&'static str> { + self.resolved.as_ref()?.note() + } + + /// 生效的配色层级码(日志用)。 + #[must_use] + pub fn palette_level(&self) -> &'static str { + match &self.resolved { + Some(resolved) => resolved.level().code(), + None => self.palette_source.as_str(), + } + } + + /// 深浅切换后重算配色(`Ui` 换主题时调)。 + pub fn set_flavor(&mut self, flavor: SchemeFlavor) { + if self.flavor == flavor { + return; + } + self.flavor = flavor; + self.refresh_palette(); + } + + /// 当前深浅。 + #[must_use] + pub fn flavor(&self) -> SchemeFlavor { + self.flavor + } + + /// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。 + pub fn apply(&mut self, change: super::Change) -> bool { + use super::Change; + match change { + Change::WindowOpacity(alpha) => { + self.window_opacity = alpha; + true + } + Change::PanelAlpha(alpha) => { + self.panel_alpha = alpha; + false + } + Change::BgEnabled(enabled) => { + self.bg_enabled = enabled; + self.refresh_background(); + self.refresh_palette(); + false + } + Change::BgImage(path) => { + self.bg_path = path; + self.bg_enabled = !self.bg_path.trim().is_empty(); + self.refresh_background(); + self.refresh_palette(); + false + } + Change::BgFill(fill) => { + self.bg_fill = fill; + false + } + Change::BgDim(dim) => { + self.bg_dim = dim; + false + } + Change::PaletteSource(source) => { + self.palette_source = source; + self.refresh_palette(); + false + } + Change::SelectGroup(index) => { + self.selected_group = index; + false + } + } + } + + /// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 8 个字段)。 + /// + /// 注意:**强调色 / 玻璃不在本 mission 的管辖内**,调用方(`app::appearance`)会拿配置里 + /// 已有的快照做基底、只覆盖本 mission 真改了的字段,所以这里不产出完整快照。 + /// 这个函数只给「当前值是什么」一个只读视图(日志与测试用)。 + #[must_use] + pub fn current_values(&self) -> AppearancePrefs { + AppearancePrefs { + bg_image_enabled: self.bg_enabled, + bg_image: self.bg_path.clone(), + bg_fill: self.bg_fill, + bg_dim: self.bg_dim, + panel_alpha: self.panel_alpha, + glass: self.glass, + accent_color: String::new(), + } + } + + /// 取证日志(**一行**,数值而不是观感):`[appearance] window_alpha=… panel_alpha=… byte=…`。 + #[must_use] + pub fn log_line(&self, context: &str) -> String { + let byte = self + .window_alpha_byte() + .map_or_else(|| "none".to_owned(), |byte| byte.to_string()); + format!( + "[appearance] {context} window_alpha={:.3} panel_alpha={:.3} (requested {:.3}) \ + byte={byte} backend={} bg={} fill={} dim={:.2} palette={} group={}", + self.window_opacity, + self.effective_panel_alpha(), + self.panel_alpha, + self.backend.name(), + if self.background_active() { + self.bg_path.as_str() + } else { + "none" + }, + self.bg_fill.as_str(), + self.bg_dim, + self.palette_level(), + self.selected_group + ) + } + + /// 重读配置后同步(热切)。 + pub fn reload(&mut self, prefs: &AppearancePrefs, base: &AppearanceBase) { + self.window_opacity = base.window_opacity; + self.palette_source = base.palette_source; + self.panel_alpha = prefs.panel_alpha; + self.bg_enabled = prefs.bg_image_enabled; + self.bg_fill = prefs.bg_fill; + self.bg_dim = prefs.bg_dim; + self.glass = prefs.glass; + if self.bg_path != prefs.bg_image { + self.bg_path = prefs.bg_image.clone(); + } + self.refresh_background(); + self.refresh_palette(); + } + + // ------------------------------------------------------------ 内部 + + fn refresh_background(&mut self) { + self.image = None; + self.diagnostics.clear(); + let trimmed = self.bg_path.trim(); + if !self.bg_enabled || trimmed.is_empty() { + return; + } + match background::load(trimmed) { + Ok(image) => self.image = Some(image), + Err(error) => self.diagnostics.push(error.describe()), + } + } + + fn refresh_palette(&mut self) { + let background = match (&self.image, self.bg_enabled) { + (Some(image), true) => Source::Pixels(image), + (None, true) if !self.bg_path.trim().is_empty() => Source::Undecodable, + _ => Source::Absent, + }; + // 桌面壁纸探测(M73)尚未落地:这一级永远 `Absent`,回落链仍按 design 的规则走。 + self.resolved = Some(palette::resolve( + self.palette_source, + background, + Source::Absent, + self.flavor, + )); + } +} + +fn env_f32(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite()) +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 2f2b00f..95e5e4f 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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, 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), } } diff --git a/crates/app-iced/src/pages/settings.rs b/crates/app-iced/src/pages/settings.rs index c7b5cc6..29acab8 100644 --- a/crates/app-iced/src/pages/settings.rs +++ b/crates/app-iced/src/pages/settings.rs @@ -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::>(), ) .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) -> Element<'a, Message> { +fn form<'a>( + ui: &'a Ui, + only: Option, + 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) -> 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) -> 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) -> 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(), "演示下标仍在范围内"); + } } diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 1ada558..65821cf 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -29,6 +29,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 +83,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) -> AlphaOutcome { let Some(alpha) = alpha.filter(|value| value.is_finite() && *value > 0.0) else { return AlphaOutcome::Rejected(AlphaRejection::NoAlpha); @@ -102,6 +193,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 +238,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); diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index f4be16b..f9179ae 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -34,6 +34,7 @@ use unlucky_design::typography::TypeRole; use crate::app::Message; use crate::cli::ForcedState; use crate::icons; +use crate::pages::appearance::panel_color; use crate::theme::Ui; /// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。 @@ -51,24 +52,41 @@ use crate::theme::Ui; /// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距), /// 两边的账不会算串。 /// -/// [`view`] 是它的兼容包装:`content` 换成 M68 的「页头 + 一张卡」。 +/// M93 起面板 α 是**外壳层**的属性(根容器 / 标题栏 / 导航轨的底色按它乘 alpha):要背景层的 +/// 调用方走 [`view_with_background`]。 pub fn view_with_content<'a>( ui: &'a Ui, selected: NavItem, app_title: &'a str, content: Element<'a, Message>, forced: Option, +) -> Element<'a, Message> { + view_with_background(ui, selected, app_title, None, 1.0, 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>, + panel_alpha: f32, + content: Element<'a, Message>, + forced: Option, ) -> Element<'a, Message> { let work_area = column![ - titlebar(ui, app_title, forced), - row![nav::rail(ui, selected, forced), content] + titlebar(ui, app_title, forced, panel_alpha), + row![nav::rail(ui, selected, forced, panel_alpha), content] .width(Length::Fill) .height(Length::Fill), ] .width(Length::Fill) .height(Length::Fill); - window_frame(ui, work_area.into()) + window_frame(ui, background, panel_alpha, work_area.into()) } /// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。 @@ -92,8 +110,16 @@ pub fn view<'a>( ) } -/// 无边框窗口的「外壳壳子」:工作区 + 两条缩放热区 + 根容器底色。 -fn window_frame<'a>(ui: &'a Ui, work_area: Element<'a, Message>) -> Element<'a, Message> { +/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。 +/// +/// 面板 α 落在两处:根容器底色(= 整窗 Canvas 层)与标题栏 / 导航轨底色。`panel_alpha == 1.0` +/// 时颜色逐字节不变,所以没开面板透明度时不动既有验收图。 +fn window_frame<'a>( + ui: &'a Ui, + background: Option>, + panel_alpha: f32, + work_area: Element<'a, Message>, +) -> Element<'a, Message> { // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 // **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px, // 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。 @@ -124,12 +150,16 @@ 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]; + // 背景在最底层:面板 α < 1 时从它的空隙里透出来(M93 / D34)。 + 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| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha))) .into() } @@ -152,7 +182,12 @@ fn resize_edge<'a>( // ---------------------------------------------------------------- 标题栏 -fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option) -> Element<'a, Message> { +fn titlebar<'a>( + ui: &'a Ui, + title: &'a str, + forced: Option, + panel_alpha: f32, +) -> Element<'a, Message> { let metrics = ui.metrics(); // 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段) @@ -184,7 +219,7 @@ fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option) -> Elem container(bar) .width(Length::Fill) .height(metrics.titlebar_height) - .style(|_theme| filled(ui.layer(MaterialLayer::Canvas))) + .style(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha))) .into() } diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index bfd3f7d..b785972 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -25,6 +25,7 @@ use unlucky_design::typography::TypeRole; use crate::app::Message; use crate::cli::ForcedState; use crate::icons; +use crate::pages::appearance::panel_color; use crate::shell::{filled, label, overridden}; use crate::theme::Ui; @@ -81,6 +82,7 @@ pub(super) fn rail<'a>( ui: &'a Ui, selected: NavItem, forced: Option, + panel_alpha: f32, ) -> Element<'a, Message> { let metrics = ui.metrics(); let indicator = indicator_size(metrics); @@ -92,11 +94,13 @@ pub(super) fn rail<'a>( // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 // spacing(4)再加一档,实测变 28(视觉复审 P3-5)。 + // + // 轨底色也吃面板 α(M93):导航轨是「面板层」的一部分,背景图应该能透过它。 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(panel_color(ui, MaterialLayer::Canvas, panel_alpha))) .into() } diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index feaa65d..071f253 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -80,6 +80,22 @@ impl Ui { self.scheme.flavor() } + /// 用**已解析的方案**构造求值器(D38 的落点:背景图 / 桌面壁纸取色 → 49 个角色换掉 baseline)。 + /// + /// 只是**追加**的构造入口:既有的 [`Ui::new`] / [`Ui::with_platform`] 一字未动,默认仍是 + /// [`Scheme::baseline`]。调用方(`app` 侧)拿到 `unlucky_design::palette::resolve_source` 的 + /// 结果后走这里,于是「配色来源」真的能改颜色,而不只是一条日志。 + /// + /// 平台与骨架尺寸仍按当前环境求值——方案只带颜色,不带平台。 + pub fn from_scheme(scheme: Scheme, families: Families) -> Self { + Self { + scheme, + platform: Platform::current(), + metrics: LayoutMetrics::DESKTOP, + families, + } + } + /// 当前平台(决定字体族与字重降级)。 pub fn platform(&self) -> Platform { self.platform @@ -331,4 +347,47 @@ mod tests { assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果"); assert_ne!(hovered, ui.color(Role::Surface)); } + + /// D38:配色来源解析出来的方案必须**真的换色**(M93)。 + /// + /// 抓得住:①`from_scheme` 用的是传进来的方案,不是 baseline(配一张蓝底的取色方案, + /// primary 与 baseline 不同);②默认构造(`new` / `with_platform`)逐字段等于 baseline + /// ——即「不传 scheme 时行为不变」,这条是防回流的那一半。 + /// 抓不住:取色结果好不好看、设置页是否真的切过去(前者是设计判断,后者由 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 时默认行为必须与改动前一致" + ); + } + } } diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 644d060..e510c1b 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -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 { #[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(); + } } From 22e4c423fa8a48703f8c3212d22d1eb0b036b1a6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 17:16:21 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(app-iced):=20M93=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=83=8C=E6=99=AF=E5=9B=BE=E7=9C=9F=E5=9B=BE?= =?UTF-8?q?=E7=9B=B4=E5=87=BA=20+=20=E9=9D=A2=E6=9D=BF=20=CE=B1=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=88=B0=20Ui::layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。 1) 背景图**真图直出** - `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器); 编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。 - `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles` 算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。 - 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。 - 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用 `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。 2) 面板 α **收敛到唯一来源** - `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、 `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。 - 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、 `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**, 调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。 - 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。 3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95 字面量改为转发常量。 验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、 GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见; 另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。 --- Cargo.lock | 18 ++ crates/app-iced/Cargo.toml | 13 +- crates/app-iced/src/app/state.rs | 6 +- crates/app-iced/src/app/tests/appearance.rs | 14 +- crates/app-iced/src/app/view.rs | 1 - crates/app-iced/src/pages/appearance/alpha.rs | 12 +- .../src/pages/appearance/background.rs | 289 +++++------------- .../src/pages/appearance/background/tests.rs | 230 +++++--------- crates/app-iced/src/pages/appearance/mod.rs | 15 +- .../app-iced/src/pages/appearance/picture.rs | 104 +++++++ .../app-iced/src/pages/appearance/section.rs | 61 +--- crates/app-iced/src/pages/appearance/state.rs | 20 +- crates/app-iced/src/pages/frame.rs | 4 + crates/app-iced/src/pages/frame/pieces.rs | 2 +- crates/app-iced/src/shell/mod.rs | 39 ++- crates/app-iced/src/shell/nav.rs | 6 +- crates/app-iced/src/theme.rs | 93 +++--- crates/presenter/src/prefs.rs | 7 +- 18 files changed, 399 insertions(+), 535 deletions(-) create mode 100644 crates/app-iced/src/pages/appearance/picture.rs diff --git a/Cargo.lock b/Cargo.lock index 3235503..6079eaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 6ec44fb..af2fd01 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -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 → diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index d660719..5f44ca1 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -235,6 +235,9 @@ impl App { /// `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!( @@ -244,7 +247,8 @@ impl App { Ui::from_scheme(*scheme, self.families) } None => Ui::new(self.appearance.flavor(), self.families), - }; + } + .with_panel_alpha(panel_alpha); } /// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。 diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index 8ff9ee8..1b56eb2 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -130,7 +130,7 @@ fn a_broken_background_path_falls_back_silently_but_says_why() { .apply(Change::BgImage("D:/definitely/not/here.png".to_owned())); assert!(!app.appearance.background_active()); - assert!(app.appearance.mosaic((1180.0, 760.0), [0, 0, 0]).is_none()); + assert!(app.appearance.image().is_none(), "解码失败 ⇒ 没有可用图"); assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断"); assert!( app.appearance.diagnostics()[0].contains("could not be read"), @@ -169,11 +169,11 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() { app.rebuild_ui(); assert!(app.appearance.background_active()); - let mosaic = app - .appearance - .mosaic((1180.0, 760.0), [0, 0, 0]) - .expect("有背景层"); - assert!(mosaic.cols > 1 && mosaic.rows > 1); + 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!( @@ -286,5 +286,5 @@ fn a_fresh_state_is_the_factory_appearance() { assert_eq!(state.effective_panel_alpha(), 1.0); assert!(!state.background_active()); assert!(state.diagnostics().is_empty()); - assert!(state.mosaic((1180.0, 760.0), [0, 0, 0]).is_none()); + assert!(state.image().is_none()); } diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b94b47b..80b4ece 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -56,7 +56,6 @@ impl App { nav_item_of(self.state.route()), APP_TITLE, background, - self.appearance.effective_panel_alpha(), pages::page_with_appearance(&self.ui, &self.view, &self.appearance) .map(Message::Page), self.options.force_state, diff --git a/crates/app-iced/src/pages/appearance/alpha.rs b/crates/app-iced/src/pages/appearance/alpha.rs index ebfd50a..1e5dec3 100644 --- a/crates/app-iced/src/pages/appearance/alpha.rs +++ b/crates/app-iced/src/pages/appearance/alpha.rs @@ -37,12 +37,8 @@ use crate::Backend; /// 整窗 α 下限的 `f32` 形态(唯一真源仍是 `unlucky_presenter::prefs::WINDOW_ALPHA_MIN`)。 pub const WINDOW_ALPHA_FLOOR: f32 = WINDOW_ALPHA_MIN as f32; -/// 面板 α 下限(0.4)。 -/// -/// 与 `unlucky_core::setting::appearance::PANEL_ALPHA_MIN` **同值**,但只能在这里再写一遍: -/// `unlucky-presenter` 没有重导出它(只重导出了 `WINDOW_ALPHA_MIN`),而 `app-iced` -/// 不直接依赖 core。已报 finding,收口方式是让 presenter 重导出。 -pub const PANEL_ALPHA_FLOOR: f32 = 0.4; +/// 面板 α 下限(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] @@ -130,8 +126,8 @@ mod tests { #[test] fn floor_values_are_the_ones_the_user_decided() { // 抓得住:有人把下限改回 0(= 整窗穿透)或把面板下限降到「前景压不住背景图」。 - // 抓不住:数值本身是否来自令牌(`WINDOW_ALPHA_FLOOR` 由 presenter 重导出,这条能对; - // 面板下限是本地字面量,只能钉 0.4 这个事实)。 + // 两个下限都来自 `unlucky_presenter::prefs` 的重导出(M93 r2 起不再有本地字面量), + // 所以这条同时钉住「presenter 的重导出没被改坏」。 assert_eq!(WINDOW_ALPHA_FLOOR, 0.08); assert_eq!(PANEL_ALPHA_FLOOR, 0.4); } diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index 08cb321..8023c6d 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -1,60 +1,51 @@ -//! 背景图:**读盘解码 → 填充几何 → 暗化 → 马赛克取样**。纯函数(除 [`load`] 一次 IO)。 +//! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。 //! -//! ## 三种填充(`appearance.bg_fill`,core 的 [`BgFill`]) +//! ## 真图直出(M93 r2 修订) //! -//! | 档 | 缩放 | 位置 | 露底色 | -//! |---|---|---|---| -//! | `cover` | `max(vw/iw, vh/ih)`(铺满、裁掉溢出) | 居中 | 否 | -//! | `contain` | `min(vw/iw, vh/ih)`(完整显示) | 居中 | **是**(上下或左右留底色) | -//! | `tile` | `1`(原始尺寸平铺) | 从视口左上角起重复 | 否 | +//! r1 因为 iced 的 `image` feature 没开,只能把像素画成 36dp 网格的马赛克。r2 打开了 +//! `iced/image`(`crates/app-iced/Cargo.toml`),于是**真图直出**:填充由 iced 的 `ContentFit` +//! 负责(`Cover` / `Contain`),平铺由本模块的 [`tiles`] 算网格、渲染层真的铺 N 个图块。 +//! 马赛克那条路已删除(不再有「每格一个颜色」的降级)。 //! -//! 几何是**纯函数**([`placement`] / [`source_point`]),因此三种档的差异可以被数值断言, -//! 不必靠肉眼比对截图。 +//! | 档 | 渲染 | 露底色 | +//! |---|---|---| +//! | `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`) //! -//! 语义是「让背景往面板底色退」:`rgb + (toward − rgb) × dim`(`toward` = 当前主题的 Canvas 色)。 -//! -//! 为什么**不是**「叠一层黑」:叠黑在深色主题里正合适,但在浅色主题下会把亮图压成暗块,而压在 -//! 它上面的深色正文反而更读不清。向底色退在两种主题下都是单调变好(浅色向白退、深色向黑退), -//! 也仍然是「一次乘法式的插值」——结果**不透明**,不依赖渲染器的 alpha 混合精度, -//! CPU 档与 GPU 档出图逐字节一致。 -//! -//! 这条偏离(相对「暗化 = 乘黑」的字面语义)登记在 [`super`] 的偏离表 #7。 -//! -//! ## 为什么是「马赛克」而不是直接画图 -//! -//! iced 0.14 的 `widget::image` 需要 `iced/image` feature,而本 crate 的 iced 只开了 -//! `tiny-skia / crisp / advanced / svg / thread-pool`,`Cargo.toml` 不在本 mission 的 scope -//! (已登记偏离 #1)。所以这里把解码后的像素按 [`CELL_PX`] 的网格做**块平均**,每格画一个实心 -//! 方块:填充几何、暗化、取色全部是真的,只有「每格一个颜色」这一条是降级。 -//! 网格上限 [`MAX_COLS`]×[`MAX_ROWS`],成本有界(1180×760 下约 33×22 个方块)。 +//! 语义是「让背景往面板底色退」,即把版面底色(`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; -/// 暗化上限(与 core 的 `BG_DIM_MAX` 同值 0.95):再高背景图基本看不见,等于关掉。 -pub const BG_DIM_MAX: f32 = 0.95; - -/// 解码后保留的最大边长:取样与取色都不需要分辨率,4K 图缩到 256 边后内存从 33MB 掉到 147KB。 +/// 解码后保留给取色的最大边长:取色只需要色彩分布,4K 图缩到 256 边后内存从 33MB 掉到 147KB。 pub const MAX_EDGE: u32 = 256; /// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。 pub const MAX_SOURCE_EDGE: u32 = 16_384; -/// 马赛克网格的目标格边长(dp)。 -pub const CELL_PX: f32 = 36.0; - -/// 马赛克网格上限(列 / 行)——渲染成本的天花板。 -pub const MAX_COLS: u32 = 48; -/// 马赛克网格上限(行)。 -pub const MAX_ROWS: u32 = 32; +/// 平铺档一次最多铺多少个图块(列 × 行)。超了就不铺——避免一张 8×8 的小图在 4K 上拼出 +/// 上万个子控件。 +pub const MAX_TILES: u32 = 64; /// 解码失败的原因(人话在 [`LoadError::describe`])。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadError { /// 读文件失败:不存在 / 没权限 / 被占用。 Io(String), - /// 解码失败:格式不认识(本 build 只带 png 编解码器)/ 文件截断 / 不是图片。 + /// 解码失败:格式不认识(webp/tiff 等没开编码器)/ 文件截断 / 不是图片。 Undecodable(String), /// 图片头里的尺寸超限。 TooLarge { @@ -72,7 +63,7 @@ impl LoadError { match self { Self::Io(reason) => format!("Background image could not be read: {reason}"), Self::Undecodable(reason) => format!( - "Background image could not be decoded ({reason}; this build decodes PNG only)" + "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)" @@ -90,12 +81,12 @@ pub struct Image { pub width: u32, /// 缩略图高(≤ [`MAX_EDGE`])。 pub height: u32, - /// 缩略图 RGBA8(`width × height × 4`):取样与取色都用它。 + /// 缩略图 RGBA8(取色用;`width × height × 4`)。 pub rgba: Vec, } impl Image { - /// 从任意解码结果构造(测试也走这里,避免测试依赖磁盘)。 + /// 从已解码的数据构造(测试也走这里,避免测试依赖磁盘)。 #[must_use] pub fn from_rgba(source: (u32, u32), width: u32, height: u32, rgba: Vec) -> Self { Self { @@ -117,8 +108,10 @@ pub fn load(path: &str) -> Result { 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(trimmed)?; + 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, @@ -126,8 +119,7 @@ pub fn load(path: &str) -> Result { }); } - let decoded = open(trimmed)? - .decode() + let decoded = image::load_from_memory(&bytes) .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); @@ -140,24 +132,19 @@ pub fn load(path: &str) -> Result { image::imageops::FilterType::Triangle, ); - Ok(Image::from_rgba( - (source_w, source_h), - target_w, - target_h, - thumb.into_raw(), - )) + Ok(Image { + source: (source_w, source_h), + width: target_w, + height: target_h, + rgba: thumb.into_raw(), + }) } -fn open(path: &str) -> Result>, LoadError> { - let reader = - image::ImageReader::open(path).map_err(|error| LoadError::Io(error.to_string()))?; - reader +/// 原图尺寸(只读图头,不解像素)。 +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())) -} - -fn header_size(path: &str) -> Result<(u32, u32), LoadError> { - let reader = open(path)?; + .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(), @@ -168,103 +155,9 @@ fn header_size(path: &str) -> Result<(u32, u32), LoadError> { .map_err(|error| LoadError::Undecodable(error.to_string())) } -/// 填充几何:源图 → 视口 的映射。 -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Placement { - /// 缩放比:源图 1 像素 = `scale` dp。 - pub scale: f32, - /// 源图左上角落在视口里的位置(dp;`cover` 时是负值 = 已经裁掉溢出)。 - pub origin: (f32, f32), - /// 是否平铺(只有 `tile`)。 - pub repeat: bool, -} - -impl Placement { - /// 源图缩放后的尺寸(dp)。 - #[must_use] - pub fn scaled(&self, source: (u32, u32)) -> (f32, f32) { - (source.0 as f32 * self.scale, source.1 as f32 * self.scale) - } -} - -/// 按填充档算一次几何(纯函数)。 +/// 暗化系数(叠在图上那层底色的 α):`0` = 不暗化,上限 [`BG_DIM_MAX`]。 #[must_use] -pub fn placement(fill: BgFill, source: (u32, u32), viewport: (f32, f32)) -> Placement { - let source_f = (source.0.max(1) as f32, source.1.max(1) as f32); - let viewport = (viewport.0.max(1.0), viewport.1.max(1.0)); - - let scale = match fill { - BgFill::Cover => (viewport.0 / source_f.0).max(viewport.1 / source_f.1), - BgFill::Contain => (viewport.0 / source_f.0).min(viewport.1 / source_f.1), - BgFill::Tile => 1.0, - }; - let scaled = (source_f.0 * scale, source_f.1 * scale); - let origin = match fill { - // 平铺从视口左上角起(官方 Contain/Tile 的语义:不居中,直接重复) - BgFill::Tile => (0.0, 0.0), - _ => ((viewport.0 - scaled.0) / 2.0, (viewport.1 - scaled.1) / 2.0), - }; - - Placement { - scale, - origin, - repeat: matches!(fill, BgFill::Tile), - } -} - -/// 视口点 → 源图像素坐标(未取整)。`None` = 该点落在图像之外(`contain` 的留底色区)。 -#[must_use] -pub fn source_point( - placement: &Placement, - source: (u32, u32), - point: (f32, f32), -) -> Option<(f32, f32)> { - let source_f = (source.0.max(1) as f32, source.1.max(1) as f32); - let mut x = (point.0 - placement.origin.0) / placement.scale; - let mut y = (point.1 - placement.origin.1) / placement.scale; - if placement.repeat { - x = x.rem_euclid(source_f.0); - y = y.rem_euclid(source_f.1); - } else if !(0.0..source_f.0).contains(&x) || !(0.0..source_f.1).contains(&y) { - return None; - } - Some((x, y)) -} - -/// 取缩略图上一个像素(最近邻;缩略图已经 ≤256 边,够用)。 -#[must_use] -pub fn sample(image: &Image, point: (f32, f32)) -> [u8; 3] { - let x = (point.0 as u32).min(image.width.saturating_sub(1)); - let y = (point.1 as u32).min(image.height.saturating_sub(1)); - let index = ((y * image.width + x) * 4) as usize; - let rgba = &image.rgba; - [ - rgba.get(index).copied().unwrap_or(0), - rgba.get(index + 1).copied().unwrap_or(0), - rgba.get(index + 2).copied().unwrap_or(0), - ] -} - -/// 暗化:把取样色按 `dim` **混向面板底色**(`rgb + (toward − rgb) × dim`)。 -/// -/// 为什么混向底色而不是乘一个「黑」:乘黑在深色主题里正合适,但浅色主题下会让一张亮图变成 -/// 深色块、而压在其上的深色正文就更难读。混向 `toward`(= 当前主题的 Canvas 色)在两种主题下 -/// 都是「往底色的方向退」,正文对比度因此单调变好——这与 `appearance.bg_dim` 的用途 -/// (「让前景压得住背景」)一致。`dim = 0` 时逐字节不变。 -#[must_use] -pub fn dimmed(rgb: [u8; 3], dim: f32, toward: [u8; 3]) -> [u8; 3] { - let keep = clamp_dim(dim); - let channel = |index: usize| { - let from = f32::from(rgb[index]); - let to = f32::from(toward[index]); - (from + (to - from) * keep).round().clamp(0.0, 255.0) as u8 - }; - [channel(0), channel(1), channel(2)] -} - -/// 暗化系数的唯一钳位点(0 = 不暗化,0.95 = 上限)。 -#[must_use] -pub fn clamp_dim(dim: f32) -> f32 { +pub fn dim_alpha(dim: f32) -> f32 { if dim.is_nan() { 0.0 } else { @@ -272,79 +165,51 @@ pub fn clamp_dim(dim: f32) -> f32 { } } -/// 一次渲染要画的马赛克:按行主序铺的格子色(`None` = 露底色)。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Mosaic { +/// 平铺档的网格(列 × 行)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Tiles { /// 列数。 pub cols: u32, /// 行数。 pub rows: u32, - /// 每格颜色(`rows × cols`,行主序)。 - pub cells: Vec>, } -impl Mosaic { - /// 第 `row` 行第 `col` 列的颜色(越界给 `None`)。 +impl Tiles { + /// 总块数。 #[must_use] - pub fn at(&self, row: u32, col: u32) -> Option<[u8; 3]> { - if row >= self.rows || col >= self.cols { - return None; - } - self.cells - .get((row * self.cols + col) as usize) - .copied() - .flatten() - } - - /// 网格尺寸(格边长 dp = `视口 / 格数`,所以调用方可以只用 `Length::Fill` 拼行)。 - #[must_use] - pub fn cell_size(&self, viewport: (f32, f32)) -> (f32, f32) { - ( - viewport.0 / self.cols.max(1) as f32, - viewport.1 / self.rows.max(1) as f32, - ) + pub fn count(self) -> u32 { + self.cols * self.rows } } -/// 视口 → 网格尺寸(格边长 ≈ [`CELL_PX`],列/行各有上限)。 +/// 平铺档要铺几列几行(原图 1 像素 = 1 dp;总块数不超过 [`MAX_TILES`])。 +/// +/// 图比视口大时是 `1×1`(等于「以原尺寸裁切」),与小图平铺是同一套算式。 #[must_use] -pub fn grid(viewport: (f32, f32)) -> (u32, u32) { - let cols = div_ceil(viewport.0.max(1.0), CELL_PX).clamp(1, MAX_COLS); - let rows = div_ceil(viewport.1.max(1.0), CELL_PX).clamp(1, MAX_ROWS); - (cols, rows) -} +pub fn tiles(source: (u32, u32), viewport: (f32, f32)) -> Tiles { + let width = source.0.max(1) as f32; + let height = source.1.max(1) as f32; + let cols = ceil_div(viewport.0.max(1.0), width); + let rows = ceil_div(viewport.1.max(1.0), height); -/// 算一整张马赛克(纯函数:同一图 + 同一视口 + 同一底色 → 同一结果)。 -#[must_use] -pub fn mosaic( - image: &Image, - fill: BgFill, - viewport: (f32, f32), - dim: f32, - toward: [u8; 3], -) -> Mosaic { - let placement = placement(fill, image.source, viewport); - let (cols, rows) = grid(viewport); - let (cell_w, cell_h) = ( - viewport.0.max(1.0) / cols as f32, - viewport.1.max(1.0) / rows as f32, - ); - - let mut cells = Vec::with_capacity((cols * rows) as usize); - for row in 0..rows { - for col in 0..cols { - let centre = ((col as f32 + 0.5) * cell_w, (row as f32 + 0.5) * cell_h); - cells.push( - source_point(&placement, image.source, centre) - .map(|point| dimmed(sample(image, point), dim, toward)), - ); + // 超预算时按比例退:先保列再保行,至少留 1×1(否则什么都画不出来)。 + let mut tiles = Tiles { + cols: cols.max(1), + rows: rows.max(1), + }; + while tiles.count() > MAX_TILES { + if tiles.cols > 1 { + tiles.cols -= 1; + } else if tiles.rows > 1 { + tiles.rows -= 1; + } else { + break; } } - - Mosaic { cols, rows, cells } + tiles } -fn div_ceil(value: f32, step: f32) -> u32 { +fn ceil_div(value: f32, step: f32) -> u32 { let count = (value / step).ceil(); if count.is_finite() && count >= 1.0 { count as u32 diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index f020e4b..0c552ec 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -1,41 +1,16 @@ -//! `background` 的单测:填充几何 / 暗化 / 马赛克 / 解码失败。 +//! `background` 的单测:解码(png + jpeg)、失败分类、暗化钳位、平铺网格。 //! -//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律: -//! 能断言的(几何、通道值、错误分类)必须断言;观感(马赛克好不好看、真图直出)不在这里假装。 +//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。 -use super::{ - clamp_dim, dimmed, grid, load, mosaic, placement, sample, source_point, BgFill, Image, - LoadError, BG_DIM_MAX, MAX_COLS, MAX_ROWS, -}; +use super::{dim_alpha, load, tiles, LoadError, BG_DIM_MAX, MAX_TILES}; use std::path::{Path, PathBuf}; +use unlucky_presenter::prefs::BgFill; -/// 8×4 的合成图:左半红、右半蓝(缩略图与原图同尺寸,取样结果因此可预测)。 -fn halves() -> Image { - let (width, height) = (8_u32, 4_u32); - let mut rgba = Vec::with_capacity((width * height * 4) as usize); - for _row in 0..height { - for col in 0..width { - if col < width / 2 { - rgba.extend_from_slice(&[200, 40, 40, 255]); - } else { - rgba.extend_from_slice(&[40, 60, 200, 255]); - } - } - } - Image::from_rgba((width, height), width, height, rgba) -} - -/// 一整块纯色的合成图(只关心几何时用)。 -fn solid(size: (u32, u32), rgb: [u8; 3]) -> Image { - let rgba = [rgb[0], rgb[1], rgb[2], 255].repeat((size.0 * size.1) as usize); - Image::from_rgba(size, size.0, size.1, rgba) -} - -fn temp_png(label: &str) -> PathBuf { +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("bg.png") + dir.join(name) } fn cleanup(path: &Path) { @@ -45,131 +20,63 @@ fn cleanup(path: &Path) { } #[test] -fn cover_fills_the_viewport_and_contain_keeps_the_aspect_ratio() { - // 抓得住:两种档的 scale 公式(铺满 vs 完整)与居中偏移的正负号。 - // 抓不住:真实的裁切/留白在屏幕上的观感(那要看截图)。 - let source = (8, 4); // 2:1 - let viewport = (400.0, 400.0); +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"); - // cover = max(400/8 = 50, 400/4 = 100) = 100 → 缩放后 800×400,横向溢出 400 被裁。 - let cover = placement(BgFill::Cover, source, viewport); - assert_eq!(cover.scale, 100.0, "cover = max(50, 100)"); - assert_eq!(cover.origin, (-200.0, 0.0), "cover 居中后横向溢出被裁"); - - // contain = min(50, 100) = 50 → 缩放后 400×200,上下各留 100 的底色。 - let contain = placement(BgFill::Contain, source, viewport); - assert_eq!(contain.scale, 50.0); - assert_eq!(contain.origin, (0.0, 100.0), "contain 上下留底色"); - assert!(!contain.repeat && !cover.repeat); -} - -#[test] -fn cover_never_leaves_a_hole_and_contain_does() { - let image = solid((8, 4), [10, 20, 30]); - let viewport = (360.0, 360.0); - - let covered = mosaic(&image, BgFill::Cover, viewport, 0.0, [0, 0, 0]); + let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读"); + assert_eq!(image.source, (300, 20)); assert!( - covered.cells.iter().all(Option::is_some), - "cover 必须铺满(任何一格都不许是 None)" + image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE, + "缩略图 {}×{} 超预算", + image.width, + image.height ); - - let contained = mosaic(&image, BgFill::Contain, viewport, 0.0, [0, 0, 0]); - assert_eq!(contained.at(0, 0), None, "contain 的左上角是留底色"); - assert_eq!( - contained.at(0, 0), - None, - "(同一格重复读:确保 at() 不是偶发给 None)" - ); - let middle = (contained.rows / 2, contained.cols / 2); - assert!(contained.at(middle.0, middle.1).is_some(), "中间必须有图"); + 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 tile_repeats_the_original_size_art_grid() { - // 抓得住:tile 的 scale 恒 1、origin 在左上角、坐标按图宽/图高取模。 - // 抓不住:平铺后每格之间是否有一像素缝(那是渲染层的事)。 - let image = halves(); - let tile = placement(BgFill::Tile, image.source, (40.0, 40.0)); - assert_eq!(tile, tile); - assert_eq!(tile.scale, 1.0); - assert_eq!(tile.origin, (0.0, 0.0)); - assert!(tile.repeat); +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"); - // 同一源坐标:x 与 x + 图宽 必须取到同一像素。 - let left = source_point(&tile, image.source, (3.5, 1.5)).expect("平铺内部总有图"); - let wrapped = source_point(&tile, image.source, (3.5 + 8.0 * 3.0, 1.5 + 4.0 * 2.0)) - .expect("取模后仍在图内"); - assert!((left.0 - wrapped.0).abs() < 1e-4 && (left.1 - wrapped.1).abs() < 1e-4); - - // 左半红 / 右半蓝:x=1 取红、x=6 取蓝(平铺不改变这一点)。 + let image = load(path.to_str().unwrap()).expect("jpeg 必须能读(M93 r2)"); + assert_eq!(image.source, (64, 32)); assert_eq!( - sample( - &image, - source_point(&tile, image.source, (1.0, 1.0)).unwrap() - ), - [200, 40, 40] + image.rgba.len(), + (image.width * image.height * 4) as usize, + "像素解出来了" ); - assert_eq!( - sample( - &image, - source_point(&tile, image.source, (6.0, 1.0)).unwrap() - ), - [40, 60, 200] - ); -} - -#[test] -fn dim_mixes_toward_the_panel_colour_and_is_clamped() { - // 抓得住:暗化 = 向底色方向插值(不是简单乘黑);dim 越界被夹到 0..=0.95。 - // 语义的关键在浅色主题:向白退(而不是乘黑)才能让深色正文继续读得清。 - // 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断,见报告「没验的部分」)。 - assert_eq!(dimmed([200, 100, 50], 0.0, [255, 255, 255]), [200, 100, 50]); - assert_eq!( - dimmed([200, 100, 50], 0.35, [0, 0, 0]), - [130, 65, 33], - "深色主题:向黑退" - ); - assert_eq!( - dimmed([200, 100, 50], 0.35, [255, 255, 255]), - [219, 154, 122], - "浅色主题:向白退" - ); - assert_eq!(clamp_dim(-1.0), 0.0); - assert_eq!(clamp_dim(f32::NAN), 0.0); - assert_eq!(clamp_dim(9.0), BG_DIM_MAX); - assert_eq!( - dimmed([255, 255, 255], 9.0, [0, 0, 0]), - [13, 13, 13], - "夹到 0.95 → 只剩 5% 原色" - ); -} - -#[test] -fn grid_is_bounded_so_the_mosaic_cannot_explode() { - // 抓得住:超宽/超高视口下格数被上限截住(渲染成本有界),且至少 1×1。 - // 抓不住:这个上限在真机上到底花多少毫秒(没做基准)。 - assert_eq!(grid((1180.0, 760.0)), (33, 22)); - let (cols, rows) = grid((100_000.0, 100_000.0)); - assert_eq!((cols, rows), (MAX_COLS, MAX_ROWS)); - assert_eq!(grid((0.0, 0.0)), (1, 1)); + cleanup(&path); } #[test] fn a_missing_or_broken_file_reports_a_readable_reason() { // 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。 - // 两条都必须是 **Err**(不许静默回一个默认色),且 describe() 里不含 panic 文案。 - // 抓不住:本 build 只带 png 编解码器,jpeg/bmp 会在 Undecodable 里出现 - // (「Unsigned format」/「The image format ... is not supported」)。 - let missing = temp_png("missing"); + // 都必须是 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_png("broken"); - std::fs::write(&broken, b"this is not a png").expect("写坏文件"); + 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); @@ -179,22 +86,39 @@ fn a_missing_or_broken_file_reports_a_readable_reason() { } #[test] -fn a_real_png_decodes_and_is_scaled_down_to_the_sample_budget() { - // 抓得住:真磁盘 PNG 能读出来、原图尺寸保留、缩略图边长不超过 MAX_EDGE。 - // 抓不住:解码后的颜色是否与它在屏幕上一致(色彩管理不在本 mission)。 - let path = temp_png("ok"); - let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90])); - source.save(&path).expect("写 png"); +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 重导出)"); +} - let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读"); - assert_eq!(image.source, (300, 20), "原图尺寸按图头"); - assert!( - image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE, - "缩略图边 {}×{} 超预算", - image.width, - image.height - ); - assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize); - assert_eq!(sample(&image, (0.0, 0.0)), [12, 200, 90], "纯色图取样不变"); - cleanup(&path); +#[test] +fn tile_geometry_counts_enough_tiles_and_stays_within_budget() { + // 抓得住:①图比视口小时按原尺寸铺够(ceil);②图比视口大时是 1×1(= 原尺寸裁切); + // ③总块数永远 ≤ MAX_TILES(小图不会拼出上万个控件)。 + // 抓不住:铺出来的缝(渲染层的事,看截图)。 + let small = tiles((200, 200), (640.0, 480.0)); + assert_eq!((small.cols, small.rows), (4, 3)); + assert_eq!(small.count(), 12); + + let big = tiles((1600, 900), (1180.0, 760.0)); + assert_eq!((big.cols, big.rows), (1, 1), "图比视口大 ⇒ 以原尺寸裁切"); + + let tiny = tiles((16, 16), (3840.0, 2160.0)); + assert!(tiny.count() <= MAX_TILES, "{tiny:?} 超预算"); + assert!(tiny.cols >= 1 && tiny.rows >= 1, "至少铺一块"); +} + +/// 编译期钉住:填充档解析走 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); } diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 9f486db..0f217e6 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -20,20 +20,21 @@ //! | [`background`] | 背景图解码、三种填充几何、暗化、马赛克取样 | 读盘一次(`load`) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | -//! | `section` | 设置页「外观」分组(真控件)+ 背景层 Element | 无 | +//! | `section` | 设置页「外观」分组(真控件) | 无 | +//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 | //! //! ## 偏离登记(D30 要求逐条写清) //! //! | # | 偏离 | 为什么 | 什么时候收口 | //! |---|---|---|---| -//! | 1 | 背景图按 **36dp 网格马赛克**画,不是逐像素直出 | iced 0.14 的 `widget::image` 要 `iced/image` feature,本 crate 的 `Cargo.toml` 不在本 mission 的 scope。填充几何 / 暗化 / 取色全是真值,只有「每格一个颜色」是降级 | 开 `iced/image` feature 的 mission(一行 Cargo.toml) | -//! | 2 | 面板 α 只作用到**外壳层**(根容器 / 标题栏 / 导航轨)与本模块画的卡片 | `pages/frame/pieces.rs` 的 `card()` 是其它三页共用的实色卡片,不在本 mission 的 scope。于是「有背景图 + 面板 α<1」时,实例/下载/日志页的卡片仍是实色块 | 把 `card()` 的底色改成「面板层」(一个函数,改完三页同时生效) | +//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:`iced/image` 打开,真图直出(`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 编解码器 | `crates/app-iced` 的 `image` 只开了 `png`(现有截图通道用),`Cargo.toml` 在 scope 外。jpeg/bmp 会走 `LoadError::Undecodable` 并给出可读提示 | 把 `image` 的 `jpeg`/`bmp` feature 打开(或改走 `unlucky-palette-extract`) | +//! | 6 | ~~背景图只支持 png~~ → **r2 已销**:`image` 补了 `jpeg`/`bmp`,单测与截图都用 `.jpg` 举证 | 同上 | ✅ | //! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dimmed`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) | -//! | 8 | 页头副标题 / 分组标签这类**直接压在画布上**的次要文字,在 `panel_alpha < 1` 时对比度下降 | 它们由 `pages/frame.rs` 的页头画,而那个文件不在本 mission 的 scope;真正该改的是「页头带显示面板底色」 | 把页头带改成面板层(`frame::header_bar` 一处) | +//! | 8 | ~~页头文字压在画布上~~ → **r2 已销**:`frame::header_bar` 加了面板底色 | M93 r2 扩了 `pages/frame.rs` 的 scope | ✅ | //! //! ## 取证 //! @@ -45,10 +46,12 @@ pub mod alpha; pub mod background; mod palette; +mod picture; mod section; mod state; -pub use section::{layer, panel_color, section}; +pub use picture::layer; +pub use section::section; pub use state::State; /// 设置页「外观」分组在左列里的下标(0 = General,1 = Appearance …)。 diff --git a/crates/app-iced/src/pages/appearance/picture.rs b/crates/app-iced/src/pages/appearance/picture.rs new file mode 100644 index 0000000..ea859e1 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/picture.rs @@ -0,0 +1,104 @@ +//! 背景层的**像素**:真图直出(`Cover` / `Contain` / `Tile`)+ 暗化叠层。 +//! +//! 与 [`super::section`] 拆开只为单文件 ≤400 行的纪律;对外入口仍是 +//! `pages::appearance::layer`(本文件 [`layer`] 的再导出)。 +//! +//! ## 为什么是「真图」 +//! +//! M93 r1 走的是「36dp 网格马赛克」(那时 iced 的 `image` feature 没开)。r2 打开后改成本文件: +//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做),`Tile` 用 +//! [`background::tiles`] 算行列数后真的铺 N 个图块(`ContentFit::None` = 原尺寸)。 +//! +//! 暗化不是逐像素改色,而是**盖一层「版面底色 @ α=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> { + 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::MAX_TILES`])。 +fn tiled( + handle: image::Handle, + source: (u32, u32), + viewport: (f32, f32), +) -> Element<'static, Message> { + let tiles = background::tiles(source, viewport); + let (width, height) = (source.0.max(1) as f32, source.1.max(1) as f32); + + let rows = (0..tiles.rows) + .map(|_| { + let cells = (0..tiles.cols) + .map(|_| -> Element<'static, Message> { + image_widget(handle.clone()) + .content_fit(ContentFit::None) + .width(Length::Fixed(width)) + .height(Length::Fixed(height)) + .into() + }) + .collect::>(); + iced::widget::row(cells).into() + }) + .collect::>(); + + column(rows).width(Length::Fill).height(Length::Fill).into() +} diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index 57c5621..e8b00af 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -44,56 +44,6 @@ use super::background::BG_DIM_MAX; use super::state::State; use super::Change; -/// 一处面板/卡片表面色:令牌色 × 生效的面板 α。 -/// -/// `alpha == 1.0` 时返回**逐字节相同**的令牌色(`scale_alpha(1.0)` 不引入误差),所以没开面板 -/// 透明度时不会动到任何既有验收图的像素。 -#[must_use] -pub fn panel_color(ui: &Ui, layer: MaterialLayer, alpha: f32) -> Color { - let alpha = if alpha.is_finite() { - alpha.clamp(0.0, 1.0) - } else { - 1.0 - }; - ui.layer(layer).scale_alpha(alpha) -} - -/// 背景层(铺满整窗、垫在最下面的马赛克)。`None` = 没有背景图(调用方就不叠这一层)。 -#[must_use] -pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option> { - let fallback = ui.layer(MaterialLayer::Canvas); - let mosaic = state.mosaic(viewport, rgb8(fallback))?; - - let rows = (0..mosaic.rows) - .map(|row| { - let cells = (0..mosaic.cols) - .map(|col| cell(mosaic.at(row, col), fallback)) - .collect::>(); - iced::widget::row(cells) - .width(Length::Fill) - .height(Length::Fill) - .into() - }) - .collect::>(); - - Some(column(rows).width(Length::Fill).height(Length::Fill).into()) -} - -/// iced 颜色 → 8 bit RGB(暗化的「底色」方向)。 -fn rgb8(color: Color) -> [u8; 3] { - let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; - [channel(color.r), channel(color.g), channel(color.b)] -} - -fn cell<'a>(color: Option<[u8; 3]>, fallback: Color) -> Element<'a, Message> { - let color = color.map_or(fallback, |[r, g, b]| Color::from_rgb8(r, g, b)); - container(iced::widget::space::horizontal()) - .width(Length::Fill) - .height(Length::Fill) - .style(move |_theme| frame::filled(color)) - .into() -} - /// 「外观」分组整块(一张卡)。 pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill); @@ -250,16 +200,13 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { let resolved = format!("Colours: {}", state.palette_level()); rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); - card_with_panel_alpha(ui, state, rows) + card(ui, rows) } -fn card_with_panel_alpha<'a>( - ui: &'a Ui, - state: &State, - content: impl Into>, -) -> Element<'a, Message> { +/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。 +fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { let spec = Component::OutlinedCard.spec(); - let background = panel_color(ui, MaterialLayer::Card, state.effective_panel_alpha()); + let background = ui.layer(MaterialLayer::Card); container(content) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index 51fae76..cfd5c00 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -22,7 +22,7 @@ use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs, BgFill, PaletteS use crate::Backend; use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque}; -use super::background::{self, Image, Mosaic}; +use super::background::{self, Image}; use super::palette::{self, Resolved, Source}; /// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。 @@ -166,24 +166,6 @@ impl State { self.image.as_ref() } - /// 算一次马赛克(渲染层每帧调一次;纯函数,成本 = 格数)。 - /// - /// `toward` 是当前主题的面板底色(暗化往它退,见 [`background::dimmed`])。 - #[must_use] - pub fn mosaic(&self, viewport: (f32, f32), toward: [u8; 3]) -> Option { - let image = self.image.as_ref()?; - if !self.bg_enabled { - return None; - } - Some(background::mosaic( - image, - self.bg_fill, - viewport, - self.bg_dim, - toward, - )) - } - /// 取色得到的方案(`None` = 色卡档,继续用令牌 baseline)。 /// /// 为什么色卡档不换 scheme:`resolve_source` 的色卡档走的是 `DEFAULT_CARD`(天蓝卡), diff --git a/crates/app-iced/src/pages/frame.rs b/crates/app-iced/src/pages/frame.rs index 2348d1f..1bf8623 100644 --- a/crates/app-iced/src/pages/frame.rs +++ b/crates/app-iced/src/pages/frame.rs @@ -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() } diff --git a/crates/app-iced/src/pages/frame/pieces.rs b/crates/app-iced/src/pages/frame/pieces.rs index 7f08cd6..1a6b0f0 100644 --- a/crates/app-iced/src/pages/frame/pieces.rs +++ b/crates/app-iced/src/pages/frame/pieces.rs @@ -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() diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index f9179ae..122366c 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -34,7 +34,6 @@ use unlucky_design::typography::TypeRole; use crate::app::Message; use crate::cli::ForcedState; use crate::icons; -use crate::pages::appearance::panel_color; use crate::theme::Ui; /// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。 @@ -52,8 +51,8 @@ use crate::theme::Ui; /// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距), /// 两边的账不会算串。 /// -/// M93 起面板 α 是**外壳层**的属性(根容器 / 标题栏 / 导航轨的底色按它乘 alpha):要背景层的 -/// 调用方走 [`view_with_background`]。 +/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer` +/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。 pub fn view_with_content<'a>( ui: &'a Ui, selected: NavItem, @@ -61,7 +60,7 @@ pub fn view_with_content<'a>( content: Element<'a, Message>, forced: Option, ) -> Element<'a, Message> { - view_with_background(ui, selected, app_title, None, 1.0, content, forced) + view_with_background(ui, selected, app_title, None, content, forced) } /// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的背景图马赛克)。 @@ -73,20 +72,19 @@ pub fn view_with_background<'a>( selected: NavItem, app_title: &'a str, background: Option>, - panel_alpha: f32, content: Element<'a, Message>, forced: Option, ) -> Element<'a, Message> { let work_area = column![ - titlebar(ui, app_title, forced, panel_alpha), - row![nav::rail(ui, selected, forced, panel_alpha), content] + titlebar(ui, app_title, forced), + row![nav::rail(ui, selected, forced), content] .width(Length::Fill) .height(Length::Fill), ] .width(Length::Fill) .height(Length::Fill); - window_frame(ui, background, panel_alpha, work_area.into()) + window_frame(ui, background, work_area.into()) } /// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。 @@ -112,12 +110,9 @@ pub fn view<'a>( /// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。 /// -/// 面板 α 落在两处:根容器底色(= 整窗 Canvas 层)与标题栏 / 导航轨底色。`panel_alpha == 1.0` -/// 时颜色逐字节不变,所以没开面板透明度时不动既有验收图。 fn window_frame<'a>( ui: &'a Ui, background: Option>, - panel_alpha: f32, work_area: Element<'a, Message>, ) -> Element<'a, Message> { // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 @@ -150,7 +145,10 @@ fn window_frame<'a>( .height(Length::Fill) .align_y(Alignment::End); - // 背景在最底层:面板 α < 1 时从它的空隙里透出来(M93 / D34)。 + // 背景在最底层(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], @@ -159,7 +157,13 @@ fn window_frame<'a>( container(shell) .width(Length::Fill) .height(Length::Fill) - .style(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha))) + .style(move |_theme| { + if has_background { + container::Style::default() + } else { + filled(ui.layer(MaterialLayer::Canvas)) + } + }) .into() } @@ -182,12 +186,7 @@ fn resize_edge<'a>( // ---------------------------------------------------------------- 标题栏 -fn titlebar<'a>( - ui: &'a Ui, - title: &'a str, - forced: Option, - panel_alpha: f32, -) -> Element<'a, Message> { +fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option) -> Element<'a, Message> { let metrics = ui.metrics(); // 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段) @@ -219,7 +218,7 @@ fn titlebar<'a>( container(bar) .width(Length::Fill) .height(metrics.titlebar_height) - .style(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha))) + .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() } diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index b785972..892bd5e 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -25,7 +25,6 @@ use unlucky_design::typography::TypeRole; use crate::app::Message; use crate::cli::ForcedState; use crate::icons; -use crate::pages::appearance::panel_color; use crate::shell::{filled, label, overridden}; use crate::theme::Ui; @@ -82,7 +81,6 @@ pub(super) fn rail<'a>( ui: &'a Ui, selected: NavItem, forced: Option, - panel_alpha: f32, ) -> Element<'a, Message> { let metrics = ui.metrics(); let indicator = indicator_size(metrics); @@ -95,12 +93,12 @@ pub(super) fn rail<'a>( // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 // spacing(4)再加一档,实测变 28(视觉复审 P3-5)。 // - // 轨底色也吃面板 α(M93):导航轨是「面板层」的一部分,背景图应该能透过它。 + // 轨底色与其它面板同源(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(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha))) + .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() } diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index 071f253..000c62b 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -1,23 +1,18 @@ //! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 //! -//! ## 边界(M61 `design-令牌约定.md` §0 的落地) +//! ## 边界(M61 `design-令牌约定.md` §0) //! -//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`) -//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs` -//! 会扫 `src/**` 做守门。 +//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方:组件层只调 [`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 的同一结论)。所以: +//! iced 的 `Palette` 只有 6 个颜色,M3 有 49 个角色(M60 Q5 + M61 §9-0)。所以真源是本模块持有 +//! 的 [`Scheme`],iced 的 `Theme` 只用来「换名字」——名字变了 iced 才重算受主题影响的缓存, +//! `palette()` 只给 devtools 看,控件外观由各自的 `Style` 注入。 //! -//! - **真源**是本模块持有的 [`Scheme`](49 角色); -//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存 -//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。 -//! -//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写), -//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、 -//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。 +//! `success`/`warning` 在 M3 里没有对应角色,按语义最近的现成角色映射 +//! (`success → Secondary`、`warning → Tertiary`),任何控件都不要读这两个槽位。 use iced::border::Radius; use iced::theme::Palette; @@ -29,10 +24,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 +40,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,27 +65,26 @@ impl Ui { platform, metrics: LayoutMetrics::DESKTOP, families, + panel_alpha: 1.0, } } - /// 当前深浅模式。 + /// 当前深浅。 pub fn flavor(&self) -> SchemeFlavor { self.scheme.flavor() } - /// 用**已解析的方案**构造求值器(D38 的落点:背景图 / 桌面壁纸取色 → 49 个角色换掉 baseline)。 + /// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉 baseline)。 /// - /// 只是**追加**的构造入口:既有的 [`Ui::new`] / [`Ui::with_platform`] 一字未动,默认仍是 - /// [`Scheme::baseline`]。调用方(`app` 侧)拿到 `unlucky_design::palette::resolve_source` 的 - /// 结果后走这里,于是「配色来源」真的能改颜色,而不只是一条日志。 - /// - /// 平台与骨架尺寸仍按当前环境求值——方案只带颜色,不带平台。 + /// 追加式入口:`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, } } @@ -121,25 +113,44 @@ impl Ui { to_iced(self.scheme.get(role)) } - /// 材质层(Canvas/Card/Overlay)→ 容器色。 + /// 材质层(Canvas/Card/Overlay)→ 容器色,**已乘上面板 α**(M93 的唯一来源)。 + /// + /// 为什么 α 施加在这里:面板 α 是**表面色**的属性,而全仓所有表面色都从这个函数出去 + /// (外壳的根容器/标题栏/导航轨、`pages::frame::card`、空态图标块……),乘一次就让 + /// 四页 + 外壳自动同源,调用点没有机会「各处自乘」。`α = 1.0` 时逐字节等于原色。 pub fn layer(&self, layer: MaterialLayer) -> Color { self.color(layer.surface_role()) + .scale_alpha(self.panel_alpha) } - /// 状态层叠在容器色上,返回**不透明**结果。 - /// - /// 为什么不用带 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), @@ -171,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, @@ -196,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), @@ -348,12 +359,8 @@ mod tests { assert_ne!(hovered, ui.color(Role::Surface)); } - /// D38:配色来源解析出来的方案必须**真的换色**(M93)。 - /// - /// 抓得住:①`from_scheme` 用的是传进来的方案,不是 baseline(配一张蓝底的取色方案, - /// primary 与 baseline 不同);②默认构造(`new` / `with_platform`)逐字段等于 baseline - /// ——即「不传 scheme 时行为不变」,这条是防回流的那一半。 - /// 抓不住:取色结果好不好看、设置页是否真的切过去(前者是设计判断,后者由 app 侧日志举证)。 + /// D38:配色方案必须真的换色;默认构造逐字段等于 baseline(防回流的那一半)。 + /// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。 #[test] fn a_resolved_scheme_replaces_the_baseline_colours() { use unlucky_design::palette::{resolve_source, CardKey, ImageInput}; diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index 562dfb5..a29d8c8 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -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; From 5c40c64b5f92f6a5f922a3f4b598c290889b95db Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 18:18:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(app-iced):=20M93=20r3=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=A4=8D=E5=AE=A1=20r1=20=E5=9B=9B=E6=9D=A1?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=EF=BC=88Canvas=20=E4=B8=8D=E5=90=83=20=CE=B1?= =?UTF-8?q?=20/=20=E5=B9=B3=E9=93=BA=E8=A1=A5=E7=BC=9D=20/=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E4=B8=8A=E7=A7=BB=20/=20=E6=96=87=E6=A1=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先 `git rebase main`(M92 `eae7ae0` 已在主线;`Cargo.lock` 与 `crates/app-iced/Cargo.toml` 自动合并,`image-without-codecs` 与 M92 的 `unlucky-i18n` 两段都在)。 P2-2(口径违约)Canvas 层不许吃面板 α - `Ui::layer()` 改成按令牌 `MaterialLayer::allows_translucency()` 判定:Canvas 恒不透明, 只有 Card / Overlay 乘 `panel_alpha`。判据取令牌的不变量,三个调用点(标题栏 / 导航轨 / 页头带)没有各自特判。 - 守门:`the_canvas_layer_stays_opaque_while_panels_take_the_alpha`(α=0.55 时 Canvas.a==1.0、 Card/Overlay.a==0.55;CPU 档连卡片都是 1.0)。 P2-1 平铺不许留缝 - `background::tiles()` 超 `MAX_TILES` 时改成**放大图块步长**(`Tiles { cols, rows, scale }`, 按 1.15 逐级放大到块数进预算),而不是减列/减行;`MAX_TILE_SCALE` 提到 1024 只是安全阀 (1×1 的图在 1180×760 上需要 ~118 倍才能用 64 块盖满)。 - `picture::tiled()` 用 `ContentFit::Fill` + 格子尺寸 = `源尺寸 × scale`(同比例,不变形): `None` 会按图片原生尺寸画、比格子小就留空,正是那道缝。 - 守门:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 120×120 与 1×1, 断言 `covered >= viewport` 且 `count <= MAX_TILES`);背景层底下本来就有不透明画布底色兜底。 P2-3 诊断上移到首屏 - 诊断块(读盘失败红字 / CPU 降级 / 配色回落 / 当前配色层级)从卡片末尾移到「背景图路径」 紧下方:1180×760 首屏可见(截图 `r4-broken-path.png` 里红字在 y≈594)。 P2-4 文档对齐 - `transparency.rs`:`WS_EX_LAYERED` 的归属改成「本文件的 `ensure_layered` 负责」, 窗口创建侧只拿句柄;`platform/mod.rs` / `lib.rs` 的模块表同步。 - 删掉「马赛克取样」这类已删实现的现在时描述(`background` / `picture` / `app` 的注释), 保留一句 r1→r2 的历史说明;`app/tests/appearance.rs` 的测试意图跟着改。 门禁(工作区级,最终树):`cargo fmt --all --check` 干净; `cargo clippy --workspace --all-targets -- -D warnings` 无输出; `cargo test --workspace` 全绿(app-iced 299 lib + 6 集成守门、presenter 126、其余 crate 全过)。 出图:`r4-canvas-opaque-panel55{,-dark}.png`(Canvas 不透明 + 面板透底)、 `r4-tile-120.png`(120×120 平铺右缘无裸白)、`r4-broken-path.png`(诊断首屏可见)。 --- crates/app-iced/src/app/state.rs | 2 +- crates/app-iced/src/app/tests/appearance.rs | 47 ++++++++++- crates/app-iced/src/app/update.rs | 2 +- crates/app-iced/src/lib.rs | 2 +- .../src/pages/appearance/background.rs | 82 +++++++++++++------ .../src/pages/appearance/background/tests.rs | 45 +++++++--- crates/app-iced/src/pages/appearance/mod.rs | 2 +- .../app-iced/src/pages/appearance/picture.rs | 7 +- .../app-iced/src/pages/appearance/section.rs | 33 ++++---- crates/app-iced/src/platform/mod.rs | 2 +- .../src/platform/win32/transparency.rs | 8 +- crates/app-iced/src/shell/mod.rs | 2 +- crates/app-iced/src/theme.rs | 32 ++++---- 13 files changed, 181 insertions(+), 85 deletions(-) diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 5f44ca1..6ab4ce7 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -110,7 +110,7 @@ pub struct App { pub(super) refreshes: u64, /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 pub(super) auto_selected: bool, - /// 窗口高度(背景图马赛克的视口判据;宽度在 `width`)。 + /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 pub(super) height: f32, /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 pub(super) appearance: pages::appearance::State, diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index 1b56eb2..ac2b8df 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -144,9 +144,9 @@ fn a_broken_background_path_falls_back_silently_but_says_why() { ); } -/// 抓得住:真 PNG → 背景真的生效(有马赛克、有格数、配色走「背景图取色」这一级)。 -/// 抓不住:马赛克的观感(36dp 网格是已登记的降级),以及填充三种档的视觉差异(几何由 -/// `background` 的纯函数测试守)。 +/// 抓得住:真 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())); @@ -211,6 +211,47 @@ fn the_settings_page_renders_the_real_appearance_group() { 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)、最后一档不透明(不给字节)。 /// 这就是「拖动 → 实际 α」的链条在数值上的等价物;真机拖动的那一次由二进制日志举证。 diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index dcfec9b..9cd355d 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -87,7 +87,7 @@ 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(); diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index 66f6ac8..342fa77 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -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` 落点 | 窗口创建 / 业务开关 | //! //! ## 消费纪律 //! diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index 8023c6d..29eace1 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -1,11 +1,10 @@ //! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。 //! -//! ## 真图直出(M93 r2 修订) +//! ## 真图(M93 r2 起) //! -//! r1 因为 iced 的 `image` feature 没开,只能把像素画成 36dp 网格的马赛克。r2 打开了 -//! `iced/image`(`crates/app-iced/Cargo.toml`),于是**真图直出**:填充由 iced 的 `ContentFit` -//! 负责(`Cover` / `Contain`),平铺由本模块的 [`tiles`] 算网格、渲染层真的铺 N 个图块。 -//! 马赛克那条路已删除(不再有「每格一个颜色」的降级)。 +//! 渲染走 iced 的 `image`(`crates/app-iced/Cargo.toml` 的 `image-without-codecs`):`Cover` / +//! `Contain` 交给 `ContentFit`,`Tile` 用本模块的 [`tiles`] 算网格后由 `picture` 真铺图块。 +//! 本模块只负责「解码一次(取色 + 诊断 + 尺寸)」与「网格/暗化的纯数学」。 //! //! | 档 | 渲染 | 露底色 | //! |---|---|---| @@ -36,10 +35,19 @@ pub const MAX_EDGE: u32 = 256; /// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。 pub const MAX_SOURCE_EDGE: u32 = 16_384; -/// 平铺档一次最多铺多少个图块(列 × 行)。超了就不铺——避免一张 8×8 的小图在 4K 上拼出 -/// 上万个子控件。 +/// 平铺档一次最多铺多少个图块(列 × 行)。超了**放大图块**而不是少铺——见 [`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 { @@ -165,13 +173,15 @@ pub fn dim_alpha(dim: f32) -> f32 { } } -/// 平铺档的网格(列 × 行)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 平铺档的网格(列 × 行 × 图块缩放)。 +#[derive(Debug, Clone, Copy, PartialEq)] pub struct Tiles { /// 列数。 pub cols: u32, /// 行数。 pub rows: u32, + /// 单块图相对原尺寸的缩放(`1.0` = 原尺寸;超预算时**只放大图块**,不减少覆盖)。 + pub scale: f32, } impl Tiles { @@ -180,33 +190,51 @@ impl Tiles { 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;总块数不超过 [`MAX_TILES`])。 +/// 平铺档的网格(原图 1 像素 = 1 dp)。 /// -/// 图比视口大时是 `1×1`(等于「以原尺寸裁切」),与小图平铺是同一套算式。 +/// **硬不变量:`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 width = source.0.max(1) as f32; - let height = source.1.max(1) as f32; - let cols = ceil_div(viewport.0.max(1.0), width); - let rows = ceil_div(viewport.1.max(1.0), height); - - // 超预算时按比例退:先保列再保行,至少留 1×1(否则什么都画不出来)。 let mut tiles = Tiles { - cols: cols.max(1), - rows: rows.max(1), + cols: 1, + rows: 1, + scale: 1.0, }; - while tiles.count() > MAX_TILES { - if tiles.cols > 1 { - tiles.cols -= 1; - } else if tiles.rows > 1 { - tiles.rows -= 1; - } else { - break; + + 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); } - tiles } fn ceil_div(value: f32, step: f32) -> u32 { diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index 0c552ec..ec1c626 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -97,21 +97,44 @@ fn dim_is_clamped_to_the_supported_range() { assert_eq!(BG_DIM_MAX, 0.95, "与 core 同源(presenter 重导出)"); } +/// **P2-1 的守门**:任何尺寸/视口组合下,平铺网格铺出来的覆盖宽高都必须 ≥ 视口(不许留缝), +/// 同时块数不超预算(不留缝的代价只能是「放大图块」,不能是「少铺」)。 +/// +/// 抓得住:谁把 `tiles()` 改回「超预算就减列」——120×120 @ 1180×760 那条会立刻红 +/// (r1 实测右缘裸白 100px)。 +/// 抓不住:渲染器把单块图铺进格子时的实际像素对齐(有没有半像素缝由截图看)。 #[test] -fn tile_geometry_counts_enough_tiles_and_stays_within_budget() { - // 抓得住:①图比视口小时按原尺寸铺够(ceil);②图比视口大时是 1×1(= 原尺寸裁切); - // ③总块数永远 ≤ MAX_TILES(小图不会拼出上万个控件)。 - // 抓不住:铺出来的缝(渲染层的事,看截图)。 - let small = tiles((200, 200), (640.0, 480.0)); - assert_eq!((small.cols, small.rows), (4, 3)); - assert_eq!(small.count(), 12); +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), (1, 1), "图比视口大 ⇒ 以原尺寸裁切"); + assert_eq!((big.cols, big.rows, big.scale), (1, 1, 1.0)); - let tiny = tiles((16, 16), (3840.0, 2160.0)); - assert!(tiny.count() <= MAX_TILES, "{tiny:?} 超预算"); - assert!(tiny.cols >= 1 && tiny.rows >= 1, "至少铺一块"); + // 小图按原尺寸铺够(不放大时)。 + 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`)。 diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 0f217e6..cdb85da 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -17,7 +17,7 @@ //! | 文件 | 是什么 | 有没有 IO | //! |---|---|---| //! | [`alpha`] | 两层 α 的钳位链、CPU 档降级、滑块映射、下发字节 | 无(纯函数) | -//! | [`background`] | 背景图解码、三种填充几何、暗化、马赛克取样 | 读盘一次(`load`) | +//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) | //! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) | //! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` | //! | `section` | 设置页「外观」分组(真控件) | 无 | diff --git a/crates/app-iced/src/pages/appearance/picture.rs b/crates/app-iced/src/pages/appearance/picture.rs index ea859e1..646ea9d 100644 --- a/crates/app-iced/src/pages/appearance/picture.rs +++ b/crates/app-iced/src/pages/appearance/picture.rs @@ -3,11 +3,10 @@ //! 与 [`super::section`] 拆开只为单文件 ≤400 行的纪律;对外入口仍是 //! `pages::appearance::layer`(本文件 [`layer`] 的再导出)。 //! -//! ## 为什么是「真图」 +//! ## 三种填充 //! -//! M93 r1 走的是「36dp 网格马赛克」(那时 iced 的 `image` feature 没开)。r2 打开后改成本文件: -//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做),`Tile` 用 -//! [`background::tiles`] 算行列数后真的铺 N 个图块(`ContentFit::None` = 原尺寸)。 +//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做);`Tile` 用 +//! [`background::tiles`] 算行列后铺 N 个图块(单块尺寸 = 原尺寸 × `tiles.scale`)。 //! //! 暗化不是逐像素改色,而是**盖一层「版面底色 @ α=dim」**:图不透明时它与「向底色插值」 //! 数学等价([`background::dim_alpha`] 有推导),而普通 alpha 混合 CPU/GPU 两档都支持。 diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index e8b00af..671193f 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -120,7 +120,21 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { .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.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", @@ -146,7 +160,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ⑥ 暗化 + // ⑦ 暗化 rows = rows.push(slider_row( ui, "Dim", @@ -161,7 +175,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ), )); - // ⑦ 配色来源 + // ⑧ 配色来源 rows = rows.push(choice_row( ui, "Colour source", @@ -187,19 +201,6 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { ], )); - // ⑧ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默** - for note in state.diagnostics() { - rows = rows.push(note_line(ui, note, ui.color(Role::Error))); - } - if let Some(note) = state.backend_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - if let Some(note) = state.palette_note() { - rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); - } - let resolved = format!("Colours: {}", state.palette_level()); - rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant))); - card(ui, rows) } diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index 0689fbe..3c37f98 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -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 档也允许 diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 65821cf..2985ab8 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -11,8 +11,12 @@ //! ## 只做 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 落点里,门禁仍是「集合相等」。 //! //! ## 非 Windows //! diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 122366c..fff52d9 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -63,7 +63,7 @@ pub fn view_with_content<'a>( view_with_background(ui, selected, app_title, None, content, forced) } -/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的背景图马赛克)。 +/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。 /// /// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!` /// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。 diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index 000c62b..4d512db 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -1,18 +1,13 @@ //! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 //! -//! ## 边界(M61 `design-令牌约定.md` §0) -//! -//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方:组件层只调 [`Ui`], -//! 绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**` 守门)。 +//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方(M61 §0):组件层只调 +//! [`Ui`],绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**`)。 //! //! ## 为什么不用 `iced::theme::Base` 承载 49 个角色 //! -//! iced 的 `Palette` 只有 6 个颜色,M3 有 49 个角色(M60 Q5 + M61 §9-0)。所以真源是本模块持有 -//! 的 [`Scheme`],iced 的 `Theme` 只用来「换名字」——名字变了 iced 才重算受主题影响的缓存, -//! `palette()` 只给 devtools 看,控件外观由各自的 `Style` 注入。 -//! -//! `success`/`warning` 在 M3 里没有对应角色,按语义最近的现成角色映射 -//! (`success → Secondary`、`warning → Tertiary`),任何控件都不要读这两个槽位。 +//! iced 的 `Palette` 只有 6 色,M3 有 49 个角色(M60 Q5):真源是本模块的 [`Scheme`],`Theme` +//! 只用来「换名字」(名字变了 iced 才重算缓存),控件外观由各自的 `Style` 注入。 +//! `success`/`warning` 没有对应角色,按邻近语义映射(→ `Secondary` / `Tertiary`),不许读。 use iced::border::Radius; use iced::theme::Palette; @@ -113,14 +108,19 @@ impl Ui { to_iced(self.scheme.get(role)) } - /// 材质层(Canvas/Card/Overlay)→ 容器色,**已乘上面板 α**(M93 的唯一来源)。 + /// 材质层(Canvas/Card/Overlay)→ 容器色;**只有令牌允许半透明的层才乘面板 α**(M93 唯一来源)。 /// - /// 为什么 α 施加在这里:面板 α 是**表面色**的属性,而全仓所有表面色都从这个函数出去 - /// (外壳的根容器/标题栏/导航轨、`pages::frame::card`、空态图标块……),乘一次就让 - /// 四页 + 外壳自动同源,调用点没有机会「各处自乘」。`α = 1.0` 时逐字节等于原色。 + /// α 乘在这里而不是调用点:所有表面色都从这里出去(根容器/标题栏/导航轨/页头带/卡片), + /// 一次同源、调用点无从「各自自乘」。Canvas 层恒不透明(r1 P2-2):令牌 + /// [`MaterialLayer::allows_translucency`] 写死 `Canvas => false`(窗口底),而三个窗口键与 + /// 导航轨、页头带都取 Canvas——给它们乘 α 会让文字直接压在背景照片上(α=0.55 实测不可读)。 pub fn layer(&self, layer: MaterialLayer) -> Color { - self.color(layer.surface_role()) - .scale_alpha(self.panel_alpha) + let base = self.color(layer.surface_role()); + if layer.allows_translucency() { + base.scale_alpha(self.panel_alpha) + } else { + base + } } /// 当前面板 α(CPU 档由 `app` 侧强制成 1.0)。 From da2da4cd9b755312ccb84b882f958fcab641c71f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 18:46:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(app-iced,presenter):=20M93=20r4=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20Float=20=E9=94=AE=E5=86=99=E4=B8=8D?= =?UTF-8?q?=E5=9B=9E=20/=20prefs.rs=20=E8=B6=85=E9=99=90=20/=20CPU=20?= =?UTF-8?q?=E6=A1=A3=E5=88=A4=E5=AE=9A=20/=20=E5=B9=B3=E9=93=BA=E9=80=80?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审(代码)r1 的两条 P1 + 两条必修。 P1-1 `SetSetting` 写不回 Float 键(整窗透明度重启回旧值) - `presenter/src/host/settings.rs::parse_value` 改成**按 schema 的 `ValueKind` 取类型**, 不再硬编码 Int/Bool 键名。历史:`appearance.window_opacity`(Float)被当 String 送进 `store.set` → schema 的 `as_f64()` 返回 None → `InvalidValue` → **`save()` 根本没执行**, α 只在内存生效、重启回旧值、每次拖动弹一条 Error toast。 - 测试(presenter 层,`host/settings_tests.rs`): ① `float_key_round_trips_through_set_setting`:set 0.8 → **新开 store(= 重启)** 读回 0.8; ② `every_float_key_in_the_schema_can_be_written_back`:遍历 schema 里**所有** `ValueKind::Float` 的键逐个写回并读回(集合级守门,下次新增 Float 键漏接会立刻红)。 P1-2 `presenter/src/prefs.rs` 404 行 → 拆测试 - `#[cfg(test)] mod tests` 整体挪到 `crates/presenter/src/prefs/tests.rs`,结构体与 re-export 一字未动。`wc -l`:**404 → 315**(tests.rs 90 行)。 P2-2 CPU 档判定只在 `--renderer=cpu` 时成立 - 新增 `app/backend.rs`:`effective_backend()` 把 `probe::apply` 写下的 `ICED_BACKEND` 候选列表翻回档位(`tiny-skia` / `tiny-skia,wgpu` → CPU;`wgpu,tiny-skia` → GPU;未设 → 请求值)。 `App::with_presenter` 用它喂 `appearance::State`,于是**默认 Auto 档在没有独显的机器上** 也会走「面板 α / 玻璃强制 1.0」。 - 测试:`every_iced_backend_list_maps_to_a_tier` + `auto_with_a_software_renderer_forces_the_panel_opaque`。 - 实跑:`ICED_BACKEND=tiny-skia`(= probe 在无独显机器上写下的值)→ `[appearance] boot … panel_alpha=1.000 (requested 0.550) backend=cpu`。 P2-1 平铺退化成「一条竖条」——根因不是几何,是**渲染层没吃 scale**(r3 的编辑没落到 `tiled()`) - `picture::tiled()` 原来仍用 `ContentFit::None` + 原图尺寸,所以 `tiles()` 算出的 `scale` 完全没被消费:小图被按 32dp 画在 129dp 的格子里 ⇒ 到处是缝。 - 现在:每块 `源尺寸 × tiles.scale`、`ContentFit::Fill`、行高显式 `Fixed`、整列 `Fill`。 - 诊断:新增 `background::small_tile_note()`(短边 < 200dp 的图在 tile 档下给一句 「scaled up so the window stays covered」),已接进设置页诊断块(首屏可见)。 - 测试:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 32×32 / 16×16 / 1×1, 断言覆盖 ≥ 视口且块数 ≤ 预算)、`small_images_get_a_tile_diagnostic_instead_of_silently_degrading`。 - 出图:`r5-tile-tiny32.png`(32×32 铺满全窗、右缘无裸白、提示可见)、`r5-tile-120.png`、 `r5-tile-big.png`。 顺手(复审 P2-3/P2-4/P2-5) - P2-4:`MAX_SOURCE_EDGE` 16384 → **8192**,并给解码器加 `image::Limits`(宽/高 + `max_alloc` 320MB)作第二道闸;补 `an_over_sized_image_reports_too_large_instead_of_trying_to_decode` (`TooLarge` 这条分支此前无测试)。 - P2-5:`transparency.rs` 补一段「为什么 `LWA_ALPHA` + `WS_EX_LAYERED` 不影响命中测试与 自绘标题栏拖动」(穿透要 `WS_EX_TRANSPARENT` 或 `LWA_COLORKEY`,两者都没用;`window::drag` 走 `WM_NCLBUTTONDOWN(HTCAPTION)`)。 - P2-3:断链 `[background::dimmed]` → `dim_alpha`;`iced/image` → `image-without-codecs`; 测试文档 204 → 205;马赛克残留说明只保留 r1→r2 的历史一句。 - 解码在 UI 线程同步做:**登记欠账**(挪后台线程超出本 mission)。 --- crates/app-iced/src/app/backend.rs | 113 ++++++++++++++++++ crates/app-iced/src/app/mod.rs | 1 + crates/app-iced/src/app/state.rs | 5 +- crates/app-iced/src/app/tests/appearance.rs | 2 +- .../src/pages/appearance/background.rs | 50 +++++++- .../src/pages/appearance/background/tests.rs | 55 ++++++++- crates/app-iced/src/pages/appearance/mod.rs | 4 +- .../app-iced/src/pages/appearance/picture.rs | 18 ++- .../app-iced/src/pages/appearance/section.rs | 8 +- .../src/platform/win32/transparency.rs | 18 +++ crates/presenter/src/host/settings.rs | 59 ++++----- crates/presenter/src/host/settings_tests.rs | 86 +++++++++++++ crates/presenter/src/prefs.rs | 91 +------------- crates/presenter/src/prefs/tests.rs | 90 ++++++++++++++ 14 files changed, 468 insertions(+), 132 deletions(-) create mode 100644 crates/app-iced/src/app/backend.rs create mode 100644 crates/presenter/src/prefs/tests.rs diff --git a/crates/app-iced/src/app/backend.rs b/crates/app-iced/src/app/backend.rs new file mode 100644 index 0000000..3eb9d02 --- /dev/null +++ b/crates/app-iced/src/app/backend.rs @@ -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); + } +} diff --git a/crates/app-iced/src/app/mod.rs b/crates/app-iced/src/app/mod.rs index f0201e8..a58ccbb 100644 --- a/crates/app-iced/src/app/mod.rs +++ b/crates/app-iced/src/app/mod.rs @@ -28,6 +28,7 @@ //! (组件源码里的文档与测试都按这个路径写)。 mod appearance; +mod backend; mod boot; mod message; mod state; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 6ab4ce7..e762648 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -164,10 +164,13 @@ impl App { } 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, - options.backend, + backend_tier, flavor, ); let view = pages::Model::new( diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index ac2b8df..cde13d8 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -46,7 +46,7 @@ fn cleanup(dir: &PathBuf) { std::fs::remove_dir_all(dir).ok(); } -/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 204(钳位链的入口那一段)。 +/// 抓得住:配置里写 `window_opacity=0.8` → 启动读回 0.8 → 下发字节 205(钳位链的入口那一段)。 /// 抓不住:Win32 是否真的收下 204(`transparency` 的单测与本机日志各自举证)。 #[test] fn the_configured_window_alpha_is_read_back_and_turned_into_a_byte() { diff --git a/crates/app-iced/src/pages/appearance/background.rs b/crates/app-iced/src/pages/appearance/background.rs index 29eace1..d706029 100644 --- a/crates/app-iced/src/pages/appearance/background.rs +++ b/crates/app-iced/src/pages/appearance/background.rs @@ -26,14 +26,29 @@ //! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清 //! (偏离登记在 [`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; -/// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。 -pub const MAX_SOURCE_EDGE: u32 = 16_384; +/// 解码**之前**允许的原始边长上限。 +/// +/// 复审 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; @@ -127,7 +142,17 @@ pub fn load(path: &str) -> Result { }); } - let decoded = image::load_from_memory(&bytes) + // 第二道闸(复审 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); @@ -163,6 +188,25 @@ fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> { .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 { + 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 { diff --git a/crates/app-iced/src/pages/appearance/background/tests.rs b/crates/app-iced/src/pages/appearance/background/tests.rs index ec1c626..4e69b88 100644 --- a/crates/app-iced/src/pages/appearance/background/tests.rs +++ b/crates/app-iced/src/pages/appearance/background/tests.rs @@ -2,7 +2,7 @@ //! //! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。 -use super::{dim_alpha, load, tiles, LoadError, BG_DIM_MAX, MAX_TILES}; +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; @@ -145,3 +145,56 @@ fn fill_keys_are_the_schema_vocabulary() { 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()); +} diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index cdb85da..4ce2137 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -27,13 +27,13 @@ //! //! | # | 偏离 | 为什么 | 什么时候收口 | //! |---|---|---|---| -//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:`iced/image` 打开,真图直出(`ContentFit` + 平铺网格) | r1 时 `Cargo.toml` 不在 scope;M93 r2 由 tower 扩 scope 解决 | ✅ | +//! | 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::dimmed`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) | +//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dim_alpha`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) | //! | 8 | ~~页头文字压在画布上~~ → **r2 已销**:`frame::header_bar` 加了面板底色 | M93 r2 扩了 `pages/frame.rs` 的 scope | ✅ | //! //! ## 取证 diff --git a/crates/app-iced/src/pages/appearance/picture.rs b/crates/app-iced/src/pages/appearance/picture.rs index 646ea9d..f4280fa 100644 --- a/crates/app-iced/src/pages/appearance/picture.rs +++ b/crates/app-iced/src/pages/appearance/picture.rs @@ -75,27 +75,37 @@ fn fitted(handle: image::Handle, fit: ContentFit) -> Element<'static, Message> { .into() } -/// `Tile`:原图尺寸平铺(块数上限见 [`background::MAX_TILES`])。 +/// `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) = (source.0.max(1) as f32, source.1.max(1) as f32); + 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::None) + .content_fit(ContentFit::Fill) .width(Length::Fixed(width)) .height(Length::Fixed(height)) .into() }) .collect::>(); - iced::widget::row(cells).into() + iced::widget::row(cells) + .width(Length::Fill) + .height(Length::Fixed(height)) + .into() }) .collect::>(); diff --git a/crates/app-iced/src/pages/appearance/section.rs b/crates/app-iced/src/pages/appearance/section.rs index 671193f..9c87cac 100644 --- a/crates/app-iced/src/pages/appearance/section.rs +++ b/crates/app-iced/src/pages/appearance/section.rs @@ -40,7 +40,7 @@ use super::alpha::{ panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR, }; -use super::background::BG_DIM_MAX; +use super::background::{self, BG_DIM_MAX}; use super::state::State; use super::Change; @@ -128,6 +128,12 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> { 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, ¬e, ui.color(Role::OnSurfaceVariant))); + } if let Some(note) = state.palette_note() { rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant))); } diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 2985ab8..9be8cb5 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -18,6 +18,24 @@ //! 所以窗口创建侧(`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 //! //! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`, diff --git a/crates/presenter/src/host/settings.rs b/crates/presenter/src/host/settings.rs index 5f3d39b..5e45361 100644 --- a/crates/presenter/src/host/settings.rs +++ b/crates/presenter/src/host/settings.rs @@ -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 { - 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::() + .map(Value::from) + .map_err(|_| format!("{key} 需要整数:{value}")), + ValueKind::Float { .. } => match trimmed.parse::() { + 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 地区缓存(下次建引擎重新判定)。 diff --git a/crates/presenter/src/host/settings_tests.rs b/crates/presenter/src/host/settings_tests.rs index 87b5b05..0cabb39 100644 --- a/crates/presenter/src/host/settings_tests.rs +++ b/crates/presenter/src/host/settings_tests.rs @@ -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(); +} diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index a29d8c8..d2224ad 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -312,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; diff --git a/crates/presenter/src/prefs/tests.rs b/crates/presenter/src/prefs/tests.rs new file mode 100644 index 0000000..1a8b2f2 --- /dev/null +++ b/crates/presenter/src/prefs/tests.rs @@ -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); +}