Files
Unlucky-Launcher/crates/presenter/src/prefs.rs
T
AskaEth 22e4c423fa feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer
复审前补齐两处(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 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00

405 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! UI 偏好项与持久化键登记(导航折叠、AI 栏折叠、磁贴尺寸、视图、主题三态与外观四件、下载分类)。
//!
//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键,
//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源),
//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。
/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。
pub use unlucky_core::setting::appearance::BgFill;
pub use unlucky_core::setting::appearance::PALETTE_SOURCES;
/// 外观下层的三张表/常量(D36·D37·D38):复用 core 的定义,一处真相。
///
/// [`WINDOW_ALPHA_MIN`] 还要给 `unlucky-app-iced` 的 win32 α 层用(它只依赖本 crate,不直接依赖
/// core):窗口 α 的硬底必须与设置侧同源,两边各写一个 `0.08` 迟早会漂。
///
/// 同理 [`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;
/// 预设强调色板(09 §104)。**必须**与 UI 侧设置页「外观」分组的色板控件同序同值:
/// UI 侧只回传索引,十六进制串由这张表给出(一处真相)。
pub const ACCENT_PRESETS: [&str; 8] = [
"#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3",
];
/// 磁贴尺寸(Q24-3:小 6 列 / 中 4 列 / 大 3 列)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TileSize {
/// 小:6 列,只留图标/名字/启动键
Small,
/// 中:4 列(D16 默认)
#[default]
Medium,
/// 大:3 列,显示完整徽标与最后游玩时间
Large,
}
impl TileSize {
/// 该尺寸对应的网格列数。
pub fn columns(self) -> usize {
match self {
Self::Small => 6,
Self::Medium => 4,
Self::Large => 3,
}
}
/// 设置页/实例页分段控件的文案。
pub fn label(self) -> &'static str {
match self {
Self::Small => "小",
Self::Medium => "中",
Self::Large => "大",
}
}
}
/// 实例页视图模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InstanceView {
/// 磁贴网格(默认)
#[default]
Tiles,
/// 紧凑列表
List,
}
/// 主题三态(D17,P0 交付):`appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。
///
/// `auto` = 跟随系统(由 UI 侧的令牌层读系统调色板,随 OS 热更;探测口径见
/// [`crate::theme::system_theme_value`]);`light` / `dark` 钉死**自绘体系**的取值。
/// 三个取值怎么变成框架颜色是 UI 侧的事,本 crate 只给语义。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemeMode {
/// 跟随系统(默认)
#[default]
Auto,
/// 强制浅色
Light,
/// 强制深色
Dark,
}
impl ThemeMode {
/// 落盘取值(`appearance.theme_mode`):`auto` / `light` / `dark`。
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Light => "light",
Self::Dark => "dark",
}
}
/// 从落盘取值解析;未知值按 `auto` 兜底(配置被手改坏也不崩)。
pub fn from_key_value(value: &str) -> Self {
match value {
"light" => Self::Light,
"dark" => Self::Dark,
_ => Self::Auto,
}
}
}
/// 09 §101-104「外观(高级)」四件的取值快照(出厂全部关闭)。
///
/// 与 `unlucky_core::setting::appearance::Appearance` 一一对应,但字段类型是 UI 侧好用的
/// `f32`/`String`;落盘由 [`crate::settings_bridge`] 转成 schema 键。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AppearancePrefs {
/// 背景图总开关
pub bg_image_enabled: bool,
/// 背景图路径(PNG;空 = 未设置)
pub bg_image: String,
/// 填充模式
pub bg_fill: BgFill,
/// 暗化层强度 0.0~0.9
pub bg_dim: f32,
/// 面板半透明度 0.4~1.0
pub panel_alpha: f32,
/// 玻璃拟态卡片
pub glass: bool,
/// 自定义强调色 `#RRGGBB`;空 = 出厂 [#0078D4]
pub accent_color: String,
}
impl AppearancePrefs {
/// 出厂态:Metro 纯色,四个高级特性全关。
pub fn factory() -> Self {
Self {
bg_image_enabled: false,
bg_image: String::new(),
bg_fill: BgFill::Cover,
bg_dim: 0.35,
panel_alpha: 1.0,
glass: false,
accent_color: String::new(),
}
}
/// 背景图是否真的生效(开关打开且路径非空)。
pub fn bg_active(&self) -> bool {
self.bg_image_enabled && !self.bg_image.trim().is_empty()
}
/// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂)。
///
/// 返回 `false` 表示输入不合法(调用方据此亮设置页的 warn 行,不改现状)。
pub fn set_accent(&mut self, text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
self.accent_color.clear();
return true;
}
match unlucky_core::setting::appearance::normalize_hex(trimmed) {
Some(hex) => {
self.accent_color = hex;
true
}
None => false,
}
}
/// 用预设色板第 `index` 个颜色(越界忽略)。
pub fn set_accent_preset(&mut self, index: usize) -> bool {
match ACCENT_PRESETS.get(index) {
Some(hex) => self.set_accent(hex),
None => false,
}
}
}
/// 外观下层的**只读**快照:整窗 α / 标题栏三态 / 配色来源(D36·D37·D38)。
///
/// 不并进 [`AppearancePrefs`] 的两个理由:那个结构体由 `settings_bridge` 逐字段字面量
/// 构造(加字段就得改那边),且这三个键只读不写——落盘仍走 core 的 `Appearance::write`。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AppearanceBase {
/// 整窗透明度 [`WINDOW_ALPHA_MIN`]~1.0(1.0 = 完全不透明)
pub window_opacity: f32,
/// 标题栏三态(`auto` 的具体判定属于 UI 侧,本 crate 只给值)
pub titlebar_mode: TitlebarMode,
/// 配色来源档(三级编排在 `unlucky-design`,本 crate 只给值)
pub palette_source: PaletteSource,
}
impl AppearanceBase {
/// 出厂态:不透明 / `auto` / 内置色卡。
#[must_use]
pub fn factory() -> Self {
Self {
window_opacity: 1.0,
titlebar_mode: TitlebarMode::Auto,
palette_source: PaletteSource::Preset,
}
}
/// 从配置文档读一份快照:缺键 → 出厂默认、越界 → 钳位、非法枚举 → 回落。
///
/// 三条语义都在 core 里落地(本函数只搬运,不重新判一遍)。
#[must_use]
pub fn from_document(doc: &unlucky_core::setting::document::SettingDocument) -> Self {
let appearance = unlucky_core::setting::appearance::Appearance::read(doc);
Self {
window_opacity: appearance.window_opacity as f32,
titlebar_mode: appearance.titlebar_mode,
palette_source: appearance.palette_source,
}
}
/// 整窗 α 是否需要真的落到窗口上(1.0 = 不透明,调 FFI 纯属多余)。
#[must_use]
pub fn is_transparent(&self) -> bool {
self.window_opacity < 1.0
}
}
/// 需要跨会话记忆的 UI 偏好。
///
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
#[derive(Debug, Clone, PartialEq)]
pub struct UiPrefs {
/// 左侧导航栏折叠成图标条
pub nav_collapsed: bool,
/// 右侧 AI 栏折叠成 icon 轨
pub ai_collapsed: bool,
/// 磁贴尺寸
pub tile_size: TileSize,
/// 实例页视图
pub instance_view: InstanceView,
/// 主题模式
pub theme: ThemeMode,
/// 外观(高级)四件(09 §101-104)
pub appearance: AppearancePrefs,
/// 下载页选中的分类
pub download_category: VersionCategory,
}
impl Default for UiPrefs {
fn default() -> Self {
Self {
nav_collapsed: false,
ai_collapsed: false,
// D16 默认中磁贴(4 列)
tile_size: TileSize::Medium,
instance_view: InstanceView::Tiles,
theme: ThemeMode::Auto,
appearance: AppearancePrefs::factory(),
download_category: VersionCategory::Release,
}
}
}
/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。
pub mod keys {
/// 导航栏折叠
pub const NAV_COLLAPSED: &str = "ui.nav.collapsed";
/// AI 栏折叠
pub const AI_COLLAPSED: &str = "ui.ai.collapsed";
/// 磁贴尺寸
pub const TILE_SIZE: &str = "ui.instances.tile_size";
/// 实例页视图(磁贴/列表)
pub const INSTANCE_VIEW: &str = "ui.instances.view";
/// 主题三态(D17;与设置页「外观」分组同名,取值见 [`super::ThemeMode::as_str`])
pub const THEME_MODE: &str = "appearance.theme_mode";
/// 背景图开关(09 §101)
pub const BG_IMAGE_ENABLED: &str = "appearance.bg_image_enabled";
/// 背景图路径
pub const BG_IMAGE: &str = "appearance.bg_image";
/// 背景图填充模式
pub const BG_FILL: &str = "appearance.bg_fill";
/// 暗化层强度
pub const BG_DIM: &str = "appearance.bg_dim";
/// 面板半透明度(09 §102)
pub const PANEL_ALPHA: &str = "appearance.panel_alpha";
/// 玻璃拟态卡片(09 §103)
pub const GLASS: &str = "appearance.glass";
/// 自定义强调色(09 §104)
pub const ACCENT_COLOR: &str = "appearance.accent_color";
/// 下载页分类
pub const DOWNLOAD_CATEGORY: &str = "ui.download.category";
/// 整窗透明度(D36;下限见 [`super::WINDOW_ALPHA_MIN`])
pub const WINDOW_OPACITY: &str = "appearance.window_opacity";
/// 标题栏三态(D37)
pub const TITLEBAR_MODE: &str = "appearance.titlebar_mode";
/// 配色来源档(D38)
pub const PALETTE_SOURCE: &str = "appearance.palette_source";
/// 本模块登记的全部键(自检/迁移用)。
pub const ALL: [&str; 16] = [
NAV_COLLAPSED,
AI_COLLAPSED,
TILE_SIZE,
INSTANCE_VIEW,
THEME_MODE,
BG_IMAGE_ENABLED,
BG_IMAGE,
BG_FILL,
BG_DIM,
PANEL_ALPHA,
GLASS,
ACCENT_COLOR,
DOWNLOAD_CATEGORY,
WINDOW_OPACITY,
TITLEBAR_MODE,
PALETTE_SOURCE,
];
}
#[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);
}
}