fix(app-iced): 复审 r1 的四条 p2 + 三条小事(M250)

- 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 出图逐字节与重构前相同(规则搬迁没改像素)。
This commit is contained in:
AskaEth committed 2026-11-11 15:46:10 +08:00
1 parent c609827c49
commit 14282f3325
10 files changed
+261 -78

No files matched your search

+1 -1
View File
@@ -148,7 +148,7 @@ pub struct App {
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
pub(super) appearance: pages::appearance::State, pub(super) appearance: pages::appearance::State,
/// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段, /// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段,
/// 强调色这类本 mission 不管的字段原样保留)。 /// 没动过的字段原样保留;M250 起强调色也在「真改了」的那一列里)。
pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs,
/// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。
pub(super) hwnd: Option<isize>, pub(super) hwnd: Option<isize>,
+13 -12
View File
@@ -53,36 +53,37 @@ impl App {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一** // 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。 // 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha(); 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.accent.color(),
self.appearance.derived_scheme(), self.appearance.derived_scheme().copied(),
) { self.families,
(Some(accent), _) => { );
match colors {
crate::theme::Colors::Accent => {
// M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。 // M250:一行里同时给「强调色生效了」与「取色方案被谁压住」,两件事都能读出来。
diag!( diag!(
"[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;\ "[appearance] accent={} → Ui::with_accent(只换 primary/secondary/tertiary;取色方案 palette={} 被压住)",
取色方案 palette={} 被显式强调色压住)",
self.appearance.accent.log_token(), self.appearance.accent.log_token(),
self.appearance.palette_level() self.appearance.palette_level()
); );
Ui::with_accent(self.appearance.flavor(), accent, self.families)
} }
(None, Some(scheme)) => { crate::theme::Colors::Scheme => {
diag!( diag!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level() self.appearance.palette_level()
); );
Ui::from_scheme(*scheme, self.families)
} }
(None, None) => { crate::theme::Colors::Board => {
// 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。 // 非法值:**保持出厂板**,但错行留在设置页(不静默),日志里也留一条。
if let Some(error) = self.appearance.accent.error() { if let Some(error) = self.appearance.accent.error() {
diag!("[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` 显式给了就不动。 /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
+63 -39
View File
@@ -1,26 +1,36 @@
//! 强调色的**运行期状态**(M250 ACCENTWIRE):输入原文 → 解析结果 → 生效方案。 //! 强调色的**运行期状态**(M250 ACCENTWIRE):输入原文 → 解析结果 → 生效色。
//! //!
//! 这一层存在的理由:`appearance.accent_color` 此前是死线——键、输入框、presenter 往返都在, //! 这一层存在的理由:`appearance.accent_color` 此前是死线——键、输入框、presenter 往返都在,
//! 但造 tokens 的唯一入口恒走 `DesignTokens::brand(board)`,用户填了色界面没变化。接线要落地 //! 但造 tokens 的唯一入口恒走 `DesignTokens::brand(board)`,用户填了色界面没变化。接线要落地
//! 的不只是「把 hex 喂进 `Scheme::from_accent`」,还有**失败要可见**: //! 的不只是「把 hex 喂进 `Scheme::from_accent`」,还有**失败要可见**:
//! //!
//! | 输入 | 生效配色 | `error` | 落盘值 | //! | 输入 | 生效配色 | `error()` | 落盘值 |
//! |---|---|---|---| //! |---|---|---|---|
//! | 空串 | 出厂品牌板(`DesignTokens::brand`) | `None` | `""` | //! | 空串 | 出厂品牌板(`DesignTokens::brand`) | `None` | `""` |
//! | `006A5C`(青绿) | `DesignTokens::with_accent`(只换三族) | `None` | `#` + `006A5C` | //! | `006A5C` | `DesignTokens::with_accent`(只换三族) | `None` | `#` + `006A5C` |
//! | `#12` / `zzzzzz` / 超长串 | **保持上一次合法的值**(不静默回退成出厂色) | `Some(..)` | 上一次合法的值 | //! | `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`),本文件只负责 //! 解析本身不在这里:`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 unlucky_presenter::prefs::{AccentChoice, AccentError};
use crate::theme::accent_scheme;
/// 出图矩阵的强调色开关(`ULL_APPEARANCE_ACCENT=#RRGGBB`;产品路径读 `launcher.json`)。 /// 出图矩阵的强调色开关(`ULL_APPEARANCE_ACCENT=#RRGGBB`;产品路径读 `launcher.json`)。
/// ///
/// 放在本文件而不是 `state.rs` 的 `ENV_*` 那一族:`state.rs` 正好 400 行(硬线),这一条 /// 放在本文件而不是 `state.rs` 的 `ENV_*` 那一族:`state.rs` 正好 400 行(硬线),这一条
@@ -36,41 +46,45 @@ pub struct Accent {
choice: AccentChoice, choice: AccentChoice,
/// 解析出来的强调色(`None` = 出厂板,强调色由品牌色板决定)。 /// 解析出来的强调色(`None` = 出厂板,强调色由品牌色板决定)。
color: Option<Color>, color: Option<Color>,
/// 非法输入的结构化原因(`None` = 没填或填得合法)。 /// **现在要显示**的结构化原因(`None` = 不显示)。
error: Option<AccentError>, error: Option<AccentError>,
/// 合法自定义色时的方案(`Ui` 直接用它;`None` = 走出厂品牌板)。 /// 已经打错、但「还可能被打字修好」的原因(输入中暂不显示,[`Accent::commit`] 才亮出来)。
scheme: Option<Scheme>, pending: Option<AccentError>,
} }
impl Accent { impl Accent {
/// 从配置读一份(启动时调一次)。配置里存着非法值时**也**亮错行,不静默吞掉。 /// 从配置读一份(启动时调一次)。配置里存着非法值时**当场**亮错行,不静默吞掉。
#[must_use] #[must_use]
pub fn new(text: &str, flavor: SchemeFlavor) -> Self { pub fn new(text: &str) -> Self {
let mut accent = Self { let mut accent = Self {
text: text.to_owned(), text: text.to_owned(),
choice: AccentChoice::Factory, choice: AccentChoice::Factory,
color: None, color: None,
error: None, error: None,
scheme: None, pending: None,
}; };
accent.retry(flavor); accent.retry(true);
accent accent
} }
/// 设置页改了一次输入(受控输入框:每敲一个字符都走这里)。 /// 设置页改了一次输入(受控输入框:每敲一个字符都走这里)。
pub fn set(&mut self, text: &str, flavor: SchemeFlavor) { ///
/// 「短但还可能是合法前缀」的输入只挂进 `pending`,不亮红(见模块文档 §何时才算「错」)。
pub fn set(&mut self, text: &str) {
self.text = text.to_owned(); self.text = text.to_owned();
self.retry(flavor); self.retry(false);
} }
/// 配置被外部改写后重置(热切:CLI / AI 改完 1 秒内生效)。 /// 配置被外部改写后重置(热切:CLI / AI 改完 1 秒内生效)。
pub fn reset(&mut self, text: &str, flavor: SchemeFlavor) { pub fn reset(&mut self, text: &str) {
*self = Self::new(text, flavor); *self = Self::new(text);
} }
/// 深浅切了:强调族要按新模式重派生(表面色仍由 `State::refresh_palette` 那条路管)。 /// 提交:把「输入中暂不报」的错亮出来。离开这一格就该走它(见 [`Accent::pending`])。
pub fn set_flavor(&mut self, flavor: SchemeFlavor) { pub fn commit(&mut self) {
self.scheme = self.color.map(|accent| accent_scheme(flavor, accent)); if self.error.is_none() {
self.error = self.pending.take();
}
} }
/// 输入框原文。 /// 输入框原文。
@@ -79,53 +93,63 @@ impl Accent {
&self.text &self.text
} }
/// 结构化失败原因(`None` = 合法)。 /// 要显示的结构化失败原因(`None` = 合法,或还没打完)。
#[must_use] #[must_use]
pub fn error(&self) -> Option<AccentError> { pub fn error(&self) -> Option<AccentError> {
self.error self.error
} }
/// 解析出来的强调色(`None` = 出厂板;日志与截图判据读它)。 /// 解析出来的强调色(`None` = 出厂板;`rebuild_ui` 与出图判据读它)。
#[must_use] #[must_use]
pub fn color(&self) -> Option<Color> { pub fn color(&self) -> Option<Color> {
self.color self.color
} }
/// 生效的强调色方案(`Ui` 的唯一入口:`Some` ⇒ 走 [`crate::theme::Ui::with_accent`] 的同一条路)。
#[must_use]
pub fn scheme(&self) -> Option<&Scheme> {
self.scheme.as_ref()
}
/// 落盘值:`""` = 出厂,`#RRGGBB` = 自定义。**永远是上一次合法值**——非法手输不写盘。 /// 落盘值:`""` = 出厂,`#RRGGBB` = 自定义。**永远是上一次合法值**——非法手输不写盘。
#[must_use] #[must_use]
pub fn persist_value(&self) -> &str { pub fn persist_value(&self) -> &str {
self.choice.as_str() self.choice.as_str()
} }
/// 取证日志里的一个词:`factory` / 规范化的 hex / `invalid(Short { digits: 2 })`。 /// 取证日志里的一个词:`factory` / 规范化的 hex / `invalid(..)` / `pending(..)`。
/// ///
/// 为什么日志要带这个:截图与真机复现只能靠一行文字分辨「强调色没生效」与「强调色生效了但 /// 为什么日志要带这个:截图与真机复现只能靠一行文字分辨「强调色没生效」与「强调色生效了但
/// 图上看不出来」——后者是这条 mission 的验收判据,前者是它的 bug。 /// 图上看不出来」——后者是这条 mission 的验收判据,前者是它的 bug。
#[must_use] #[must_use]
pub fn log_token(&self) -> String { pub fn log_token(&self) -> String {
match self.error { if let Some(error) = self.error {
Some(error) => format!("invalid({error:?})"), return 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.pending {
return format!("pending({error:?})");
}
if self.choice.as_str().is_empty() {
"factory".to_owned()
} else {
self.persist_value().to_owned()
} }
} }
/// 按当前 `text` 重算:合法 ⇒ 换方案 + 清错;非法 ⇒ **方案不动** + 记错。 /// 按当前 `text` 重算:合法 ⇒ 换色 + 清错;非法 ⇒ **色不动**,按 `show` 决定报不报。
fn retry(&mut self, flavor: SchemeFlavor) { fn retry(&mut self, show: bool) {
match AccentChoice::parse(&self.text) { match AccentChoice::parse(&self.text) {
Ok(choice) => { Ok(choice) => {
self.color = Color::from_hex(choice.as_str()); self.color = Color::from_hex(choice.as_str());
self.scheme = self.color.map(|accent| accent_scheme(flavor, accent));
self.choice = choice; self.choice = choice;
self.error = None; 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),
} }
} }
} }
@@ -19,7 +19,8 @@
//! 颜色本身。所以色块 = `ShapeScale::Full` 的实心圆 + 描边;选中态加粗描边(不靠色相区分: //! 颜色本身。所以色块 = `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 iced::{Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
use unlucky_design::color::Role; 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 width = if selected { STROKE_PX * 2.0 } else { STROKE_PX };
let radius = ui.radius(ShapeScale::Full, diameter); let radius = ui.radius(ShapeScale::Full, diameter);
button( let dot: Element<'a, Message> = button(
Gap::new() Gap::new()
.width(Length::Fixed(diameter)) .width(Length::Fixed(diameter))
.height(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, snap: true,
}) })
.on_press(Message::Appearance(Change::Accent(hex.to_owned()))) .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}`)。 /// 结构化原因 → 一句话(参数由 Rust 侧格式化,词表只认 `{name}`)。
@@ -169,7 +195,20 @@ fn reason(lang: Lang, error: AccentError) -> String {
AccentError::Digit { ch } => tr( AccentError::Digit { ch } => tr(
lang, lang,
Key::SettingsAppearanceAccentDigit, 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))
}
}
+11 -10
View File
@@ -98,7 +98,7 @@ impl State {
palette_source: base.palette_source, palette_source: base.palette_source,
window_corner: base.window_corner, window_corner: base.window_corner,
glass: prefs.glass, glass: prefs.glass,
accent: Accent::new(&prefs.accent_color, flavor), accent: Accent::new(&prefs.accent_color),
backend, backend,
selected_group: super::APPEARANCE_GROUP, selected_group: super::APPEARANCE_GROUP,
image: None, image: None,
@@ -137,7 +137,8 @@ impl State {
self.palette_source = PaletteSource::from_key_value(&value.to_string_lossy()); self.palette_source = PaletteSource::from_key_value(&value.to_string_lossy());
} }
if let Some(value) = std::env::var_os(ENV_ACCENT) { 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_background();
self.refresh_palette(); self.refresh_palette();
@@ -225,14 +226,12 @@ impl State {
} }
} }
/// 深浅切换后重算配色(`Ui` 换主题时调)。 /// 深浅切换后重算配色(`Ui` 换主题时调)。强调色不参与:它是用户给的绝对色值,与深浅无关。
pub fn set_flavor(&mut self, flavor: SchemeFlavor) { pub fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.flavor == flavor { if self.flavor == flavor {
return; return;
} }
self.flavor = flavor; self.flavor = flavor;
// M250:自定义强调色也要按新模式重派生(否则切深浅后强调族停在旧模式)。
self.accent.set_flavor(flavor);
self.refresh_palette(); self.refresh_palette();
} }
@@ -245,6 +244,10 @@ impl State {
/// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。 /// 应用一条改动(设置页 → 状态)。返回**是否需要立刻下发整窗 α**。
pub fn apply(&mut self, change: super::Change) -> bool { pub fn apply(&mut self, change: super::Change) -> bool {
use super::Change; use super::Change;
// M250 p2-3:其余外观改动都意味着用户离开了强调色那一格 ⇒ 提交(理由见 `accent` 模块文档)。
if !matches!(change, Change::Accent(_)) {
self.accent.commit();
}
match change { match change {
Change::WindowOpacity(alpha) => { Change::WindowOpacity(alpha) => {
self.window_opacity = alpha; self.window_opacity = alpha;
@@ -285,8 +288,7 @@ impl State {
false false
} }
Change::Accent(text) => { Change::Accent(text) => {
// M250:非法输入不改生效色(`Accent::set` 保留上一次合法值),只挂原因。 self.accent.set(&text);
self.accent.set(&text, self.flavor);
false false
} }
Change::SelectGroup(index) => { Change::SelectGroup(index) => {
@@ -298,8 +300,7 @@ impl State {
/// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 9 个字段)。 /// 写回配置用的快照(`SettingsBridge::persist` 覆盖的那 9 个字段)。
/// ///
/// M250 起 `accent_color` 也在里面:以前这里恒给空串(当时「强调色不在本 mission 管辖内」), /// M250 起 `accent_color` 也在里面(以前恒给空串 ⇒ 设置页改的强调色落不了盘),给的是**上一次合法值**。
/// 而 `persist_appearance` 拿配置里的旧快照做基底 ⇒ 设置页改的强调色落不了盘。现在给**上一次合法值**。
#[must_use] #[must_use]
pub fn current_values(&self) -> AppearancePrefs { pub fn current_values(&self) -> AppearancePrefs {
AppearancePrefs { AppearancePrefs {
@@ -351,7 +352,7 @@ impl State {
self.bg_dim = prefs.bg_dim; self.bg_dim = prefs.bg_dim;
self.glass = prefs.glass; self.glass = prefs.glass;
// M250:文件被外部改过(CLI / AI)时强调色也要热生效,错行同样重置。 // M250:文件被外部改过(CLI / AI)时强调色也要热生效,错行同样重置。
self.accent.reset(&prefs.accent_color, self.flavor); self.accent.reset(&prefs.accent_color);
if self.bg_path != prefs.bg_image { if self.bg_path != prefs.bg_image {
self.bg_path = prefs.bg_image.clone(); self.bg_path = prefs.bg_image.clone();
} }
@@ -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); let good_primary = Ui::with_accent(SchemeFlavor::Light, good, FAMILIES).color(Role::Primary);
// 超长串由合法值拼出来:`src/**` 的色值字面量门禁把注释与测试也算在内(那条门禁是对的)。 // 超长串由合法值拼出来:`src/**` 的色值字面量门禁把注释与测试也算在内(那条门禁是对的)。
//
// 三档里只有后两档**当场**亮:`#12` 是「还可能是合法前缀」(用户可能正打到一半),它的
// 亮灯时机由 `accent_errors_only_appear_when_they_are_actionable` 与 `commit` 覆盖。
let long = format!("{}00112233", TEAL.to_hex()); let long = format!("{}00112233", TEAL.to_hex());
for (text, expected) in [ for (text, expected) in [
("#12".to_owned(), AccentError::Short { digits: 2 }),
("zzzzzz".to_owned(), AccentError::Digit { ch: 'z' }), ("zzzzzz".to_owned(), AccentError::Digit { ch: 'z' }),
(long, AccentError::Long { digits: 14 }), (long, AccentError::Long { digits: 14 }),
] { ] {
@@ -140,7 +142,7 @@ fn illegal_accent_reports_a_reason_and_never_silently_falls_back() {
assert_eq!( assert_eq!(
state.accent.error(), state.accent.error(),
Some(expected), Some(expected),
"{text}: 失败原因必须是结构化的(不是一句 bool / 一句「格式不对」)" "{text}: 不可能变合法的输入必须当场亮且原因结构化(不是一句 bool / 一句「格式不对」)"
); );
assert_eq!( assert_eq!(
state.accent.color(), 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); 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())); state.apply(Change::Accent(String::new()));
assert_eq!(state.accent.error(), None, "空串不是错误"); 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()); 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(), "坏值不生效,走出厂板");
}
+10 -7
View File
@@ -18,7 +18,6 @@ use crate::fonts;
use crate::pages::appearance; use crate::pages::appearance;
use crate::probe::{self, Probe}; use crate::probe::{self, Probe};
use crate::renderer; use crate::renderer;
use crate::theme::Ui;
use super::{demo_model, Harness, Message, Request, CAPTURE_POSITION}; 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() { if boot.screenshot.is_some() {
model.forced = Some(Interaction::Enabled); 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 { Harness {
// M250:gallery 出图也要能看见强调色 —— 与产品路径同一条规则(显式强调色 > ui,
// 出厂板),来源是 `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, model,
appearance: appearance.clone(), appearance: appearance.clone(),
form: crate::pages::demo_launch_form(), form: crate::pages::demo_launch_form(),
+34
View File
@@ -338,6 +338,40 @@ pub fn accent_scheme(flavor: SchemeFlavor, accent: unlucky_design::color::Color)
unlucky_design::DesignTokens::with_accent(flavor, accent).colors 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<unlucky_design::color::Color>,
scheme: Option<Scheme>,
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 字重。 /// 令牌字重 → iced 字重。
pub fn weight_of(weight: Weight) -> iced::font::Weight { pub fn weight_of(weight: Weight) -> iced::font::Weight {
match weight { match weight {
+1 -1
View File
@@ -236,7 +236,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsAppearanceAccent", "Accent colour", "强调色"), ("SettingsAppearanceAccent", "Accent colour", "强调色"),
("SettingsAppearanceAccentPresets", "Presets", "预设"), ("SettingsAppearanceAccentPresets", "Presets", "预设"),
("SettingsAppearanceAccentFactory", "Factory", "出厂"), ("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} 位。"), ("SettingsAppearanceAccentShort", "Accent needs 6 hex digits (RRGGBB); got {digits}.", "强调色要 6 位十六进制(RRGGBB),现在 {digits} 位。"),
("SettingsAppearanceAccentLong", "Accent has {digits} digits; a colour takes exactly 6.", "强调色有 {digits} 位,一个颜色只要 6 位。"), ("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}"), ("SettingsAppearanceAccentDigit", "Accent contains a character that is not a hex digit: {ch}", "强调色里有非十六进制字符:{ch}"),
+2 -2
View File
@@ -26,8 +26,8 @@ macro_rules! settings_accent_entries {
SettingsAppearanceAccentPresets => ("Presets", "预设"), SettingsAppearanceAccentPresets => ("Presets", "预设"),
SettingsAppearanceAccentFactory => ("Factory", "出厂"), SettingsAppearanceAccentFactory => ("Factory", "出厂"),
SettingsAppearanceAccentHint => ( SettingsAppearanceAccentHint => (
"Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error keep the baseline.", "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 三族;中性表面与 error 保持基线。" "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与错误色不随强调色变。"
), ),
SettingsAppearanceAccentShort => ( SettingsAppearanceAccentShort => (
"Accent needs 6 hex digits (RRGGBB); got {digits}.", "Accent needs 6 hex digits (RRGGBB); got {digits}.",