From 14282f332539af25578c861208c33a6f3bd01394 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 15:46:10 +0800 Subject: [PATCH] =?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}.",