用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮, presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。 ## 令牌(先定数值再动手) - nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动) 两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。 - 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。 ## 折叠按钮与折叠态 - 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比), 轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。 - 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号); 纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。 ## 动效(复用底盘、零新常数) - 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec(); 借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。 - 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty** —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。 ## 落盘(任务要求「重启读回仍折叠」) - core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加 load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。 - 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。 ## 判据 - app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例); presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。 - 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。 - 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽 且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。 ## 已知偏离(已登记,tower 批了紧随 mission) crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块 宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
400 lines
15 KiB
Rust
400 lines
15 KiB
Rust
//! 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 接),
|
||
//! **唯一例外是 [`UiPrefs::nav_collapsed`](M300 已接:schema 键 + 读 + 写三处都在)**。
|
||
|
||
/// 背景图填充模式(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, WindowCorner, BG_DIM_MAX, PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN,
|
||
WINDOW_CORNERS,
|
||
};
|
||
|
||
/// 系统版本探测(M116):Windows 侧「能不能用 DWM 圆角」的判据要 build 号,而
|
||
/// `unlucky-app-iced` 不直接依赖 `unlucky-core`(分层:UI → presenter → core),
|
||
/// 所以在这里做**唯一一次**重导出,`platform` 只认 [`WindowsVersion`]。
|
||
///
|
||
/// [`WIN11_MIN_BUILD`] = 22000:Win11 与 Server 2025 共用同一内核判据(M116 实测本机
|
||
/// Server 2025 build 26100 落在这条线之上)。
|
||
pub use unlucky_core::platform::windows_version::{
|
||
probe as probe_windows_version, WindowsVersion, WIN11_MIN_BUILD,
|
||
};
|
||
|
||
use crate::dto::VersionCategory;
|
||
|
||
/// 预设强调色板(09 §104)。**必须**与 UI 侧设置页「外观」分组的色板控件同序同值:
|
||
/// UI 侧只回传索引,十六进制串由这张表给出(一处真相)。
|
||
pub const ACCENT_PRESETS: [&str; 8] = [
|
||
"#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3",
|
||
];
|
||
|
||
/// 强调色输入的**结构化失败原因**(M250 ACCENTWIRE)。
|
||
///
|
||
/// 为什么不是 `bool`:接线之后「填坏的串」必须能变成一条看得见的原因,回退成 `false` 只会让
|
||
/// 用户再体验一次「保存成功、界面没变」。空串**不在这里**:空 = 出厂品牌板(合法取值)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum AccentError {
|
||
/// 去掉 `#` 后不足 6 位(`#12`、`#006A5`)。
|
||
Short {
|
||
/// 实际位数(原样报给用户,别把「6 位」写死在句子里)。
|
||
digits: usize,
|
||
},
|
||
/// 去掉 `#` 后超过 6 位(`#006A5C00`、粘了整行的串)。
|
||
Long {
|
||
/// 实际位数。
|
||
digits: usize,
|
||
},
|
||
/// 含非十六进制字符(`zzzzzz`)。
|
||
Digit {
|
||
/// 第一个不合法字符(比「格式不对」有用:用户能一眼看到错在哪个字符)。
|
||
ch: char,
|
||
},
|
||
}
|
||
|
||
/// 一次强调色输入的解析结果:`appearance.accent_color` 的**唯一**解析点(M250):设置页输入框、
|
||
/// 配置读回、出图取证开关都走它。少了 `#` 也接受(`006A5C` = `#006A5C`)——core 的 `normalize_hex`
|
||
/// 同样接受,而 `ull config set appearance.accent_color 006A5C` 写出来的值必须能被界面吃下
|
||
/// (否则 CLI 设的值会在设置页亮红行、颜色却是生效的)。输出一律规范化为大写 `#RRGGBB`。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum AccentChoice {
|
||
/// 空串 = 出厂品牌板(`DesignTokens::brand`)。**不是错误**。
|
||
Factory,
|
||
/// 合法的 `#RRGGBB`(大写、带 `#`)。
|
||
Custom(String),
|
||
}
|
||
|
||
impl AccentChoice {
|
||
/// 解析一段用户输入 / 配置值。
|
||
///
|
||
/// # Errors
|
||
/// 位数不是 6 或含非十六进制字符时返回 [`AccentError`];空串是 `Ok(Factory)`。
|
||
pub fn parse(text: &str) -> Result<Self, AccentError> {
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
return Ok(Self::Factory);
|
||
}
|
||
let body = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
||
if let Some(ch) = body.chars().find(|ch| !ch.is_ascii_hexdigit()) {
|
||
return Err(AccentError::Digit { ch });
|
||
}
|
||
match body.chars().count() {
|
||
digits @ 0..=5 => Err(AccentError::Short { digits }),
|
||
6 => Ok(Self::Custom(format!("#{}", body.to_ascii_uppercase()))),
|
||
digits => Err(AccentError::Long { digits }),
|
||
}
|
||
}
|
||
|
||
/// 落盘字符串(`""` = 出厂;`#RRGGBB` = 自定义)。
|
||
#[must_use]
|
||
pub fn as_str(&self) -> &str {
|
||
match self {
|
||
Self::Factory => "",
|
||
Self::Custom(hex) => hex,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 磁贴尺寸(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` = 输入不合法、
|
||
/// 现状不变。需要**原因**时走 [`AppearancePrefs::set_accent_checked`](设置页走那条)。
|
||
pub fn set_accent(&mut self, text: &str) -> bool {
|
||
self.set_accent_checked(text).is_ok()
|
||
}
|
||
|
||
/// 与 [`AppearancePrefs::set_accent`] 同一条路,但把结构化原因交出去。失败时**不动**
|
||
/// `accent_color`(保持上一次合法值):非法串既不落盘、也不改生效色,只在 UI 上留一条原因。
|
||
///
|
||
/// # Errors
|
||
/// 位数不是 6 或含非十六进制字符时返回 [`AccentError`](见 [`AccentChoice::parse`])。
|
||
pub fn set_accent_checked(&mut self, text: &str) -> Result<(), AccentError> {
|
||
let choice = AccentChoice::parse(text)?;
|
||
self.accent_color = choice.as_str().to_owned();
|
||
Ok(())
|
||
}
|
||
|
||
/// 用预设色板第 `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 · M116)。
|
||
///
|
||
/// 不并进 [`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,
|
||
/// 窗口外框圆角策略(M116:`system` / `square`;能不能圆由平台决定)
|
||
pub window_corner: WindowCorner,
|
||
}
|
||
|
||
impl AppearanceBase {
|
||
/// 出厂态:不透明 / `auto` / 内置色卡 / 跟随系统圆角。
|
||
#[must_use]
|
||
pub fn factory() -> Self {
|
||
Self {
|
||
window_opacity: 1.0,
|
||
titlebar_mode: TitlebarMode::Auto,
|
||
palette_source: PaletteSource::Preset,
|
||
window_corner: WindowCorner::System,
|
||
}
|
||
}
|
||
|
||
/// 从配置文档读一份快照:缺键 → 出厂默认、越界 → 钳位、非法枚举 → 回落。
|
||
///
|
||
/// 三条语义都在 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,
|
||
window_corner: appearance.window_corner,
|
||
}
|
||
}
|
||
|
||
/// 整窗 α 是否需要真的落到窗口上(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 {
|
||
/// 左侧导航栏折叠成图标条(M300 起**已落盘**:键 [`keys::NAV_COLLAPSED`],读 `SettingsBridge::load_nav_collapsed`)
|
||
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 {
|
||
/// 导航栏折叠(M300 起是**真落盘**键,core schema 同名字符串)
|
||
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";
|
||
/// 窗口圆角策略(M116;`system` / `square`)
|
||
pub const WINDOW_CORNER: &str = "appearance.window_corner";
|
||
|
||
/// 本模块登记的全部键(自检/迁移用)。
|
||
pub const ALL: [&str; 17] = [
|
||
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,
|
||
WINDOW_CORNER,
|
||
];
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|