From e6b2200360bdc11ccadc036ce4147c520853f78d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 14:56:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(app-iced):=20=E5=BC=BA=E8=B0=83?= =?UTF-8?q?=E8=89=B2=E6=8E=A5=E7=BA=BF=E8=90=BD=E5=9C=B0=EF=BC=88M250=20AC?= =?UTF-8?q?CENTWIRE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appearance.accent_color 此前是死线:配置键、presenter 往返、预设色板都在,但造 tokens 的 唯一入口恒走 DesignTokens::brand(board),用户填了色界面不变。 - theme.rs:Ui::with_accent + accent_scheme(唯一调用 DesignTokens::with_accent 的地方) - app/state/theme.rs:rebuild_ui 三选一(显式强调色 > D38 取色方案 > 出厂板),diag 一行说清是谁生效 - pages/appearance:新增 accent 状态(原文/解析结果/方案/结构化错误)+ 设置页输入行与预设色块; 非法值保持上一次合法值并给出 Short/Long/Digit 三档原因,不静默回退出厂色 - presenter::prefs:AccentChoice::parse 成为唯一解析点,set_accent_checked 交回结构化原因 - app/appearance.rs:persist_appearance 带上 accent_color(此前写不进去),只写上一次合法值 - pages/harness/app.rs:gallery 也能吃 ULL_APPEARANCE_ACCENT,供出图矩阵 - core keys.rs:订正「空 = 出厂 #0078D4;accent 令牌全域生效」这句半真半假的说明 - i18n:强调色 7 条文案(链尾新域,金表只追加) 判据:同一组件(进度条 Primary)在空配置与 #006A5C 下色值不同(App 级 + 令牌级两条); 非法值拿到结构化错误、生效色不回退、不写盘、tokens 仍可构造。 --- crates/app-iced/src/app/appearance.rs | 6 +- crates/app-iced/src/app/state.rs | 2 +- crates/app-iced/src/app/state/theme.rs | 86 ++++++++- .../app-iced/src/pages/appearance/accent.rs | 131 ++++++++++++++ crates/app-iced/src/pages/appearance/mod.rs | 9 + .../src/pages/appearance/section/accent.rs | 165 +++++++++++++++++ .../src/pages/appearance/section/mod.rs | 4 + crates/app-iced/src/pages/appearance/state.rs | 28 ++- crates/app-iced/src/pages/appearance/tests.rs | 21 ++- .../src/pages/appearance/tests/accent.rs | 171 ++++++++++++++++++ crates/app-iced/src/pages/harness/app.rs | 17 +- crates/app-iced/src/theme.rs | 23 +++ crates/core/src/setting/keys.rs | 2 +- crates/i18n/src/catalog.rs | 7 +- crates/i18n/src/catalog/golden/tail2.rs | 7 + crates/i18n/src/catalog/settings/accent.rs | 48 +++++ crates/i18n/src/catalog/settings/mod.rs | 3 + crates/presenter/src/prefs.rs | 95 ++++++++-- 18 files changed, 787 insertions(+), 38 deletions(-) create mode 100644 crates/app-iced/src/pages/appearance/accent.rs create mode 100644 crates/app-iced/src/pages/appearance/section/accent.rs create mode 100644 crates/app-iced/src/pages/appearance/tests/accent.rs create mode 100644 crates/i18n/src/catalog/settings/accent.rs diff --git a/crates/app-iced/src/app/appearance.rs b/crates/app-iced/src/app/appearance.rs index 385d4ae..a03ece3 100644 --- a/crates/app-iced/src/app/appearance.rs +++ b/crates/app-iced/src/app/appearance.rs @@ -83,7 +83,7 @@ impl App { }); } } - // 其余键:走 bridge(它知道怎么保留强调色这类本 mission 不管的字段)。 + // 其余键:走 bridge(M250 起强调色也在这条路上,取的是上一次合法值)。 Some(_) => self.persist_appearance(), None => {} } @@ -110,7 +110,7 @@ impl App { self.apply_window_corner("reload"); } - /// 把外观写回 `launcher.json`(只改本 mission 管的字段,强调色/玻璃原样保留)。 + /// 把外观写回 `launcher.json`(本页控件管的那批字段 + 强调色;其余键原样保留)。 fn persist_appearance(&mut self) { let mut prefs = self.appearance_prefs.clone(); prefs.bg_image_enabled = self.appearance.bg_enabled; @@ -119,6 +119,8 @@ impl App { prefs.bg_dim = self.appearance.bg_dim; prefs.panel_alpha = self.appearance.panel_alpha; prefs.glass = self.appearance.glass; + // M250:强调色也是「UI 写入字段」了——取**上一次合法值**(非法手输不写盘,见 `accent::Accent`)。 + prefs.accent_color = self.appearance.accent.persist_value().to_owned(); self.bridge.persist(&prefs, self.theme_mode); self.appearance_prefs = prefs; diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 0cf752e..8028f26 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -148,7 +148,7 @@ pub struct App { /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 pub(super) appearance: pages::appearance::State, /// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段, - /// 强调色这类本 mission 不管的字段原样保留)。 + /// 没动过的字段原样保留;M250 起强调色也在被改的那一列里)。 pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 pub(super) hwnd: Option, diff --git a/crates/app-iced/src/app/state/theme.rs b/crates/app-iced/src/app/state/theme.rs index b8086ab..9243282 100644 --- a/crates/app-iced/src/app/state/theme.rs +++ b/crates/app-iced/src/app/state/theme.rs @@ -40,7 +40,11 @@ impl App { ); } - /// 按「配色来源是否取色成功」重建 [`Ui`]。 + /// 按「强调色 / 配色来源是否接管」重建 [`Ui`]。 + /// + /// **优先级(M250):显式强调色 > D38 取色方案 > 出厂品牌板**。手填的色是直接意图,取色是从 + /// 图片派生的,所以强调色只在用户真的填了合法值时压过取色——两条路都只决定「颜色来自哪里」, + /// `flavor`(明暗 / 跟随系统)仍由 `self.appearance.flavor()` 决定。 /// /// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是 /// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的 @@ -49,15 +53,34 @@ impl App { // 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一** // 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。 let panel_alpha = self.appearance.effective_panel_alpha(); - self.ui = match self.appearance.derived_scheme() { - Some(scheme) => { + self.ui = match ( + self.appearance.accent.color(), + self.appearance.derived_scheme(), + ) { + (Some(accent), _) => { + // M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。 + diag!( + "[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;\ + 取色方案 palette={} 被显式强调色压住)", + self.appearance.accent.log_token(), + self.appearance.palette_level() + ); + Ui::with_accent(self.appearance.flavor(), accent, self.families) + } + (None, Some(scheme)) => { diag!( "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", self.appearance.palette_level() ); Ui::from_scheme(*scheme, self.families) } - None => Ui::new(self.appearance.flavor(), self.families), + (None, None) => { + // 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。 + if let Some(error) = self.appearance.accent.error() { + diag!("[appearance] accent 非法({error:?})→ 出厂板,错行见设置页"); + } + Ui::new(self.appearance.flavor(), self.families) + } } .with_panel_alpha(panel_alpha); } @@ -151,3 +174,58 @@ impl App { &self.ui } } + +#[cfg(test)] +mod tests { + use super::super::App; + use crate::app::components::feedback::progress; + use crate::cli::Options; + use crate::pages::appearance::Change; + use crate::theme::Families; + use unlucky_design::color::{Color, SchemeFlavor}; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 强调色接线(M250 ACCENTWIRE)的 App 级判据:`rebuild_ui` 真的换了令牌。 + /// + /// **这条是判据一的变异探针**:把 [`App::rebuild_ui`] 改回「恒走 `Ui::new` 出厂板」 + /// (或在它前面短路掉 `accent.color()` 分支),下面 `assert_ne!` 当场红——`pages/appearance` + /// 那条用例只证明「`Ui::with_accent` 能给出不同的色」,抓不到「谁去调它」。 + /// + /// 抓得住:接线不存在 / 被短路 / 强调色解析结果没进 `Ui`。 + /// 抓不住:像素(样式闭包被别处覆盖)——那是出图的事。 + #[test] + fn rebuild_ui_follows_the_configured_accent() { + let mut app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ); + + // 出厂态:同一组件(进度指示条 = `Role::Primary`)取到的值 + let (_, factory_primary) = progress::linear_colors(app.ui()); + assert_eq!(app.ui().name(), "ull-silver"); + + // 设置页填色 → `on_appearance` 会 rebuild 一次(与真机路径同一个函数) + app.on_appearance(Change::Accent(Color::rgb(0x00, 0x6A, 0x5C).to_hex())); + let (_, accent_primary) = progress::linear_colors(app.ui()); + + assert_ne!( + factory_primary, accent_primary, + "填了自定义强调色,`rebuild_ui` 却还给出出厂板的 Primary ⇒ 接线没落地" + ); + assert_eq!( + app.ui().flavor(), + SchemeFlavor::Light, + "强调色只换颜色来源,不改明暗" + ); + } +} diff --git a/crates/app-iced/src/pages/appearance/accent.rs b/crates/app-iced/src/pages/appearance/accent.rs new file mode 100644 index 0000000..2b63aee --- /dev/null +++ b/crates/app-iced/src/pages/appearance/accent.rs @@ -0,0 +1,131 @@ +//! 强调色的**运行期状态**(M250 ACCENTWIRE):输入原文 → 解析结果 → 生效方案。 +//! +//! 这一层存在的理由:`appearance.accent_color` 此前是死线——键、输入框、presenter 往返都在, +//! 但造 tokens 的唯一入口恒走 `DesignTokens::brand(board)`,用户填了色界面没变化。接线要落地 +//! 的不只是「把 hex 喂进 `Scheme::from_accent`」,还有**失败要可见**: +//! +//! | 输入 | 生效配色 | `error` | 落盘值 | +//! |---|---|---|---| +//! | 空串 | 出厂品牌板(`DesignTokens::brand`) | `None` | `""` | +//! | `#006A5C` | `DesignTokens::with_accent`(只换三族) | `None` | `#006A5C` | +//! | `#12` / `zzzzzz` / 超长串 | **保持上一次合法的值**(不静默回退成出厂色) | `Some(..)` | 上一次合法的值 | +//! +//! 「保持上一次合法值」而不是「回退出厂板」是刻意的:回退会让「填错」和「填对但没生效」在屏幕上 +//! 长得一模一样,用户唯一的线索就只剩那条错行——而那正是这里要保证不被丢掉的。 +//! +//! 解析本身不在这里:`AccentChoice::parse` 是唯一真相(`unlucky_presenter::prefs`),本文件只负责 +//! 把结果接到 `Scheme` 与 UI 上。 + +use unlucky_design::color::{Color, Scheme, SchemeFlavor}; +use unlucky_presenter::prefs::{AccentChoice, AccentError}; + +use crate::theme::accent_scheme; + +/// 出图矩阵的强调色开关(`ULL_APPEARANCE_ACCENT=#RRGGBB`;产品路径读 `launcher.json`)。 +/// +/// 放在本文件而不是 `state.rs` 的 `ENV_*` 那一族:`state.rs` 正好 400 行(硬线),这一条 +/// 属于强调色自己,跟 `Accent` 放一起也更好找。语义与其它 `ULL_APPEARANCE_*` 一致——只影响这次进程。 +pub const ENV_ACCENT: &str = "ULL_APPEARANCE_ACCENT"; + +/// 强调色的运行期状态(`App` 经 `pages::appearance::State` 持有一份)。 +#[derive(Debug, Clone, PartialEq)] +pub struct Accent { + /// 输入框的原文(受控控件要它;非法输入也必须原样留着,否则用户看不见自己错在哪)。 + text: String, + /// 上一次**合法**的解析结果(非法输入不改它 —— 生效色因此不会跳回出厂板)。 + choice: AccentChoice, + /// 解析出来的强调色(`None` = 出厂板,强调色由品牌色板决定)。 + color: Option, + /// 非法输入的结构化原因(`None` = 没填或填得合法)。 + error: Option, + /// 合法自定义色时的方案(`Ui` 直接用它;`None` = 走出厂品牌板)。 + scheme: Option, +} + +impl Accent { + /// 从配置读一份(启动时调一次)。配置里存着非法值时**也**亮错行,不静默吞掉。 + #[must_use] + pub fn new(text: &str, flavor: SchemeFlavor) -> Self { + let mut accent = Self { + text: text.to_owned(), + choice: AccentChoice::Factory, + color: None, + error: None, + scheme: None, + }; + accent.retry(flavor); + accent + } + + /// 设置页改了一次输入(受控输入框:每敲一个字符都走这里)。 + pub fn set(&mut self, text: &str, flavor: SchemeFlavor) { + self.text = text.to_owned(); + self.retry(flavor); + } + + /// 配置被外部改写后重置(热切:CLI / AI 改完 1 秒内生效)。 + pub fn reset(&mut self, text: &str, flavor: SchemeFlavor) { + *self = Self::new(text, flavor); + } + + /// 深浅切了:强调族要按新模式重派生(表面色仍由 `State::refresh_palette` 那条路管)。 + pub fn set_flavor(&mut self, flavor: SchemeFlavor) { + self.scheme = self.color.map(|accent| accent_scheme(flavor, accent)); + } + + /// 输入框原文。 + #[must_use] + pub fn text(&self) -> &str { + &self.text + } + + /// 结构化失败原因(`None` = 合法)。 + #[must_use] + pub fn error(&self) -> Option { + self.error + } + + /// 解析出来的强调色(`None` = 出厂板;日志与截图判据读它)。 + #[must_use] + pub fn color(&self) -> Option { + self.color + } + + /// 生效的强调色方案(`Ui` 的唯一入口:`Some` ⇒ 走 [`crate::theme::Ui::with_accent`] 的同一条路)。 + #[must_use] + pub fn scheme(&self) -> Option<&Scheme> { + self.scheme.as_ref() + } + + /// 落盘值:`""` = 出厂,`#RRGGBB` = 自定义。**永远是上一次合法值**——非法手输不写盘。 + #[must_use] + pub fn persist_value(&self) -> &str { + self.choice.as_str() + } + + /// 取证日志里的一个词:`factory` / `#006A5C` / `invalid(Short { digits: 2 })`。 + /// + /// 为什么日志要带这个:截图与真机复现只能靠一行文字分辨「强调色没生效」与「强调色生效了但 + /// 图上看不出来」——后者是这条 mission 的验收判据,前者是它的 bug。 + #[must_use] + pub fn log_token(&self) -> String { + match self.error { + Some(error) => format!("invalid({error:?})"), + None if self.choice.as_str().is_empty() => "factory".to_owned(), + None => self.persist_value().to_owned(), + } + } + + /// 按当前 `text` 重算:合法 ⇒ 换方案 + 清错;非法 ⇒ **方案不动** + 记错。 + fn retry(&mut self, flavor: SchemeFlavor) { + match AccentChoice::parse(&self.text) { + Ok(choice) => { + self.color = Color::from_hex(choice.as_str()); + self.scheme = self.color.map(|accent| accent_scheme(flavor, accent)); + self.choice = choice; + self.error = None; + } + Err(error) => self.error = Some(error), + } + } +} diff --git a/crates/app-iced/src/pages/appearance/mod.rs b/crates/app-iced/src/pages/appearance/mod.rs index 0574c8f..da1da56 100644 --- a/crates/app-iced/src/pages/appearance/mod.rs +++ b/crates/app-iced/src/pages/appearance/mod.rs @@ -60,6 +60,7 @@ use crate::pages::frame; use crate::pages::Message; use crate::theme::Ui; +pub mod accent; pub mod alpha; pub mod background; @@ -129,6 +130,8 @@ pub enum Change { PaletteSource(unlucky_presenter::prefs::PaletteSource), /// 窗口圆角档位(M116:跟随系统 / 直角)。 Corner(unlucky_presenter::prefs::WindowCorner), + /// 自定义强调色输入(M250:原文;空串 = 恢复出厂板)。 + Accent(String), /// 设置页切分组(左列点选)。 SelectGroup(usize), } @@ -146,6 +149,7 @@ impl Change { Self::BgDim(_) => unlucky_presenter::prefs::keys::BG_DIM, Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE, Self::Corner(_) => unlucky_presenter::prefs::keys::WINDOW_CORNER, + Self::Accent(_) => unlucky_presenter::prefs::keys::ACCENT_COLOR, Self::SelectGroup(_) => return None, }) } @@ -153,6 +157,10 @@ impl Change { /// 这条改动的落盘字符串形态(与 `schema` 的取值词表逐字一致)。 /// /// `None` = 不落盘(分组选择)。数字一律给 `f64` 的十进制字面量(`store.set` 按 schema 类型转)。 + /// + /// ⚠️ `Accent` 这一格的字符串是**用户原文**:真正落盘的值来自 + /// `State::current_values()` 的 `accent_color`(上一次**合法**值,见 `accent::Accent`), + /// 因为非法手输不许写进配置。这里保留原文只为「每条改动都点名到自己的键」这条不变量。 #[must_use] pub fn value(&self) -> Option { Some(match self { @@ -164,6 +172,7 @@ impl Change { Self::BgDim(dim) => format!("{dim}"), Self::PaletteSource(source) => source.as_str().to_owned(), Self::Corner(corner) => corner.as_str().to_owned(), + Self::Accent(text) => text.clone(), Self::SelectGroup(_) => return None, }) } diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs new file mode 100644 index 0000000..6348134 --- /dev/null +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -0,0 +1,165 @@ +//! 外观分组第 ⓪ 段(视觉上排在最前):**自定义强调色**(M250 ACCENTWIRE)。 +//! +//! 为什么这一段值得单开:`appearance.accent_color` 从 M41 起就有键、有 presenter 往返、 +//! 有 `ACCENT_PRESETS` 色板,却**没有任何 UI 写者**——造 tokens 的唯一入口恒走 +//! `DesignTokens::brand(board)`,用户即使改了配置文件,界面也不会变。接线之后这一段就是那条 +//! 链路的手动入口,也是**非法值唯一能被看见的地方**(错行紧贴输入框,不折叠、不写进日志了事)。 +//! +//! ## 三件控件对应的键与消息 +//! +//! | 控件 | 键 | 消息 | +//! |---|---|---| +//! | 强调色文本框(受控,输入即生效) | `appearance.accent_color` | [`Change::Accent`] | +//! | 「出厂」+ 8 个预设色块 | 同上(写入色板里的 hex) | [`Change::Accent`] | +//! | 非法值错行 | —(不进配置) | 无 | +//! +//! ## 色块为什么画成圆点而不是药丸 +//! +//! 9 颗写着 `#0078D4` 的药丸会变成一排噪声,而且色相要靠读字获取——色板控件的可见内容**就是** +//! 颜色本身。所以色块 = `ShapeScale::Full` 的实心圆 + 描边;选中态加粗描边(不靠色相区分: +//! 色块底下垫的是它自己的颜色,色相对比不可靠)。 + +use iced::widget::{button, column, container, row, Space as Gap}; +use iced::{Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; + +use unlucky_design::color::Role; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_i18n::catalog::Key; +use unlucky_i18n::Lang; +use unlucky_presenter::prefs::{AccentError, ACCENT_PRESETS}; + +use crate::app::components::inputs::{text_field, Field}; +use crate::pages::Message; +use crate::theme::{to_iced, Ui, STROKE_PX}; + +use super::super::accent::Accent; +use super::super::state::State; +use super::super::Change; +use super::{choice, label, note_line, t, tr}; + +/// 把 ⓪ 段推进 `rows`。 +pub(super) fn push<'a>( + ui: &'a Ui, + state: &'a State, + lang: Lang, + rows: &mut Vec>, +) { + let accent = &state.accent; + + // 受控输入框 + 失败态:`Field::error()` 让框本身变成错误色,错行再补「错在哪」。 + let mut field = Field::new(accent.text()) + .label(t(Key::SettingsAppearanceAccent, lang)) + .placeholder("#RRGGBB"); + if accent.error().is_some() { + field = field.error(); + } + let input: Element<'a, Message> = text_field(ui, field, |value| { + Message::Appearance(Change::Accent(value)) + }); + rows.push( + column![label(ui, t(Key::SettingsAppearanceAccent, lang)), input] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into(), + ); + + rows.push(presets(ui, accent, lang)); + + // 非法值:**结构化的原因**,不是「格式不对」这种没法行动的一句话。 + if let Some(error) = accent.error() { + let text = reason(lang, error); + rows.push(note_line(ui, &text, ui.color(Role::Error))); + } + + // 说明:空 = 出厂板;自定义色只换三族(中性表面与 error 保持基线是 design 既定语义)。 + rows.push(note_line( + ui, + t(Key::SettingsAppearanceAccentHint, lang), + ui.color(Role::OnSurfaceVariant), + )); +} + +/// 「预设」一行:`出厂` + 8 颗色块。 +fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Message> { + let current = accent.persist_value(); + let mut items: Vec> = vec![ + container(label(ui, t(Key::SettingsAppearanceAccentPresets, lang))) + .align_y(iced::Alignment::Center) + .into(), + choice( + ui, + t(Key::SettingsAppearanceAccentFactory, lang), + current.is_empty(), + Change::Accent(String::new()), + ), + ]; + for hex in ACCENT_PRESETS { + items.push(swatch(ui, hex, current == hex)); + } + + row(items) + .spacing(ui.space(Space::X2)) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .align_y(iced::Alignment::Center) + .wrap() + .into() +} + +/// 一颗色块:实心圆 + 描边(选中态加粗)。点击写入该预设。 +fn swatch<'a>(ui: &'a Ui, hex: &'static str, selected: bool) -> Element<'a, Message> { + let fill = unlucky_design::color::Color::from_hex(hex).map(to_iced); + // 直径取控件高度的六成(40 → 24dp):与药丸按钮同一行时不抢节奏。 + let diameter = ui.metrics().control_height * 0.6; + let ring = if selected { + ui.color(Role::OnSurface) + } else { + ui.color(Role::OutlineVariant) + }; + let width = if selected { STROKE_PX * 2.0 } else { STROKE_PX }; + let radius = ui.radius(ShapeScale::Full, diameter); + + button( + Gap::new() + .width(Length::Fixed(diameter)) + .height(Length::Fixed(diameter)), + ) + .padding(Padding::from(0.0)) + .style(move |_theme: &IcedTheme, status| button::Style { + background: fill.map(Background::Color), + text_color: ring, + border: Border { + color: match status { + button::Status::Hovered | button::Status::Pressed => ui.color(Role::OnSurface), + _ => ring, + }, + width, + radius, + }, + shadow: Shadow::default(), + snap: true, + }) + .on_press(Message::Appearance(Change::Accent(hex.to_owned()))) + .into() +} + +/// 结构化原因 → 一句话(参数由 Rust 侧格式化,词表只认 `{name}`)。 +fn reason(lang: Lang, error: AccentError) -> String { + match error { + AccentError::Short { digits } => tr( + lang, + Key::SettingsAppearanceAccentShort, + &[("digits", &digits.to_string())], + ), + AccentError::Long { digits } => tr( + lang, + Key::SettingsAppearanceAccentLong, + &[("digits", &digits.to_string())], + ), + AccentError::Digit { ch } => tr( + lang, + Key::SettingsAppearanceAccentDigit, + &[("ch", &ch.to_string())], + ), + } +} diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index d4cf2b0..391b337 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -4,6 +4,7 @@ //! //! | 控件 | 键 | 改动消息 | //! |---|---|---| +//! | 强调色文本框 + 预设色块(M250) | `appearance.accent_color` | [`Change::Accent`] | //! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] | //! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] | //! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] | @@ -72,6 +73,7 @@ use crate::theme::Ui; use super::state::State; use super::Change; +mod accent; mod bg_image; mod colour_source; mod panel; @@ -84,6 +86,8 @@ mod window; pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { let mut rows: Vec> = Vec::new(); + // M250:强调色排在最前 —— 它是「配色可自定义」这一页最直接的入口(键一直在,控件是新的)。 + accent::push(ui, state, lang, &mut rows); window::push(ui, state, lang, &mut rows); panel::push(ui, state, lang, &mut rows); bg_image::push(ui, state, lang, &mut rows); diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index 753da40..13a51fa 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -24,6 +24,7 @@ use unlucky_presenter::prefs::{ use crate::Backend; +use super::accent::{Accent, ENV_ACCENT}; use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque}; use super::background::{self, Image, Note}; use super::palette::{self, Resolved, Source}; @@ -60,6 +61,8 @@ pub struct State { pub palette_source: PaletteSource, /// 玻璃拟态开关(CPU 档强制关)。 pub glass: bool, + /// 自定义强调色(M250 ACCENTWIRE:输入原文 / 解析结果 / 生效方案 / 结构化错误都在里面)。 + pub accent: Accent, /// 窗口圆角策略(M116:`system` / `square`;真能不能圆由平台判,见 `platform::plan_corner`)。 pub window_corner: WindowCorner, /// 渲染后端(决定 CPU 档的两条降级)。 @@ -95,6 +98,7 @@ impl State { palette_source: base.palette_source, window_corner: base.window_corner, glass: prefs.glass, + accent: Accent::new(&prefs.accent_color, flavor), backend, selected_group: super::APPEARANCE_GROUP, image: None, @@ -132,6 +136,9 @@ impl State { if let Some(value) = std::env::var_os(ENV_PALETTE) { self.palette_source = PaletteSource::from_key_value(&value.to_string_lossy()); } + if let Some(value) = std::env::var_os(ENV_ACCENT) { + self.accent.set(&value.to_string_lossy(), self.flavor); + } self.refresh_background(); self.refresh_palette(); } @@ -224,6 +231,8 @@ impl State { return; } self.flavor = flavor; + // M250:自定义强调色也要按新模式重派生(否则切深浅后强调族停在旧模式)。 + self.accent.set_flavor(flavor); self.refresh_palette(); } @@ -275,6 +284,11 @@ impl State { self.window_corner = policy; false } + Change::Accent(text) => { + // M250:非法输入不改生效色(`Accent::set` 保留上一次合法值),只挂原因。 + self.accent.set(&text, self.flavor); + false + } Change::SelectGroup(index) => { self.selected_group = index; false @@ -282,11 +296,10 @@ impl State { } } - /// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 8 个字段)。 + /// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 9 个字段)。 /// - /// 注意:**强调色 / 玻璃不在本 mission 的管辖内**,调用方(`app::appearance`)会拿配置里 - /// 已有的快照做基底、只覆盖本 mission 真改了的字段,所以这里不产出完整快照。 - /// 这个函数只给「当前值是什么」一个只读视图(日志与测试用)。 + /// M250 起 `accent_color` 也在里面:以前这里恒给空串(当时「强调色不在本 mission 管辖内」), + /// 而 `persist_appearance` 拿配置里的旧快照做基底 ⇒ 设置页改的强调色落不了盘。现在给**上一次合法值**。 #[must_use] pub fn current_values(&self) -> AppearancePrefs { AppearancePrefs { @@ -296,7 +309,7 @@ impl State { bg_dim: self.bg_dim, panel_alpha: self.panel_alpha, glass: self.glass, - accent_color: String::new(), + accent_color: self.accent.persist_value().to_owned(), } } @@ -308,7 +321,7 @@ impl State { .map_or_else(|| "none".to_owned(), |byte| byte.to_string()); format!( "[appearance] {context} window_alpha={:.3} panel_alpha={:.3} (requested {:.3}) \ - byte={byte} corner={} backend={} bg={} fill={} dim={:.2} palette={} group={}", + byte={byte} corner={} backend={} bg={} fill={} dim={:.2} palette={} accent={} group={}", self.window_opacity, self.effective_panel_alpha(), self.panel_alpha, @@ -322,6 +335,7 @@ impl State { self.bg_fill.as_str(), self.bg_dim, self.palette_level(), + self.accent.log_token(), self.selected_group ) } @@ -336,6 +350,8 @@ impl State { self.bg_fill = prefs.bg_fill; self.bg_dim = prefs.bg_dim; self.glass = prefs.glass; + // M250:文件被外部改过(CLI / AI)时强调色也要热生效,错行同样重置。 + self.accent.reset(&prefs.accent_color, self.flavor); if self.bg_path != prefs.bg_image { self.bg_path = prefs.bg_image.clone(); } diff --git a/crates/app-iced/src/pages/appearance/tests.rs b/crates/app-iced/src/pages/appearance/tests.rs index 0f8208f..34bb04d 100644 --- a/crates/app-iced/src/pages/appearance/tests.rs +++ b/crates/app-iced/src/pages/appearance/tests.rs @@ -21,6 +21,8 @@ use unlucky_i18n::catalog::Key; use unlucky_i18n::Lang; use unlucky_presenter::prefs::{keys, BgFill, PaletteSource}; +mod accent; + /// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。 /// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。 #[test] @@ -93,7 +95,7 @@ fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> { }; let (backend, alpha) = match case { - Case::Factory => (Backend::Auto, 1.0), + Case::Factory | Case::BadAccent => (Backend::Auto, 1.0), Case::Cpu => (Backend::Cpu, 1.0), Case::Dim => (Backend::Gpu, 0.5), }; @@ -106,6 +108,10 @@ fn appearance_texts(lang: Lang, case: Case) -> Vec<(Key, String)> { if alpha < 1.0 { state.apply(Change::WindowOpacity(alpha)); } + if matches!(case, Case::BadAccent) { + // M250:错行只在失败态出现,所以「三档状态合起来覆盖本页全部 key」需要这一档。 + state.apply(Change::Accent("zzzzzz".to_owned())); + } let ui = Ui::new(SchemeFlavor::Light, FAMILIES); // 前面几条用例可能留了记录:本线程的取证每次都从清空开始。 @@ -142,6 +148,8 @@ enum Case { Cpu, /// 整窗 α 调低(圆角暂时不生效)。 Dim, + /// 强调色填了非法值(M250:错行 + 输入框失败态)。 + BadAccent, } /// 这一页**常驻**的 key(三档状态合起来必须一个不缺地取到)。 @@ -150,6 +158,11 @@ enum Case { /// 小图平铺)取决于运行时状态,单测里要让它们出现得造磁盘上的坏图;那些由 /// `background/tests.rs` 与 `palette.rs` 的单测按 key 逐条钉(见各自的用例注释)。 const CARD_KEYS: &[Key] = &[ + Key::SettingsAppearanceAccent, + Key::SettingsAppearanceAccentPresets, + Key::SettingsAppearanceAccentFactory, + Key::SettingsAppearanceAccentHint, + Key::SettingsAppearanceAccentDigit, Key::SettingsAppearanceCorner, Key::SettingsAppearanceCornerSystem, Key::SettingsAppearanceCornerSquare, @@ -191,7 +204,7 @@ fn the_appearance_card_speaks_chinese() { // 白名单:技术专名允许是纯 ASCII(现在一条都没有——`%` / 数字不在文案里,它们是参数)。 const ASCII_OK: &[&str] = &[]; - for case in [Case::Factory, Case::Cpu, Case::Dim] { + for case in [Case::Factory, Case::Cpu, Case::Dim, Case::BadAccent] { let pairs = appearance_texts(Lang::ZhCn, case); assert!( pairs.len() >= 20, @@ -222,7 +235,7 @@ fn the_appearance_card_speaks_chinese() { #[test] fn every_appearance_card_key_is_drawn() { let mut seen: Vec = Vec::new(); - for case in [Case::Factory, Case::Cpu, Case::Dim] { + for case in [Case::Factory, Case::Cpu, Case::Dim, Case::BadAccent] { seen.extend( appearance_texts(Lang::ZhCn, case) .into_iter() @@ -244,7 +257,7 @@ fn every_appearance_card_key_is_drawn() { /// 英文界面下不许出现汉字(否则「含汉字」可能来自写死的中文,而不是词表)。 #[test] fn the_appearance_card_keeps_its_english_baseline() { - for case in [Case::Factory, Case::Cpu, Case::Dim] { + for case in [Case::Factory, Case::Cpu, Case::Dim, Case::BadAccent] { let texts = appearance_texts(Lang::En, case); let with_han: Vec<&(Key, String)> = texts .iter() diff --git a/crates/app-iced/src/pages/appearance/tests/accent.rs b/crates/app-iced/src/pages/appearance/tests/accent.rs new file mode 100644 index 0000000..bc372bc --- /dev/null +++ b/crates/app-iced/src/pages/appearance/tests/accent.rs @@ -0,0 +1,171 @@ +//! 强调色接线的两条判据(M250 ACCENTWIRE):判据一「两套配置下同一组件色值不同」、 +//! 判据二「非法值拿到结构化原因且不静默回退」。 +//! +//! 为什么从 `tests.rs` 再拆一层:这两条用例加起来 150 行,`tests.rs` 会越过 400 行硬线 +//! (M180 把它从 `mod.rs` 拆出来就是为了这个)。判据本身与写进 `tests.rs` 的版本逐字一致, +//! 只是换了落点。 + +use super::Change; + +/// `#006A5C`(青绿)由 design 的 `Color` 拼出来:本 crate 的 `src/**` 不许出现 `#RRGGBB` +/// 字面量(`tests/no_hardcoded_tokens.rs` 把测试文件也扫在内——那条门禁是对的,色值只能来自令牌)。 +const TEAL: unlucky_design::color::Color = unlucky_design::color::Color::rgb(0x00, 0x6A, 0x5C); + +/// 判据一:`accent_color` 为空 与 为 `#006A5C` 时,**同一组件取到的 primary 类角色色值必须不同**。 +/// +/// 取色点是 `progress::linear_colors`(进度条指示条 = `Role::Primary`,实心按钮的容器也是它): +/// 断言的是**取到的值**,不是「没 panic」——把接线改回「恒走出厂板」,两边相等当场红。 +/// +/// 抓得住:① 接线不存在(两套 Ui 同色);② 只改了 `Primary` 而漏掉 secondary/tertiary; +/// ③ 有人把 `Scheme::from_accent` 的中性基准换掉(下面那条 `assert_eq` 会红)。 +/// 抓不住:像素(样式闭包被别处覆盖)——那是出图与 `components/**` 的 probe 用例的事。 +#[test] +fn custom_accent_changes_the_component_colours_it_reaches() { + use crate::app::components::feedback::progress; + use crate::cli::Backend; + use crate::theme::{Families, Ui}; + use unlucky_design::color::{Role, Scheme, SchemeFlavor}; + use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; + + use super::super::State; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + let mut state = State::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + Backend::Gpu, + SchemeFlavor::Light, + ); + // 空配置 = 出厂板(`rebuild_ui` 的 `(None, None)` 分支同一条路) + assert!( + state.accent.color().is_none(), + "空串是「用出厂板」,不该被解析成自定义色" + ); + let factory = Ui::new(SchemeFlavor::Light, FAMILIES); + + // 填 `#006A5C` = `rebuild_ui` 的 `(Some(accent), _)` 分支 + state.apply(Change::Accent(TEAL.to_hex())); + assert_eq!(state.accent.error(), None, "合法 hex 不该报错"); + let accent = state.accent.color().expect("合法 hex 必须解析成颜色"); + let custom = Ui::with_accent(SchemeFlavor::Light, accent, FAMILIES); + + let (_, factory_primary) = progress::linear_colors(&factory); + let (_, custom_primary) = progress::linear_colors(&custom); + assert_eq!(factory_primary, factory.color(Role::Primary)); + assert_eq!(custom_primary, custom.color(Role::Primary)); + assert_ne!( + factory_primary, custom_primary, + "同一组件的 primary 类角色在两套配置下同色 ⇒ 强调色没接线" + ); + + // 强调族逐条跟着变(不只 primary)。 + // + // `OnPrimary` 刻意**不在**清单里:深浅两套方案的 on-primary 都是 tone 100/20 的白/深色, + // 强调色只换色相,这一格在两套方案下同为纯白——令牌表里两格同值是合法的,钉它会假红。 + // 「强调族真的换了色相」由下面三个色相承载角色负责:Primary / PrimaryContainer / Tertiary。 + let baseline = Ui::from_scheme(Scheme::baseline(SchemeFlavor::Light), FAMILIES); + for role in [ + Role::Primary, + Role::PrimaryContainer, + Role::Secondary, + Role::Tertiary, + ] { + assert_ne!( + custom.color(role), + baseline.color(role), + "{role:?} 没跟着强调色走" + ); + } + // **中性表面与 error 保持基线**:`Scheme::from_accent` 的既定语义(design 侧登记,不是没接线) + for role in [ + Role::Surface, + Role::SurfaceContainerHighest, + Role::OnSurface, + Role::Outline, + Role::Error, + Role::OnError, + ] { + assert_eq!( + custom.color(role), + baseline.color(role), + "{role:?} 被强调色染了:`Scheme::from_accent` 只换 primary/secondary/tertiary 三族" + ); + } +} + +/// 判据二:非法值必须拿到**结构化的原因**,生效色不回退、不写盘,tokens 仍可构造(不崩)。 +/// +/// 抓得住:把非法分支改成「静默回退出厂色」——`color()` 会变成 `None`、`persist_value()` 会变成 +/// 空串,三条断言当场红;把错误吞成 `bool`——`error()` 不再是 `Some(..)`,也红。 +/// 抓不住:错行在屏幕上到底画没画出来(`every_appearance_card_key_is_drawn` 的 `BadAccent` 档 +/// 证明取词口被走到;像素由出图负责)。 +#[test] +fn illegal_accent_reports_a_reason_and_never_silently_falls_back() { + use crate::cli::Backend; + use crate::theme::{Families, Ui}; + use unlucky_design::color::{Role, SchemeFlavor}; + use unlucky_presenter::prefs::{AccentError, AppearanceBase, AppearancePrefs}; + + use super::super::State; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + let mut state = State::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + Backend::Gpu, + SchemeFlavor::Light, + ); + state.apply(Change::Accent(TEAL.to_hex())); + let good = state.accent.color().expect("先放一个合法值"); + let good_primary = Ui::with_accent(SchemeFlavor::Light, good, FAMILIES).color(Role::Primary); + + for (text, expected) in [ + ("#12", AccentError::Short { digits: 2 }), + ("zzzzzz", AccentError::Digit { ch: 'z' }), + ("#006A5C00112233", AccentError::Long { digits: 14 }), + ] { + state.apply(Change::Accent(text.to_owned())); + assert_eq!( + state.accent.error(), + Some(expected), + "{text}: 失败原因必须是结构化的(不是一句 bool / 一句「格式不对」)" + ); + assert_eq!( + state.accent.color(), + Some(good), + "{text}: 静默回退出厂色了(非法输入不该改生效色)" + ); + assert_eq!( + state.accent.persist_value(), + TEAL.to_hex(), + "{text}: 非法值写进配置了" + ); + // tokens 仍可构造,取到的还是上一次合法色 + let ui = Ui::with_accent(SchemeFlavor::Light, state.accent.color().unwrap(), FAMILIES); + assert_eq!(ui.color(Role::Primary), good_primary); + } + + // 空串是**合法**的「回出厂板」,不是错误 + state.apply(Change::Accent(String::new())); + assert_eq!(state.accent.error(), None, "空串不是错误"); + assert!(state.accent.color().is_none(), "空串 = 出厂板"); + assert_eq!(state.accent.persist_value(), ""); + + // 小写、缺 `#` 都吃得下;落盘一律规范化成大写的 `#RRGGBB` + state.apply(Change::Accent(TEAL.to_hex().to_lowercase())); + assert_eq!(state.accent.persist_value(), TEAL.to_hex()); + state.apply(Change::Accent( + TEAL.to_hex().trim_start_matches('#').to_owned(), + )); + assert_eq!(state.accent.persist_value(), TEAL.to_hex()); +} diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs index 50b6e18..573c8fa 100644 --- a/crates/app-iced/src/pages/harness/app.rs +++ b/crates/app-iced/src/pages/harness/app.rs @@ -98,7 +98,12 @@ pub fn run(request: Request) -> Result<(), String> { // 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。 // 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。 - let appearance = request.real_appearance.then(|| { + // + // M250:**gallery 也要这一份** —— 强调色出图矩阵拍的是组件库(buttons + progress), + // 而 `--appearance=real` 按设计只对设置页生效(`request.rs` 的守卫,别绕过它)。所以判据加 + // 一条:设了 `ULL_APPEARANCE_ACCENT` 就造状态,让 `Ui` 按强调色求值(设置页的语义不变)。 + let wants_accent = std::env::var_os(appearance::accent::ENV_ACCENT).is_some(); + let appearance = (request.real_appearance || wants_accent).then(|| { let mut state = appearance::State::new( &AppearancePrefs::factory(), &AppearanceBase::factory(), @@ -116,7 +121,7 @@ pub fn run(request: Request) -> Result<(), String> { } eprintln!( "[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \ - 配色来源={:?} 选中分组={}", + 配色来源={:?} accent={} 选中分组={}", state.window_opacity, state.panel_alpha, state.effective_panel_alpha(), @@ -124,6 +129,7 @@ pub fn run(request: Request) -> Result<(), String> { state.bg_fill, state.bg_dim, state.palette_source, + state.accent.log_token(), state.selected_group, ); state @@ -149,7 +155,12 @@ pub fn run(request: Request) -> Result<(), String> { } ( Harness { - ui: Ui::new(boot.flavor, families), + // M250:gallery 出图也要能看见强调色 —— 与产品路径同一条规则(显式强调色 > + // 出厂板),来源是 `ULL_APPEARANCE_ACCENT`(`State::apply_env` 灌进来的)。 + ui: match appearance.as_ref().and_then(|state| state.accent.color()) { + Some(accent) => Ui::with_accent(boot.flavor, accent, families), + None => Ui::new(boot.flavor, families), + }, model, appearance: appearance.clone(), form: crate::pages::demo_launch_form(), diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index ab4f3ae..7b2aab5 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -9,6 +9,8 @@ //! `Scheme::baseline`(tone 派生)退回它本来的角色——D38 取色链的中性锚点。两条路径并列: //! //! - [`Ui::brand`]:出厂外观,色板自己决定深浅([`BrandBoard::flavor`]); +//! - [`Ui::with_accent`]:用户自定义强调色接管 primary/secondary/tertiary 三族(M250), +//! 中性表面与 error 保持基线; //! - [`Ui::from_scheme`]:背景图 / 壁纸取色成功时接管 49 个角色(取色失败回落色板); //! - [`Ui::baseline`]:老的 tone 派生口径,只给对照与测试用(产品路径不再默认走它)。 //! @@ -124,6 +126,19 @@ impl Ui { } } + /// 按**用户自定义强调色**求值(M250 ACCENTWIRE):`DesignTokens::with_accent` 的 iced 侧入口。 + /// + /// 语义来自 design 的 [`Scheme::from_accent`](既定产品口径,不是缺口):只换 + /// primary / secondary / tertiary 三族,**中性表面与 error 保持基线**——「换强调色不该让整个 + /// 窗口底色变脏」。所以拿它跟 [`Ui::new`] 比,Primary 类角色会不同,Surface 系列**不会**。 + pub fn with_accent( + flavor: SchemeFlavor, + accent: unlucky_design::color::Color, + families: Families, + ) -> Self { + Self::from_scheme(accent_scheme(flavor, accent), families) + } + /// 当前平台(决定字体族与字重降级)。 pub fn platform(&self) -> Platform { self.platform @@ -315,6 +330,14 @@ pub fn to_iced(color: unlucky_design::color::Color) -> Color { ) } +/// 强调色 → 49 角色方案:`DesignTokens::with_accent` 的**唯一**调用点(M250)。 +/// +/// 放在适配层而不是调用点,是因为「强调色只换三族、中性色保持基线」这条语义属于令牌层; +/// 页面侧只该拿到「一套方案」,不该各自决定换哪几个角色。 +pub fn accent_scheme(flavor: SchemeFlavor, accent: unlucky_design::color::Color) -> Scheme { + unlucky_design::DesignTokens::with_accent(flavor, accent).colors +} + /// 令牌字重 → iced 字重。 pub fn weight_of(weight: Weight) -> iced::font::Weight { match weight { diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 97a74b4..7cba6a7 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -317,7 +317,7 @@ pub fn launcher_schema() -> SettingSchema { "", 9, AiPolicy::audited(), - "自定义强调色 #RRGGBB(空 = 出厂 #0078D4;accent 令牌全域生效)", + "自定义强调色 #RRGGBB(空 = 出厂品牌板;非空时只换 primary/secondary/tertiary 三族,中性表面与 error 保持基线;非法值不写盘,设置页给错行)", )) // M91(P4 / D36·D37·D38):「外观下层」三键——只登记取值与范围,判定与落地都在 UI 侧。 // 分级同 D17 那批(audited/Medium):纯视觉、可一键复原,但要留审计痕迹。 diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index ad363e6..7a61255 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -195,8 +195,8 @@ use loader::loader_entries; use logs::logs_entries; use nav::nav_entries; use settings::{ - settings_ai_entries, settings_appearance_entries, settings_download_entries, - settings_general_entries, settings_java_entries, + settings_accent_entries, settings_ai_entries, settings_appearance_entries, + settings_download_entries, settings_general_entries, settings_java_entries, }; use update::update_entries; @@ -233,6 +233,9 @@ nav_entries! { // M168:中文字体下载状态行(设置页 General 组)。同样只追加在链尾之后—— // `catalog/font.rs` 那四条老文案在链中间,往那里追加会平移其后所有 key(金表整片红)。 font_install_entries, + // M250:强调色一族(设置页 Appearance 组的第一个控件)。同理追加在链尾——`appearance` + // 那个域在链中间,往那里追加会平移其后所有 key(金表整片红)。 + settings_accent_entries, finish, {} } diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index a9621ec..eda1643 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -233,4 +233,11 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"), ("FontCjkStatusUnprobeable", "System fonts cannot be probed, so nothing was downloaded", "无法探测系统字体,因此没有下载"), ("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), + ("SettingsAppearanceAccent", "Accent colour", "强调色"), + ("SettingsAppearanceAccentPresets", "Presets", "预设"), + ("SettingsAppearanceAccentFactory", "Factory", "出厂"), + ("SettingsAppearanceAccentHint", "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error keep the baseline.", "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与 error 保持基线。"), + ("SettingsAppearanceAccentShort", "Accent needs 6 hex digits (RRGGBB); got {digits}.", "强调色要 6 位十六进制(RRGGBB),现在 {digits} 位。"), + ("SettingsAppearanceAccentLong", "Accent has {digits} digits; a colour takes exactly 6.", "强调色有 {digits} 位,一个颜色只要 6 位。"), + ("SettingsAppearanceAccentDigit", "Accent contains a character that is not a hex digit: {ch}", "强调色里有非十六进制字符:{ch}"), ]; diff --git a/crates/i18n/src/catalog/settings/accent.rs b/crates/i18n/src/catalog/settings/accent.rs new file mode 100644 index 0000000..32d7180 --- /dev/null +++ b/crates/i18n/src/catalog/settings/accent.rs @@ -0,0 +1,48 @@ +//! 设置页「Appearance」分组**强调色**一族的文案(M250 ACCENTWIRE)。 +//! +//! ## 为什么单开一个域文件,而不是写进 `settings/appearance.rs` +//! +//! `settings_appearance_entries` 在装配链**中间**。往那个域后面追加,会把其后所有 key 在 +//! `Key::ALL` 里整体平移 ⇒ 金表(逐 index 逐字节)整片红。本域挂在**链尾** +//! (`font_install_entries` 之后),于是既有顺序一个不动,金表只需在 `tail2::ROWS` 末尾追加 +//! (M104 / M106 / M107 / M109 / M114 / M159 / M168 都是这条做法)。 +//! +//! key 前缀仍叫 `SettingsAppearanceAccent*`:`catalog/settings/appearance.rs` 的译文守卫按 +//! **名字前缀**筛 key,改名会让那两条守卫漏掉这一族。 +//! +//! ## 三档失败原因为什么分开写 +//! +//! `#12`(位数不足)、`#006A5C00`(位数过多)、`zzzzzz`(非十六进制)是三种不同的手误, +//! 而「格式不对」对用户没有行动性。分开写之后错行能直接说「现在 2 位」「有非十六进制字符 z」。 +//! +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。 + +macro_rules! settings_accent_entries { + ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { + $next! { $($rest,)* { + $($acc)* + // ── 强调色(M250:设置页 Appearance 组的第一个控件) + SettingsAppearanceAccent => ("Accent colour", "强调色"), + SettingsAppearanceAccentPresets => ("Presets", "预设"), + SettingsAppearanceAccentFactory => ("Factory", "出厂"), + SettingsAppearanceAccentHint => ( + "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error keep the baseline.", + "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与 error 保持基线。" + ), + SettingsAppearanceAccentShort => ( + "Accent needs 6 hex digits (RRGGBB); got {digits}.", + "强调色要 6 位十六进制(RRGGBB),现在 {digits} 位。" + ), + SettingsAppearanceAccentLong => ( + "Accent has {digits} digits; a colour takes exactly 6.", + "强调色有 {digits} 位,一个颜色只要 6 位。" + ), + SettingsAppearanceAccentDigit => ( + "Accent contains a character that is not a hex digit: {ch}", + "强调色里有非十六进制字符:{ch}" + ), + } } + }; +} + +pub(crate) use settings_accent_entries; diff --git a/crates/i18n/src/catalog/settings/mod.rs b/crates/i18n/src/catalog/settings/mod.rs index b2746d9..9085834 100644 --- a/crates/i18n/src/catalog/settings/mod.rs +++ b/crates/i18n/src/catalog/settings/mod.rs @@ -7,6 +7,7 @@ //! | 文件 | 分组(`pages/settings` 的 `GROUPS`) | 现状 | //! |---|---|---| //! | [`general`] | General(Language / Theme / Startup page / Close button) | 2 条 key(语言) | +//! | [`accent`] | Appearance 组的**强调色**一族(M250) | 7 条 key(标签 / 预设 / 三档失败原因 / 说明) | //! | [`appearance`] | Appearance(Density / Corner style / Motion / Text scale) | 预留,暂无 key | //! | [`java`] | Java(Runtime / Default memory / JVM arguments) | 预留,暂无 key | //! | [`download`] | Downloads(Preferred source / Concurrency / Verify checksums) | 预留,暂无 key | @@ -15,12 +16,14 @@ //! 「预留」的文件里是**空转移器**(只把累积表原样交给下一个域):加 key 时把条目写进它 //! `$($acc)*` 后面即可——签名不用改,`catalog.rs` 不用碰,跨线零冲突。 +mod accent; mod ai; mod appearance; mod download; mod general; mod java; +pub(crate) use accent::settings_accent_entries; pub(crate) use ai::settings_ai_entries; pub(crate) use appearance::settings_appearance_entries; pub(crate) use download::settings_download_entries; diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index a0dc13a..5441456 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -39,6 +39,72 @@ 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 { + 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 { @@ -158,22 +224,21 @@ impl AppearancePrefs { self.bg_image_enabled && !self.bg_image.trim().is_empty() } - /// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂)。 - /// - /// 返回 `false` 表示输入不合法(调用方据此亮设置页的 warn 行,不改现状)。 + /// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂);返回 `false` = 输入不合法、 + /// 现状不变。需要**原因**时走 [`AppearancePrefs::set_accent_checked`](设置页走那条)。 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, - } + 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` 个颜色(越界忽略)。 From e345b55c093a1046b93f4a1e8149cabd6b14c133 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 15:10:03 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(app-iced):=20=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E8=89=B2=E6=9D=BF=E6=94=B9=E6=88=90=E4=B8=A4=E8=A1=8C=E6=8E=92?= =?UTF-8?q?=E7=89=88=EF=BC=8C=E6=B3=A8=E9=87=8A=E4=B8=8E=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=89=B2=E5=80=BC=E5=AD=97=E9=9D=A2=E9=87=8F?= =?UTF-8?q?=EF=BC=88M250=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预设色板原来挤在一行,出图实测被折成「7 + 1」(最后一颗掉到下一行)——改成 「标签 + 出厂」一行、8 颗色块一行;这是有意排版,不是被挤出来的 - src/** 的色值字面量门禁把注释与测试一起扫(那条门禁是对的):文档里的 `#RRGGBB` 实例改成不带 `#` 的描述,超长串用例改用 `TEAL.to_hex()` 拼出来 --- .../app-iced/src/pages/appearance/accent.rs | 4 +- .../src/pages/appearance/section/accent.rs | 40 ++++++++++++------- .../src/pages/appearance/tests/accent.rs | 16 ++++---- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/crates/app-iced/src/pages/appearance/accent.rs b/crates/app-iced/src/pages/appearance/accent.rs index 2b63aee..7305cff 100644 --- a/crates/app-iced/src/pages/appearance/accent.rs +++ b/crates/app-iced/src/pages/appearance/accent.rs @@ -7,7 +7,7 @@ //! | 输入 | 生效配色 | `error` | 落盘值 | //! |---|---|---|---| //! | 空串 | 出厂品牌板(`DesignTokens::brand`) | `None` | `""` | -//! | `#006A5C` | `DesignTokens::with_accent`(只换三族) | `None` | `#006A5C` | +//! | `006A5C`(青绿) | `DesignTokens::with_accent`(只换三族) | `None` | `#` + `006A5C` | //! | `#12` / `zzzzzz` / 超长串 | **保持上一次合法的值**(不静默回退成出厂色) | `Some(..)` | 上一次合法的值 | //! //! 「保持上一次合法值」而不是「回退出厂板」是刻意的:回退会让「填错」和「填对但没生效」在屏幕上 @@ -103,7 +103,7 @@ impl Accent { self.choice.as_str() } - /// 取证日志里的一个词:`factory` / `#006A5C` / `invalid(Short { digits: 2 })`。 + /// 取证日志里的一个词:`factory` / 规范化的 hex / `invalid(Short { digits: 2 })`。 /// /// 为什么日志要带这个:截图与真机复现只能靠一行文字分辨「强调色没生效」与「强调色生效了但 /// 图上看不出来」——后者是这条 mission 的验收判据,前者是它的 bug。 diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs index 6348134..92e4162 100644 --- a/crates/app-iced/src/pages/appearance/section/accent.rs +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -15,7 +15,7 @@ //! //! ## 色块为什么画成圆点而不是药丸 //! -//! 9 颗写着 `#0078D4` 的药丸会变成一排噪声,而且色相要靠读字获取——色板控件的可见内容**就是** +//! 9 颗写着十六进制串的药丸会变成一排噪声,而且色相要靠读字获取——色板控件的可见内容**就是** //! 颜色本身。所以色块 = `ShapeScale::Full` 的实心圆 + 描边;选中态加粗描边(不靠色相区分: //! 色块底下垫的是它自己的颜色,色相对比不可靠)。 @@ -80,30 +80,40 @@ pub(super) fn push<'a>( )); } -/// 「预设」一行:`出厂` + 8 颗色块。 +/// 「预设」两行:先「标签 + 出厂」,再一排 8 颗色块。 +/// +/// 为什么分两行:一行放 9 个控件在设置卡片的正文宽度里会被挤成「7 + 1」折行,最后一颗色块 +/// 孤零零掉到下一行(出图实测)。分两行是**有意**的排版,不是被挤出来的。 fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Message> { let current = accent.persist_value(); - let mut items: Vec> = vec![ + let head = row![ container(label(ui, t(Key::SettingsAppearanceAccentPresets, lang))) - .align_y(iced::Alignment::Center) - .into(), + .align_y(iced::Alignment::Center), choice( ui, t(Key::SettingsAppearanceAccentFactory, lang), current.is_empty(), Change::Accent(String::new()), ), - ]; - for hex in ACCENT_PRESETS { - items.push(swatch(ui, hex, current == hex)); - } + ] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center); - row(items) - .spacing(ui.space(Space::X2)) - .padding(Padding::from([ui.space(Space::X2), 0.0])) - .align_y(iced::Alignment::Center) - .wrap() - .into() + let swatches: Vec> = ACCENT_PRESETS + .iter() + .map(|hex| swatch(ui, hex, current == *hex)) + .collect(); + + column![ + head, + row(swatches) + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() } /// 一颗色块:实心圆 + 描边(选中态加粗)。点击写入该预设。 diff --git a/crates/app-iced/src/pages/appearance/tests/accent.rs b/crates/app-iced/src/pages/appearance/tests/accent.rs index bc372bc..8c0477f 100644 --- a/crates/app-iced/src/pages/appearance/tests/accent.rs +++ b/crates/app-iced/src/pages/appearance/tests/accent.rs @@ -7,11 +7,11 @@ use super::Change; -/// `#006A5C`(青绿)由 design 的 `Color` 拼出来:本 crate 的 `src/**` 不许出现 `#RRGGBB` +/// 青绿(`00 6A 5C`)由 design 的 `Color` 拼出来:本 crate 的 `src/**` 不许出现十六进制色值 /// 字面量(`tests/no_hardcoded_tokens.rs` 把测试文件也扫在内——那条门禁是对的,色值只能来自令牌)。 const TEAL: unlucky_design::color::Color = unlucky_design::color::Color::rgb(0x00, 0x6A, 0x5C); -/// 判据一:`accent_color` 为空 与 为 `#006A5C` 时,**同一组件取到的 primary 类角色色值必须不同**。 +/// 判据一:`accent_color` 为空 与 为青绿(见 [`TEAL`])时,**同一组件取到的 primary 类角色色值必须不同**。 /// /// 取色点是 `progress::linear_colors`(进度条指示条 = `Role::Primary`,实心按钮的容器也是它): /// 断言的是**取到的值**,不是「没 panic」——把接线改回「恒走出厂板」,两边相等当场红。 @@ -48,7 +48,7 @@ fn custom_accent_changes_the_component_colours_it_reaches() { ); let factory = Ui::new(SchemeFlavor::Light, FAMILIES); - // 填 `#006A5C` = `rebuild_ui` 的 `(Some(accent), _)` 分支 + // 填青绿 = `rebuild_ui` 的 `(Some(accent), _)` 分支 state.apply(Change::Accent(TEAL.to_hex())); assert_eq!(state.accent.error(), None, "合法 hex 不该报错"); let accent = state.accent.color().expect("合法 hex 必须解析成颜色"); @@ -129,12 +129,14 @@ fn illegal_accent_reports_a_reason_and_never_silently_falls_back() { let good = state.accent.color().expect("先放一个合法值"); let good_primary = Ui::with_accent(SchemeFlavor::Light, good, FAMILIES).color(Role::Primary); + // 超长串由合法值拼出来:`src/**` 的色值字面量门禁把注释与测试也算在内(那条门禁是对的)。 + let long = format!("{}00112233", TEAL.to_hex()); for (text, expected) in [ - ("#12", AccentError::Short { digits: 2 }), - ("zzzzzz", AccentError::Digit { ch: 'z' }), - ("#006A5C00112233", AccentError::Long { digits: 14 }), + ("#12".to_owned(), AccentError::Short { digits: 2 }), + ("zzzzzz".to_owned(), AccentError::Digit { ch: 'z' }), + (long, AccentError::Long { digits: 14 }), ] { - state.apply(Change::Accent(text.to_owned())); + state.apply(Change::Accent(text.clone())); assert_eq!( state.accent.error(), Some(expected), From c609827c495d17c709afc6382e29ddf725fda03a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 15:12:15 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore(app-iced):=20=E6=92=A4=E5=9B=9E?= =?UTF-8?q?=E8=B6=8A=E7=95=8C=E7=9A=84=E6=B3=A8=E9=87=8A=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=EF=BC=88app/state.rs=20=E4=B8=8D=E5=9C=A8=20M250=20scope?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-iced/src/app/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 8028f26..0cf752e 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -148,7 +148,7 @@ pub struct App { /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 pub(super) appearance: pages::appearance::State, /// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段, - /// 没动过的字段原样保留;M250 起强调色也在被改的那一列里)。 + /// 强调色这类本 mission 不管的字段原样保留)。 pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 pub(super) hwnd: Option, From 14282f332539af25578c861208c33a6f3bd01394 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 15:46:10 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(app-iced):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E7=9A=84=E5=9B=9B=E6=9D=A1=20p2=20+=20=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=E5=B0=8F=E4=BA=8B=EF=BC=88M250=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - p2-1 删死状态:`Accent::scheme` 字段 / `scheme()` 取值口 / `set_flavor` 重派生全仓零读者, 且注释谎称「Ui 的唯一入口」。色值 `color` 与深浅无关,`Ui` 由 `rebuild_ui` 重算, 所以整条删掉,`new/set/reset` 也不再收 `flavor` - p2-2 规则只定义一次:新增 `theme::tokens(flavor, accent, scheme, families) -> (Ui, Colors)`, `App::rebuild_ui` 与出图 harness 都调它(harness 传 scheme=None,如实说明不覆盖 D38 分支); README 改成「同一条规则、不是同一个调用点」 - p2-3 逐键不闪红:`Accent` 区分「显示中的错」与 `pending`。只有**不可能再变合法**(非 hex / 超 6 位) 才立刻亮;位数不足先挂 pending,等 `commit()`(点到别的外观控件)或配置读回才亮 - p2-4 订正 `app/state.rs:151` 那句反话(强调色现在是 UI 写入字段) - 小事:hint 文案改「中性表面与错误色不随强调色变」;`Digit{ch}` 里的不可见字符显示成 U+XXXX; 8 颗色块补 tooltip(hex 原文,Overlay 底) 判据:新用例 `accent_errors_only_appear_when_they_are_actionable` 覆盖「输入中不亮 / 提交后亮 / 配置读回立刻亮」三态;gallery 出图逐字节与重构前相同(规则搬迁没改像素)。 --- crates/app-iced/src/app/state.rs | 2 +- crates/app-iced/src/app/state/theme.rs | 25 ++--- .../app-iced/src/pages/appearance/accent.rs | 102 +++++++++++------- .../src/pages/appearance/section/accent.rs | 47 +++++++- crates/app-iced/src/pages/appearance/state.rs | 21 ++-- .../src/pages/appearance/tests/accent.rs | 85 ++++++++++++++- crates/app-iced/src/pages/harness/app.rs | 17 +-- crates/app-iced/src/theme.rs | 34 ++++++ crates/i18n/src/catalog/golden/tail2.rs | 2 +- crates/i18n/src/catalog/settings/accent.rs | 4 +- 10 files changed, 261 insertions(+), 78 deletions(-) diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 0cf752e..318da94 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -148,7 +148,7 @@ pub struct App { /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 pub(super) appearance: pages::appearance::State, /// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段, - /// 强调色这类本 mission 不管的字段原样保留)。 + /// 没动过的字段原样保留;M250 起强调色也在「真改了」的那一列里)。 pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 pub(super) hwnd: Option, diff --git a/crates/app-iced/src/app/state/theme.rs b/crates/app-iced/src/app/state/theme.rs index 9243282..154ce00 100644 --- a/crates/app-iced/src/app/state/theme.rs +++ b/crates/app-iced/src/app/state/theme.rs @@ -53,36 +53,37 @@ impl App { // 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一** // 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。 let panel_alpha = self.appearance.effective_panel_alpha(); - self.ui = match ( + // 三选一规则只在 `theme::tokens` 里写一遍:出图 harness 也调它(两头各抄一份迟早 + // 出现「图上有强调色、产品里没有」,而那种偏差在图上看不出来)。 + let (ui, colors) = crate::theme::tokens( + self.appearance.flavor(), self.appearance.accent.color(), - self.appearance.derived_scheme(), - ) { - (Some(accent), _) => { + self.appearance.derived_scheme().copied(), + self.families, + ); + match colors { + crate::theme::Colors::Accent => { // M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。 diag!( - "[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;\ - 取色方案 palette={} 被显式强调色压住)", + "[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;取色方案 palette={} 被压住)", self.appearance.accent.log_token(), self.appearance.palette_level() ); - Ui::with_accent(self.appearance.flavor(), accent, self.families) } - (None, Some(scheme)) => { + crate::theme::Colors::Scheme => { diag!( "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", self.appearance.palette_level() ); - Ui::from_scheme(*scheme, self.families) } - (None, None) => { + crate::theme::Colors::Board => { // 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。 if let Some(error) = self.appearance.accent.error() { diag!("[appearance] accent 非法({error:?})→ 出厂板,错行见设置页"); } - Ui::new(self.appearance.flavor(), self.families) } } - .with_panel_alpha(panel_alpha); + self.ui = ui.with_panel_alpha(panel_alpha); } /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。 diff --git a/crates/app-iced/src/pages/appearance/accent.rs b/crates/app-iced/src/pages/appearance/accent.rs index 7305cff..59039b2 100644 --- a/crates/app-iced/src/pages/appearance/accent.rs +++ b/crates/app-iced/src/pages/appearance/accent.rs @@ -1,26 +1,36 @@ -//! 强调色的**运行期状态**(M250 ACCENTWIRE):输入原文 → 解析结果 → 生效方案。 +//! 强调色的**运行期状态**(M250 ACCENTWIRE):输入原文 → 解析结果 → 生效色。 //! //! 这一层存在的理由:`appearance.accent_color` 此前是死线——键、输入框、presenter 往返都在, //! 但造 tokens 的唯一入口恒走 `DesignTokens::brand(board)`,用户填了色界面没变化。接线要落地 //! 的不只是「把 hex 喂进 `Scheme::from_accent`」,还有**失败要可见**: //! -//! | 输入 | 生效配色 | `error` | 落盘值 | +//! | 输入 | 生效配色 | `error()` | 落盘值 | //! |---|---|---|---| //! | 空串 | 出厂品牌板(`DesignTokens::brand`) | `None` | `""` | -//! | `006A5C`(青绿) | `DesignTokens::with_accent`(只换三族) | `None` | `#` + `006A5C` | -//! | `#12` / `zzzzzz` / 超长串 | **保持上一次合法的值**(不静默回退成出厂色) | `Some(..)` | 上一次合法的值 | +//! | `006A5C` | `DesignTokens::with_accent`(只换三族) | `None` | `#` + `006A5C` | +//! | `zzzzzz` / 超长串 | **保持上一次合法的值**(不静默回退成出厂色) | `Some(..)` | 上一次合法的值 | +//! | `#12`(还没打完) | 同上(还没生效) | `None`,`pending` 里挂着 | 上一次合法的值 | //! //! 「保持上一次合法值」而不是「回退出厂板」是刻意的:回退会让「填错」和「填对但没生效」在屏幕上 //! 长得一模一样,用户唯一的线索就只剩那条错行——而那正是这里要保证不被丢掉的。 //! +//! ## 何时才算「错」(M250 r1 p2-3) +//! +//! 受控输入框**每敲一个字符**都会调 [`Accent::set`]。手打 `006A5C` 的过程里,前 5 个字符都是 +//! `Short`,若立刻亮红就会闪 5 次——用户没打错,只是没打完。所以只有两种情形立刻报错: +//! +//! 1. **不可能再靠打字变合法**:出现非十六进制字符(`Digit`)或超过 6 位(`Long`)——任何继续 +//! 输入都只会更错,立刻亮; +//! 2. **提交**([`Accent::commit`]):离开这一格、点到别的外观控件、或配置读回。 +//! +//! 配置读回走 [`Accent::new`](`commit` 语义):文件里存着坏值必须**当场**看得见,不能等用户来点。 +//! //! 解析本身不在这里:`AccentChoice::parse` 是唯一真相(`unlucky_presenter::prefs`),本文件只负责 -//! 把结果接到 `Scheme` 与 UI 上。 +//! 把结果接到色值与 UI 上。 -use unlucky_design::color::{Color, Scheme, SchemeFlavor}; +use unlucky_design::color::Color; use unlucky_presenter::prefs::{AccentChoice, AccentError}; -use crate::theme::accent_scheme; - /// 出图矩阵的强调色开关(`ULL_APPEARANCE_ACCENT=#RRGGBB`;产品路径读 `launcher.json`)。 /// /// 放在本文件而不是 `state.rs` 的 `ENV_*` 那一族:`state.rs` 正好 400 行(硬线),这一条 @@ -36,41 +46,45 @@ pub struct Accent { choice: AccentChoice, /// 解析出来的强调色(`None` = 出厂板,强调色由品牌色板决定)。 color: Option, - /// 非法输入的结构化原因(`None` = 没填或填得合法)。 + /// **现在要显示**的结构化原因(`None` = 不显示)。 error: Option, - /// 合法自定义色时的方案(`Ui` 直接用它;`None` = 走出厂品牌板)。 - scheme: Option, + /// 已经打错、但「还可能被打字修好」的原因(输入中暂不显示,[`Accent::commit`] 才亮出来)。 + pending: Option, } impl Accent { - /// 从配置读一份(启动时调一次)。配置里存着非法值时**也**亮错行,不静默吞掉。 + /// 从配置读一份(启动时调一次)。配置里存着非法值时**当场**亮错行,不静默吞掉。 #[must_use] - pub fn new(text: &str, flavor: SchemeFlavor) -> Self { + pub fn new(text: &str) -> Self { let mut accent = Self { text: text.to_owned(), choice: AccentChoice::Factory, color: None, error: None, - scheme: None, + pending: None, }; - accent.retry(flavor); + accent.retry(true); accent } /// 设置页改了一次输入(受控输入框:每敲一个字符都走这里)。 - pub fn set(&mut self, text: &str, flavor: SchemeFlavor) { + /// + /// 「短但还可能是合法前缀」的输入只挂进 `pending`,不亮红(见模块文档 §何时才算「错」)。 + pub fn set(&mut self, text: &str) { self.text = text.to_owned(); - self.retry(flavor); + self.retry(false); } /// 配置被外部改写后重置(热切:CLI / AI 改完 1 秒内生效)。 - pub fn reset(&mut self, text: &str, flavor: SchemeFlavor) { - *self = Self::new(text, flavor); + pub fn reset(&mut self, text: &str) { + *self = Self::new(text); } - /// 深浅切了:强调族要按新模式重派生(表面色仍由 `State::refresh_palette` 那条路管)。 - pub fn set_flavor(&mut self, flavor: SchemeFlavor) { - self.scheme = self.color.map(|accent| accent_scheme(flavor, accent)); + /// 提交:把「输入中暂不报」的错亮出来。离开这一格就该走它(见 [`Accent::pending`])。 + pub fn commit(&mut self) { + if self.error.is_none() { + self.error = self.pending.take(); + } } /// 输入框原文。 @@ -79,53 +93,63 @@ impl Accent { &self.text } - /// 结构化失败原因(`None` = 合法)。 + /// 要显示的结构化失败原因(`None` = 合法,或还没打完)。 #[must_use] pub fn error(&self) -> Option { self.error } - /// 解析出来的强调色(`None` = 出厂板;日志与截图判据读它)。 + /// 解析出来的强调色(`None` = 出厂板;`rebuild_ui` 与出图判据读它)。 #[must_use] pub fn color(&self) -> Option { self.color } - /// 生效的强调色方案(`Ui` 的唯一入口:`Some` ⇒ 走 [`crate::theme::Ui::with_accent`] 的同一条路)。 - #[must_use] - pub fn scheme(&self) -> Option<&Scheme> { - self.scheme.as_ref() - } - /// 落盘值:`""` = 出厂,`#RRGGBB` = 自定义。**永远是上一次合法值**——非法手输不写盘。 #[must_use] pub fn persist_value(&self) -> &str { self.choice.as_str() } - /// 取证日志里的一个词:`factory` / 规范化的 hex / `invalid(Short { digits: 2 })`。 + /// 取证日志里的一个词:`factory` / 规范化的 hex / `invalid(..)` / `pending(..)`。 /// /// 为什么日志要带这个:截图与真机复现只能靠一行文字分辨「强调色没生效」与「强调色生效了但 /// 图上看不出来」——后者是这条 mission 的验收判据,前者是它的 bug。 #[must_use] pub fn log_token(&self) -> String { - match self.error { - Some(error) => format!("invalid({error:?})"), - None if self.choice.as_str().is_empty() => "factory".to_owned(), - None => self.persist_value().to_owned(), + if let Some(error) = self.error { + return format!("invalid({error:?})"); + } + if let Some(error) = self.pending { + return format!("pending({error:?})"); + } + if self.choice.as_str().is_empty() { + "factory".to_owned() + } else { + self.persist_value().to_owned() } } - /// 按当前 `text` 重算:合法 ⇒ 换方案 + 清错;非法 ⇒ **方案不动** + 记错。 - fn retry(&mut self, flavor: SchemeFlavor) { + /// 按当前 `text` 重算:合法 ⇒ 换色 + 清错;非法 ⇒ **色不动**,按 `show` 决定报不报。 + fn retry(&mut self, show: bool) { match AccentChoice::parse(&self.text) { Ok(choice) => { self.color = Color::from_hex(choice.as_str()); - self.scheme = self.color.map(|accent| accent_scheme(flavor, accent)); self.choice = choice; self.error = None; + self.pending = None; + } + Err(error) => { + // 只有「位数不足」还有机会靠继续打字补成合法;非 hex 字符与超 6 位已经回不了头。 + let hopeless = !matches!(error, AccentError::Short { .. }); + if show || hopeless { + self.error = Some(error); + self.pending = None; + } else { + self.pending = Some(error); + self.error = None; + } } - Err(error) => self.error = Some(error), } } } diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs index 92e4162..676cad5 100644 --- a/crates/app-iced/src/pages/appearance/section/accent.rs +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -19,7 +19,8 @@ //! 颜色本身。所以色块 = `ShapeScale::Full` 的实心圆 + 描边;选中态加粗描边(不靠色相区分: //! 色块底下垫的是它自己的颜色,色相对比不可靠)。 -use iced::widget::{button, column, container, row, Space as Gap}; +use iced::widget::tooltip::{self, Tooltip}; +use iced::widget::{button, column, container, row, text, Space as Gap}; use iced::{Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; use unlucky_design::color::Role; @@ -129,7 +130,7 @@ fn swatch<'a>(ui: &'a Ui, hex: &'static str, selected: bool) -> Element<'a, Mess let width = if selected { STROKE_PX * 2.0 } else { STROKE_PX }; let radius = ui.radius(ShapeScale::Full, diameter); - button( + let dot: Element<'a, Message> = button( Gap::new() .width(Length::Fixed(diameter)) .height(Length::Fixed(diameter)), @@ -150,7 +151,32 @@ fn swatch<'a>(ui: &'a Ui, hex: &'static str, selected: bool) -> Element<'a, Mess snap: true, }) .on_press(Message::Appearance(Change::Accent(hex.to_owned()))) - .into() + .into(); + + // 色块本体只有颜色、没有字:补一条可读提示(hex),否则键盘与读屏用户只能靠位置辨色。 + Tooltip::new(dot, hint(ui, hex), tooltip::Position::Top).into() +} + +/// 色块的悬停提示:`Overlay` 底 + `BodySmall` 的 hex 原文(与 `pages/agent` 的 tooltip 同一手法)。 +fn hint<'a>(ui: &'a Ui, hex: &'a str) -> iced::widget::Container<'a, Message> { + let style = ui.style(unlucky_design::typography::TypeRole::BodySmall); + container( + text(hex.to_owned()) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurface)), + ) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme: &IcedTheme| container::Style { + background: Some(Background::Color( + ui.layer(unlucky_design::elevation::MaterialLayer::Overlay), + )), + border: Border { + radius: ui.radius_dp(ShapeScale::Small), + ..Border::default() + }, + ..container::Style::default() + }) } /// 结构化原因 → 一句话(参数由 Rust 侧格式化,词表只认 `{name}`)。 @@ -169,7 +195,20 @@ fn reason(lang: Lang, error: AccentError) -> String { AccentError::Digit { ch } => tr( lang, Key::SettingsAppearanceAccentDigit, - &[("ch", &ch.to_string())], + &[("ch", &visible(ch))], ), } } + +/// 把一个字符变成**看得见**的形态:控制字符 / 空白 / 零宽与双向控制符(`Cf`)一律写 `U+XXXX`。 +/// +/// 为什么不能直接 `ch.to_string()`:用户粘进来的可能是不换行空格、零宽空格或 RTL 覆盖符—— +/// 那几个字符在句子里**看不见**,错行会变成「强调色里有非十六进制字符:」后面一片空白, +/// 用户根本不知道自己错在哪。 +fn visible(ch: char) -> String { + if ch.is_alphanumeric() || ch.is_ascii_graphic() { + ch.to_string() + } else { + format!("U+{:04X}", u32::from(ch)) + } +} diff --git a/crates/app-iced/src/pages/appearance/state.rs b/crates/app-iced/src/pages/appearance/state.rs index 13a51fa..80a2595 100644 --- a/crates/app-iced/src/pages/appearance/state.rs +++ b/crates/app-iced/src/pages/appearance/state.rs @@ -98,7 +98,7 @@ impl State { palette_source: base.palette_source, window_corner: base.window_corner, glass: prefs.glass, - accent: Accent::new(&prefs.accent_color, flavor), + accent: Accent::new(&prefs.accent_color), backend, selected_group: super::APPEARANCE_GROUP, image: None, @@ -137,7 +137,8 @@ impl State { self.palette_source = PaletteSource::from_key_value(&value.to_string_lossy()); } if let Some(value) = std::env::var_os(ENV_ACCENT) { - self.accent.set(&value.to_string_lossy(), self.flavor); + // 取证开关 = 「像刚敲进去的」(走输入中规则),不是配置读回。 + self.accent.set(&value.to_string_lossy()); } self.refresh_background(); self.refresh_palette(); @@ -225,14 +226,12 @@ impl State { } } - /// 深浅切换后重算配色(`Ui` 换主题时调)。 + /// 深浅切换后重算配色(`Ui` 换主题时调)。强调色不参与:它是用户给的绝对色值,与深浅无关。 pub fn set_flavor(&mut self, flavor: SchemeFlavor) { if self.flavor == flavor { return; } self.flavor = flavor; - // M250:自定义强调色也要按新模式重派生(否则切深浅后强调族停在旧模式)。 - self.accent.set_flavor(flavor); self.refresh_palette(); } @@ -245,6 +244,10 @@ impl State { /// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。 pub fn apply(&mut self, change: super::Change) -> bool { use super::Change; + // M250 p2-3:其余外观改动都意味着用户离开了强调色那一格 ⇒ 提交(理由见 `accent` 模块文档)。 + if !matches!(change, Change::Accent(_)) { + self.accent.commit(); + } match change { Change::WindowOpacity(alpha) => { self.window_opacity = alpha; @@ -285,8 +288,7 @@ impl State { false } Change::Accent(text) => { - // M250:非法输入不改生效色(`Accent::set` 保留上一次合法值),只挂原因。 - self.accent.set(&text, self.flavor); + self.accent.set(&text); false } Change::SelectGroup(index) => { @@ -298,8 +300,7 @@ impl State { /// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 9 个字段)。 /// - /// M250 起 `accent_color` 也在里面:以前这里恒给空串(当时「强调色不在本 mission 管辖内」), - /// 而 `persist_appearance` 拿配置里的旧快照做基底 ⇒ 设置页改的强调色落不了盘。现在给**上一次合法值**。 + /// M250 起 `accent_color` 也在里面(以前恒给空串 ⇒ 设置页改的强调色落不了盘),给的是**上一次合法值**。 #[must_use] pub fn current_values(&self) -> AppearancePrefs { AppearancePrefs { @@ -351,7 +352,7 @@ impl State { self.bg_dim = prefs.bg_dim; self.glass = prefs.glass; // M250:文件被外部改过(CLI / AI)时强调色也要热生效,错行同样重置。 - self.accent.reset(&prefs.accent_color, self.flavor); + self.accent.reset(&prefs.accent_color); if self.bg_path != prefs.bg_image { self.bg_path = prefs.bg_image.clone(); } diff --git a/crates/app-iced/src/pages/appearance/tests/accent.rs b/crates/app-iced/src/pages/appearance/tests/accent.rs index 8c0477f..17c2d4c 100644 --- a/crates/app-iced/src/pages/appearance/tests/accent.rs +++ b/crates/app-iced/src/pages/appearance/tests/accent.rs @@ -130,9 +130,11 @@ fn illegal_accent_reports_a_reason_and_never_silently_falls_back() { let good_primary = Ui::with_accent(SchemeFlavor::Light, good, FAMILIES).color(Role::Primary); // 超长串由合法值拼出来:`src/**` 的色值字面量门禁把注释与测试也算在内(那条门禁是对的)。 + // + // 三档里只有后两档**当场**亮:`#12` 是「还可能是合法前缀」(用户可能正打到一半),它的 + // 亮灯时机由 `accent_errors_only_appear_when_they_are_actionable` 与 `commit` 覆盖。 let long = format!("{}00112233", TEAL.to_hex()); for (text, expected) in [ - ("#12".to_owned(), AccentError::Short { digits: 2 }), ("zzzzzz".to_owned(), AccentError::Digit { ch: 'z' }), (long, AccentError::Long { digits: 14 }), ] { @@ -140,7 +142,7 @@ fn illegal_accent_reports_a_reason_and_never_silently_falls_back() { assert_eq!( state.accent.error(), Some(expected), - "{text}: 失败原因必须是结构化的(不是一句 bool / 一句「格式不对」)" + "{text}: 不可能变合法的输入必须当场亮且原因结构化(不是一句 bool / 一句「格式不对」)" ); assert_eq!( state.accent.color(), @@ -157,6 +159,17 @@ fn illegal_accent_reports_a_reason_and_never_silently_falls_back() { assert_eq!(ui.color(Role::Primary), good_primary); } + // `#12`:输入中不亮,**提交**后必须亮(否则「非法值可见」会在短输入上落空) + state.apply(Change::Accent("#12".to_owned())); + assert_eq!(state.accent.error(), None, "#12: 输入中先不亮"); + state.accent.commit(); + assert_eq!( + state.accent.error(), + Some(AccentError::Short { digits: 2 }), + "#12: 提交后必须亮且原因正确" + ); + assert_eq!(state.accent.color(), Some(good), "#12: 提交也不许改生效色"); + // 空串是**合法**的「回出厂板」,不是错误 state.apply(Change::Accent(String::new())); assert_eq!(state.accent.error(), None, "空串不是错误"); @@ -171,3 +184,71 @@ fn illegal_accent_reports_a_reason_and_never_silently_falls_back() { )); assert_eq!(state.accent.persist_value(), TEAL.to_hex()); } + +/// **p2-3**:错行与 `Field::error()` 的出现时机 —— 只在「提交」或「不可能再变合法」时。 +/// +/// 抓得住:把 `Accent::retry` 的延后逻辑删掉(所有错都立刻亮)⇒ 第一段当场红(手打青绿 +/// 的每一个前缀都会亮);把 `commit` 改成空实现 ⇒ 第二段红(提交后不亮,等于「非法值不可见」); +/// 把配置读回也走延后 ⇒ 第三段红。 +/// 抓不住:像素(红框与红字到底画没画)——那是出图与 `every_appearance_card_key_is_drawn` 的事。 +#[test] +fn accent_errors_only_appear_when_they_are_actionable() { + use crate::cli::Backend; + use unlucky_design::color::SchemeFlavor; + use unlucky_presenter::prefs::{AccentError, AppearanceBase, AppearancePrefs}; + + use super::super::State; + + let factory = AppearancePrefs::factory(); + let mut state = State::new( + &factory, + &AppearanceBase::factory(), + Backend::Gpu, + SchemeFlavor::Light, + ); + + // ① 手打青绿的过程:每个**合法前缀**都不许亮红(否则一次输入闪 5 次) + for prefix in ["#", "#0", "#00", "#006", "#006A", "#006A5"] { + state.apply(Change::Accent(prefix.to_owned())); + assert_eq!( + state.accent.error(), + None, + "{prefix:?}: 还没打完就亮红了(手打一个合法色会闪好几次)" + ); + assert!( + state.accent.color().is_none(), + "{prefix:?}: 没打完的输入不该生效" + ); + } + state.apply(Change::Accent(TEAL.to_hex())); + assert_eq!(state.accent.error(), None); + assert!(state.accent.color().is_some(), "打完了就该生效"); + + // ② 输入中留着不打完 ⇒ 不亮;**提交**(点到别的外观控件)⇒ 亮且原因正确 + state.apply(Change::Accent("#12".to_owned())); + assert_eq!(state.accent.error(), None, "输入中先不亮"); + state.apply(Change::PanelAlpha(0.8)); + assert_eq!( + state.accent.error(), + Some(AccentError::Short { digits: 2 }), + "离开这一格之后必须亮,否则非法值就不可见了" + ); + + // ③ 配置读回(`Accent::new`)⇒ **当场**亮,不等用户来点 + let bad = AppearancePrefs { + accent_color: "#12".to_owned(), + ..AppearancePrefs::factory() + }; + let loaded = State::new( + &bad, + &AppearanceBase::factory(), + Backend::Gpu, + SchemeFlavor::Light, + ); + assert_eq!( + loaded.accent.error(), + Some(AccentError::Short { digits: 2 }), + "配置文件里的坏值必须当场可见" + ); + assert!(loaded.accent.color().is_none(), "坏值不生效,走出厂板"); +} diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs index 573c8fa..de198f9 100644 --- a/crates/app-iced/src/pages/harness/app.rs +++ b/crates/app-iced/src/pages/harness/app.rs @@ -18,7 +18,6 @@ use crate::fonts; use crate::pages::appearance; use crate::probe::{self, Probe}; use crate::renderer; -use crate::theme::Ui; use super::{demo_model, Harness, Message, Request, CAPTURE_POSITION}; @@ -153,14 +152,18 @@ pub fn run(request: Request) -> Result<(), String> { if boot.screenshot.is_some() { model.forced = Some(Interaction::Enabled); } + // M250:gallery 出图也要能看见强调色。**规则不在这里抄** —— 调产品用的同一个 + // `theme::tokens`(优先级只定义一次)。如实补一句:harness 不建 `App`,所以这里 + // 没有 D38 取色方案(第三个实参恒 `None`),出图**不覆盖**那条分支。 + let (ui, _) = crate::theme::tokens( + boot.flavor, + appearance.as_ref().and_then(|state| state.accent.color()), + None, + families, + ); ( Harness { - // M250:gallery 出图也要能看见强调色 —— 与产品路径同一条规则(显式强调色 > - // 出厂板),来源是 `ULL_APPEARANCE_ACCENT`(`State::apply_env` 灌进来的)。 - ui: match appearance.as_ref().and_then(|state| state.accent.color()) { - Some(accent) => Ui::with_accent(boot.flavor, accent, families), - None => Ui::new(boot.flavor, families), - }, + ui, model, appearance: appearance.clone(), form: crate::pages::demo_launch_form(), diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index 7b2aab5..3b0bcd0 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -338,6 +338,40 @@ pub fn accent_scheme(flavor: SchemeFlavor, accent: unlucky_design::color::Color) unlucky_design::DesignTokens::with_accent(flavor, accent).colors } +/// 这一屏的颜色从哪来(M250)。日志与出图都靠它说清「是谁生效」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Colors { + /// 用户显式填的强调色(`appearance.accent_color`)。 + Accent, + /// D38 取色方案(背景图 / 壁纸取色成功,`State::derived_scheme`)。 + Scheme, + /// 出厂品牌色板(`DesignTokens::brand`)。 + Board, +} + +/// 强调色 / 取色方案 / 出厂板 **三选一**:全仓**唯一**的优先级定义点(M250)。 +/// +/// 产品路径(`App::rebuild_ui`)与出图 harness(`pages::harness`)都调它。为什么必须有这一个 +/// 函数:两头各抄一份两选一/三选一,迟早出现「出图上有强调色、产品里没有」或者反过来,而那种 +/// 偏差在图上看不出来(图是对的,产品是错的)。 +/// +/// 优先级:**显式强调色 > D38 取色方案 > 出厂板**。手填的色是直接意图,取色是从图片派生的。 +/// `flavor`(明暗 / 跟随系统)不在这里解析,由调用方按现有主题逻辑给。 +pub fn tokens( + flavor: SchemeFlavor, + accent: Option, + scheme: Option, + families: Families, +) -> (Ui, Colors) { + if let Some(accent) = accent { + (Ui::with_accent(flavor, accent, families), Colors::Accent) + } else if let Some(scheme) = scheme { + (Ui::from_scheme(scheme, families), Colors::Scheme) + } else { + (Ui::new(flavor, families), Colors::Board) + } +} + /// 令牌字重 → iced 字重。 pub fn weight_of(weight: Weight) -> iced::font::Weight { match weight { diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index eda1643..1306bb6 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -236,7 +236,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearanceAccent", "Accent colour", "强调色"), ("SettingsAppearanceAccentPresets", "Presets", "预设"), ("SettingsAppearanceAccentFactory", "Factory", "出厂"), - ("SettingsAppearanceAccentHint", "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error keep the baseline.", "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与 error 保持基线。"), + ("SettingsAppearanceAccentHint", "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error colours do not follow it.", "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与错误色不随强调色变。"), ("SettingsAppearanceAccentShort", "Accent needs 6 hex digits (RRGGBB); got {digits}.", "强调色要 6 位十六进制(RRGGBB),现在 {digits} 位。"), ("SettingsAppearanceAccentLong", "Accent has {digits} digits; a colour takes exactly 6.", "强调色有 {digits} 位,一个颜色只要 6 位。"), ("SettingsAppearanceAccentDigit", "Accent contains a character that is not a hex digit: {ch}", "强调色里有非十六进制字符:{ch}"), diff --git a/crates/i18n/src/catalog/settings/accent.rs b/crates/i18n/src/catalog/settings/accent.rs index 32d7180..e2583b0 100644 --- a/crates/i18n/src/catalog/settings/accent.rs +++ b/crates/i18n/src/catalog/settings/accent.rs @@ -26,8 +26,8 @@ macro_rules! settings_accent_entries { SettingsAppearanceAccentPresets => ("Presets", "预设"), SettingsAppearanceAccentFactory => ("Factory", "出厂"), SettingsAppearanceAccentHint => ( - "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error keep the baseline.", - "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与 error 保持基线。" + "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error colours do not follow it.", + "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与错误色不随强调色变。" ), SettingsAppearanceAccentShort => ( "Accent needs 6 hex digits (RRGGBB); got {digits}.", From c14622f828893d01203be5e8f9c5e5aa0bae5003 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 15:54:59 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(app-iced):=20=E9=92=89=E4=BD=8F?= =?UTF-8?q?=E9=94=99=E8=A1=8C=E9=87=8C=E4=B8=8D=E5=8F=AF=E8=A7=81=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E7=9A=84=20U+XXXX=20=E5=BD=A2=E6=80=81=EF=BC=88M250?= =?UTF-8?q?=20r1=20=E5=B0=8F=E4=BA=8B=202=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/appearance/section/accent.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/app-iced/src/pages/appearance/section/accent.rs b/crates/app-iced/src/pages/appearance/section/accent.rs index 676cad5..3944737 100644 --- a/crates/app-iced/src/pages/appearance/section/accent.rs +++ b/crates/app-iced/src/pages/appearance/section/accent.rs @@ -212,3 +212,24 @@ fn visible(ch: char) -> String { format!("U+{:04X}", u32::from(ch)) } } + +#[cfg(test)] +mod tests { + use super::visible; + + /// 错行里的字符必须**看得见**:不可见字符写成 `U+XXXX`,可见字符原样(r1 小事 2)。 + /// + /// 抓得住:把 `visible` 改回 `ch.to_string()`——`U+00A0` / `U+200B` 那两条立刻红(错行会变成 + /// 「强调色里有非十六进制字符:」后面一片空白,用户不知道自己错在哪)。 + /// 抓不住:句子本身(词表在 `unlucky-i18n`)。 + #[test] + fn invisible_characters_are_shown_as_code_points() { + assert_eq!(visible('\u{a0}'), "U+00A0", "不换行空格看不见"); + assert_eq!(visible('\u{200b}'), "U+200B", "零宽空格看不见"); + assert_eq!(visible('\u{202e}'), "U+202E", "RTL 覆盖符看不见"); + assert_eq!(visible(' '), "U+0020", "普通空格也看不见"); + assert_eq!(visible('z'), "z", "可见字符原样"); + assert_eq!(visible('#'), "#", "ASCII 标点原样"); + assert_eq!(visible('中'), "中", "非 ASCII 可见字符原样"); + } +}