feat(app-iced): Ink Cat / Silver Cat 换肤 —— 双色板默认 + 规格卡组件几何(M229)

产品出厂外观从 tone 派生的 `Scheme::baseline` 切到品牌色板:深色 = Ink(淡绿)、
浅色 = Silver(钢蓝),D38 取色链(背景图 / 壁纸)仍然优先,取色失败回落到色板。

主题三档接进设置页「通用 → 主题」:Ink / Silver / 跟随系统(落盘仍是
`appearance.theme_mode` 的 dark / light / auto,不新增配置键)。

组件几何按规格卡对齐(M228 的 `inkcat` 登记项逐条落地):
- 卡片圆角 12 → 16;实例元信息卡改用新的 ELEVATED CARD(无描边、SurfaceContainer
  底、内距 24、上标注 11/600)
- Snackbar 48/4 → 52/16(双行 72)
- Checkbox 20/rx2 → 18/rx4;Radio 20 → 18 Ø(内点仍 10)
- 按钮加 XS~XL 五档(产品仍只用 S=40),内距改走 ControlSize::pad_x
- 新增 FAB 四态(40/56/80/160×56)与图标按钮第四式 outlined
- 语言与主题两行 chips 统一成规格卡 chip(32 高 / rx8)

`theme.rs` 原本正好 400 行,先把单测纯搬移到 `theme/tests.rs` 再加色板字段;
`app/tests.rs`、`settings/general.rs` 同样按纯搬移拆分守 400 行硬线。

验证:cargo fmt --all --check、cargo check -p unlucky-app-iced --all-targets、
cargo test --workspace(除 clippy 前的一轮 flake,见报告)、cargo clippy --workspace
--all-targets -- -D warnings、全仓 400 行扫描。视觉矩阵 42 张在
DevDocs/artifacts/m229/(gitignored,不进提交)。
This commit is contained in:
AskaEth committed 2026-11-11 01:49:13 +08:00
1 parent a06b7ff4ec
commit db541c820e
44 files changed
+1439 -300

No files matched your search

+4 -1
View File
@@ -154,7 +154,10 @@ impl App {
avatars: pages::account::avatar::Cache::default(),
corner_applied: None,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
// M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`),
// 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。
app.settings.language_appearance.theme = theme_mode;
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。
app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
+66
View File
@@ -6,6 +6,8 @@
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use crate::app::diag::diag;
use crate::app::message::Message;
use iced::Task;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode;
@@ -73,6 +75,8 @@ impl App {
}
let mode = self.bridge.load().1;
self.theme_mode = mode;
// M229:设置页那一行画的是这个字段(唯一写者见 [`App::set_theme_mode`])。
self.settings.language_appearance.theme = mode;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
@@ -80,6 +84,68 @@ impl App {
}
}
/// 换主题档(M229:设置页「通用 → 主题」→ `pages::Message::Theme`)。
///
/// 四件事:①改 `theme_mode`(唯一真相)②同步设置页那一行的档位 ③重建 `Ui`(色板换了,
/// `Theme::name` 与 49 个角色一起变,iced 才会重算缓存)④落盘 `appearance.theme_mode`。
/// `Auto` 自己没有颜色,要等系统回答——所以返回 `iced::system::theme()` 那个 Task,
/// 由既有的 `Message::SystemTheme` 分支落地(它只在 `theme_mode == Auto` 时吃这条消息)。
pub(in crate::app) fn set_theme_mode(&mut self, mode: ThemeMode) -> Task<Message> {
self.settings.language_appearance.theme = mode;
if mode == self.theme_mode {
return Task::none();
}
self.theme_mode = mode;
let prefs = self.appearance_prefs.clone();
self.bridge.persist(&prefs, mode);
match self.bridge.path() {
Some(path) => diag!("[theme] mode={} 已写回 {}", mode.as_str(), path.display()),
None => diag!(
"[theme] mode={} 未落盘(数据根解析不出来,内存态运行):重启会回到旧值",
mode.as_str()
),
}
match mode {
ThemeMode::Light => {
self.set_flavor(SchemeFlavor::Light);
self.after_theme_switch();
Task::none()
}
ThemeMode::Dark => {
self.set_flavor(SchemeFlavor::Dark);
self.after_theme_switch();
Task::none()
}
// 跟随系统:当前先按已有深浅画,紧接着由系统主题回答覆盖
// (`set_flavor` 有「同档不重建」的保护,所以这里不必先猜一次)。
ThemeMode::Auto => iced::system::theme().map(Message::SystemTheme),
}
}
/// 换档之后重建视图模型 + 记一行取证日志(色板名就是证据:`ink` / `silver`)。
fn after_theme_switch(&mut self) {
self.view_dirty = true;
self.refresh_view();
diag!(
"[theme] mode={} board={} Theme::name={}",
self.theme_mode().as_str(),
self.board_name(),
self.ui.name()
);
}
/// 当前主题档(设置页那一行的选中态来自 `settings.groups`,这里是取证与测试的读数口)。
pub(in crate::app) fn theme_mode(&self) -> ThemeMode {
self.theme_mode
}
/// 当前色板名(日志与测试用:`ink` / `silver`;取色方案接管时仍报它所属深浅的那块)。
pub(in crate::app) fn board_name(&self) -> &'static str {
self.ui.board().name()
}
/// 当前令牌求值器(测试与截图日志用)。
pub(in crate::app) fn ui(&self) -> &Ui {
&self.ui
+9 -7
View File
@@ -54,11 +54,11 @@ fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
app.state.navigate(Route::Logs);
app.refresh_view();
assert_eq!(app.ui().name(), "ull-m3-light");
assert_eq!(app.ui().name(), "ull-silver");
app.set_flavor(SchemeFlavor::Dark);
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
assert_eq!(app.ui().name(), "ull-ink", "切主题要换主题名");
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
@@ -78,7 +78,7 @@ fn screenshot_mode_never_waits_for_the_system_theme() {
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
let app = headless(options, SchemeFlavor::Light);
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
assert!(app.has_screenshot() && app.ui().name() == "ull-silver");
}
#[test]
@@ -336,7 +336,7 @@ fn the_page_navigation_entry_also_detaches() {
fn screenshot_mode_ignores_the_settings_file_theme() {
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
app.apply_config_theme();
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
assert_eq!(app.ui().name(), "ull-silver", "截图模式的主题只认命令行");
}
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
@@ -355,7 +355,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
assert_eq!(auto.ui().name(), "ull-ink", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
@@ -363,7 +363,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
pinned.ui().name(),
"ull-m3-dark",
"ull-ink",
"配置文件说 dark,系统说 light:不许改"
);
@@ -378,7 +378,9 @@ fn the_system_theme_never_overrides_an_explicit_one() {
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
explicit.ui().name(),
"ull-m3-dark",
"ull-ink",
"--theme 优先级最高,系统主题不许盖"
);
}
mod theme;
+1 -1
View File
@@ -181,7 +181,7 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() {
"取色成功后 Ui 要换成解析出的方案"
);
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
assert_eq!(app.ui().name(), "ull-m3-light");
assert_eq!(app.ui().name(), "ull-silver");
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
assert_ne!(
app.ui().color(unlucky_design::color::Role::Primary),
+85
View File
@@ -0,0 +1,85 @@
//! 主题三档(Ink / Silver / 跟随系统)的落点测试(M229;从 `app/tests.rs` 拆出守 400 行)。
//!
//! 这一份只管**用户点设置页那三枚 chip 之后**发生的事:换色板、同步设置组状态、落盘。
//! `Auto`(跟随系统)那条路在 `app::tests::the_system_theme_never_overrides_an_explicit_one`。
use crate::app::message::Message;
use crate::app::state::App;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一个把配置写在临时文件里的 App:测「落盘」必须真的过文件(内存桥读回来永远是出厂值)。
fn with_config() -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("ull-theme-m229-{}", std::process::id()));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
std::fs::write(&path, "{}").expect("写配置文件");
let app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::with_path(&path),
);
(app, dir)
}
/// M229:设置页「通用 → 主题」三档(Ink / Silver / 跟随系统)真的换色板、真的落盘。
///
/// 抓得住:①`pages::Message::Theme` 没接进 `on_page`(点了没反应)②接了但不换 `Ui`
/// (只有选中态变了,界面颜色还是旧的)③同步设置组状态漏了(设置页首帧/刷新后画错档)
/// ④落盘漏了(重启回旧值——这里读的是内存桥的 `theme_mode`)。
/// 抓不住:色板好不好看、像素级差异(那要截图,见 `DevDocs/artifacts/m229/`)。
#[test]
fn the_theme_message_switches_the_board_and_persists_it() {
let (mut app, dir) = with_config();
assert_eq!(app.board_name(), "silver", "首帧是浅色主方案(Silver)");
// 选 Ink(= 深色主方案)
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Dark)));
assert_eq!(app.theme_mode(), ThemeMode::Dark);
assert_eq!(app.board_name(), "ink", "Ink 深板要真切过去");
assert_eq!(app.ui().flavor(), SchemeFlavor::Dark);
assert_eq!(
app.settings.language_appearance.theme,
ThemeMode::Dark,
"设置页那一行的档位要跟着改"
);
// 落盘:真的过了文件(重新打开同一个配置读回来就是 dark)
assert_eq!(
SettingsBridge::with_path(dir.join("config/launcher.json"))
.load()
.1,
ThemeMode::Dark,
"主题档要落盘"
);
// 选 Silver(= 浅色主方案)
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
assert_eq!(app.board_name(), "silver");
assert_eq!(app.ui().flavor(), SchemeFlavor::Light);
assert_eq!(
SettingsBridge::with_path(dir.join("config/launcher.json"))
.load()
.1,
ThemeMode::Light
);
// 同一档再点一次:不许白重建
let refreshes = app.refreshes;
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型");
std::fs::remove_dir_all(&dir).ok();
}
@@ -66,6 +66,9 @@ impl App {
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
pages::Message::Language(choice) => self.set_language(choice),
// M229:换主题档(Ink / Silver / 跟随系统)——换 `Ui` 的色板 + 落盘
// `appearance.theme_mode`。与语言行同一条「页面消息直接改本地 + 落盘」的路。
pages::Message::Theme(mode) => return self.set_theme_mode(mode),
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
pages::Message::Content(message) => return self.on_content(message),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
+2 -2
View File
@@ -33,7 +33,7 @@ use update::update_of;
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
///
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-m3-light` / `ull-m3-dark`),
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-ink` / `ull-silver`),
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
@@ -43,7 +43,7 @@ static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync:
///
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-m3-light`。
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-silver`。
///
/// ## 为什么只在变化时打
///