feat(app-iced): 强调色接线落地(M250 ACCENTWIRE)

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 仍可构造。
This commit is contained in:
AskaEth committed 2026-11-11 14:56:26 +08:00
1 parent 4cdb46d2e0
commit e6b2200360
18 files changed
+787 -38

No files matched your search

@@ -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<Element<'a, Message>>,
) {
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<Element<'a, Message>> = 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())],
),
}
}