Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/section/accent.rs
T
AskaEth 9ce54a8119 test(app-iced): accent 回车守卫改判代码行并整取实参(M252 r2 P2-1)
上一轮(e2a9692)声称三处守卫都改了,实际只有 field.rs 与 rows.rs 进了提交:
`section/accent.rs` 的改写留在工作树里,被我自己重放 C4 变异时的 `git checkout --`
还原掉了 —— 提交面因此看不到它,那条守卫仍是 `body.contains(..)`,注释掉接线后它照样绿
(只有白名单顺手红)。先修过程、再修判据:

- `the_accent_field_wires_enter_to_commit` 改成 `code_line(body, ".on_submit(")` 取**代码行**,
  再用 `call_arguments` **整取**实参并断言等于 `["accent_commit"]` —— 顺带堵上
  「`accent_commit_none` 这类同前缀产地静默通过」。
- `code_lines()` 的边界文档换成实测口径(`app-iced/src` 9 处 `r#"`、8 处内容含引号;
  全仓 `crates/**.rs` 291 处),并写清两个方向:内嵌引号 ⇒ 尾部注释漏剥 ⇒ **假绿**;
  JSON 原始字符串同行有 `//` ⇒ 多剥 ⇒ **假红**。两种都由 `code/tests.rs` 钉住。

过程纪律(写进报告):**变异重放只在已提交的树上做**,否则 `git checkout --` 会把未提交的
修复一起扔掉,而「声称改了、diff 没动」正是这次被复审抓到的形状。
2026-11-11 19:55:21 +08:00

295 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 外观分组第 ⓪ 段(视觉上排在最前):**自定义强调色**(M250 ACCENTWIRE)。
//!
//! 为什么这一段值得单开:`appearance.accent_color` 从 M41 起就有键、有 presenter 往返、
//! 有 `ACCENT_PRESETS` 色板,却**没有任何 UI 写者**——造 tokens 的唯一入口恒走
//! `DesignTokens::brand(board)`,用户即使改了配置文件,界面也不会变。接线之后这一段就是那条
//! 链路的手动入口,也是**非法值唯一能被看见的地方**(错行紧贴输入框,不折叠、不写进日志了事)。
//!
//! ## 三件控件对应的键与消息
//!
//! | 控件 | 键 | 消息 |
//! |---|---|---|
//! | 强调色文本框(受控,输入即生效) | `appearance.accent_color` | [`Change::Accent`] |
//! | 「出厂」+ 8 个预设色块 | 同上(写入色板里的 hex) | [`Change::Accent`] |
//! | 非法值错行 | —(不进配置) | 无 |
//!
//! ## 色块为什么画成圆点而不是药丸
//!
//! 9 颗写着十六进制串的药丸会变成一排噪声,而且色相要靠读字获取——色板控件的可见内容**就是**
//! 颜色本身。所以色块 = `ShapeScale::Full` 的实心圆 + 描边;选中态加粗描边(不靠色相区分:
//! 色块底下垫的是它自己的颜色,色相对比不可靠)。
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;
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")
// P18:回车 = 立即提交(把「打了一半就放着」的 `#12` 亮成红框 + 错行)。
// **不是**逐键报错:敲字那条路(`Change::Accent`)照旧只在「不可能再变合法」时才亮红。
.on_submit(accent_commit);
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),
));
}
/// 回车那一下要发的那条消息——**唯一产地**(M252 / P18)。
///
/// 抽成函数是为了让「回车 = 提交」这件事在源码里只有一个写法:`section/accent.rs` 把它交给
/// `Field::on_submit`,测试按它钉住「回车走的是 `AccentCommit` 而不是又一条 `Accent(原文)`」
/// (后者是**逐键**路径,会把已经亮出来的错重新吞回去)。
fn accent_commit() -> Message {
Message::Appearance(Change::AccentCommit)
}
/// 「预设」两行:先「标签 + 出厂」,再一排 8 颗色块。
///
/// 为什么分两行:一行放 9 个控件在设置卡片的正文宽度里会被挤成「7 + 1」折行,最后一颗色块
/// 孤零零掉到下一行(出图实测)。分两行是**有意**的排版,不是被挤出来的。
fn presets<'a>(ui: &'a Ui, accent: &'a Accent, lang: Lang) -> Element<'a, Message> {
let current = accent.persist_value();
let head = row![
container(label(ui, t(Key::SettingsAppearanceAccentPresets, lang)))
.align_y(iced::Alignment::Center),
choice(
ui,
t(Key::SettingsAppearanceAccentFactory, lang),
current.is_empty(),
Change::Accent(String::new()),
),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
let swatches: Vec<Element<'a, Message>> = 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()
}
/// 一颗色块:实心圆 + 描边(选中态加粗)。点击写入该预设。
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);
let dot: Element<'a, Message> = 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();
// 色块本体只有颜色、没有字:补一条可读提示(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}`)。
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", &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))
}
}
#[cfg(test)]
mod tests {
use super::visible;
/// 本文件源码(源码结构断言用,与 `pages/layout/source.rs` 同一条纪律)。
const SOURCE: &str = include_str!("accent.rs");
/// **M252 / P18 回车接线的判据**:`push` 里有一**行代码**接了 `on_submit`,实参整取等于
/// `accent_commit`,且那个产地发的确实是 `AccentCommit`(提交)而不是 `Accent(原文)`(敲字)。
///
/// iced 的 `Element` 没有内省接口(见 `pages::layout::source` 的模块文档),所以「回车真的
/// 按下去」在这里量不到——能钉住的是**它接的是哪条消息**。
///
/// - 抓得住:`.on_submit(..)` 被删掉 **或注释掉**(`source::code_line` 先剥注释,判的是**代码行**);
/// 实参被换成同前缀的别的产地(`accent_commit_none`——`call_arguments` 是**整取**,不是子串);
/// 接成 `Change::Accent(原文)`(那是**逐键**路径,`retry(false)` 会把已经亮起来的错吞回去)。
/// - 抓不住:按键真的到达控件(iced 的 `Widget::update`)、以及红框/错行画没画(出图负责)。
#[test]
fn the_accent_field_wires_enter_to_commit() {
let body = crate::pages::layout::source::function_body(SOURCE, "fn push");
let line =
crate::pages::layout::source::code_line(body, ".on_submit(").unwrap_or_else(|| {
panic!("`push` 里没有一行**代码**接 `on_submit`(注释掉也算没有):\n{body}")
});
// 实参**整取**(不是子串):`accent_commit_none` 这种同前缀产地不许静默通过。
assert_eq!(
crate::pages::layout::source::call_arguments(&line, ".on_submit("),
vec!["accent_commit"],
"回车必须接 `accent_commit`(唯一产地),实际接线:{line}"
);
// 产地本身发什么:先看**代码行**(注释里的字不算),再跑一次拿 `Debug` 文本核形态
// (`Message` 没有 `PartialEq`)。
let factory = crate::pages::layout::source::function_body(SOURCE, "fn accent_commit");
assert!(
crate::pages::layout::source::code_lines(factory)
.iter()
.any(|line| line.contains("Change::AccentCommit")),
"产地必须发 `Change::AccentCommit`(提交),实际函数体:\n{factory}"
);
assert_eq!(
format!("{:?}", super::accent_commit()),
format!(
"{:?}",
crate::pages::Message::Appearance(super::Change::AccentCommit)
),
"回车那条消息必须是 `AccentCommit`(提交),不是 `Accent(原文)`(敲字)"
);
}
/// 错行里的字符必须**看得见**:不可见字符写成 `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 可见字符原样");
}
}