WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness
已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)
待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
This commit is contained in:
1 parent
22ddc74784
commit
4774e7b788
23 files changed
+3136
-133
No files matched your search
@@ -0,0 +1,172 @@
|
||||
//! 按钮家族的状态矩阵(D30:浅/深 × 五态)。
|
||||
//!
|
||||
//! ## 矩阵形状
|
||||
//!
|
||||
//! ```text
|
||||
//! | enabled | hovered | pressed | focused | disabled |
|
||||
//! filled | [btn] | [btn] | [btn] | [btn] | [btn] |
|
||||
//! tonal | ... (下同)
|
||||
//! elevated |
|
||||
//! outlined |
|
||||
//! text |
|
||||
//! icon·standard | [ico] | ...
|
||||
//! icon·filled |
|
||||
//! icon·tonal |
|
||||
//! ```
|
||||
//!
|
||||
//! 40 个单元格共 5 列 × 8 行,一屏(1180×760)装不下,靠 `gallery/mod.rs` 的 scrollable 滚动;
|
||||
//! 截图取的是**整窗**,所以首屏能看到按钮家族的全部 5 行(图标行可能需要滚动,报告里会说明)。
|
||||
//!
|
||||
//! ## 为什么每格都强制状态
|
||||
//!
|
||||
//! iced 的 `button::Status` 只由**真实鼠标**驱动,截图时没法让 40 个按钮同时 hover。
|
||||
//! 组件层的 `Button::state(...)` 就是为这件事开的取证开关(`--force-state` 只能给整屏一种态)。
|
||||
//! 强制值**只影响画什么**,不动任何状态机——所以矩阵图里的五态与运行期五态逐像素同源。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::buttons::{self, Button, IconVariant, Variant};
|
||||
use super::super::interaction::Interaction;
|
||||
use super::{cell_width, heading, row_label, state_cells, DemoMessage};
|
||||
|
||||
/// 按钮家族整段(标题 + 列头 + 5 行文字按钮 + 3 行图标按钮 + 一条图例说明)。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let legend = ui.style(TypeRole::BodySmall);
|
||||
|
||||
let mut rows = column![header_row(ui)].spacing(ui.space(Space::X2));
|
||||
|
||||
for variant in Variant::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, text_button(ui, variant, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, variant.name()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
for variant in IconVariant::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, icon_button(ui, variant, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, variant.name()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
column![
|
||||
heading(
|
||||
ui,
|
||||
"buttons · filled / tonal / elevated / outlined / text + icon",
|
||||
"12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 16[G14] 间距 8 · \
|
||||
hover 8% / focus 10% / pressed 10% / disabled 容器 12% 内容 38%"
|
||||
),
|
||||
rows,
|
||||
iced::widget::text(
|
||||
"图例:列 = 五态(focus 与 pressed 同 α 10%,看起来一样是规范要求);\
|
||||
行 = 变体。虚线不表示任何东西,只是矩阵栅格。"
|
||||
)
|
||||
.size(legend.size_px)
|
||||
.font(ui.font_of(legend))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 列头(五态名)。
|
||||
fn header_row<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::LabelSmall);
|
||||
let mut head = row![row_label(ui, "")].spacing(ui.space(Space::X2));
|
||||
|
||||
for (name, width) in state_cells(ui) {
|
||||
head = head.push(
|
||||
iced::widget::container(
|
||||
iced::widget::text(name)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.width(Length::Fixed(width)),
|
||||
);
|
||||
}
|
||||
|
||||
head.into()
|
||||
}
|
||||
|
||||
/// 一个矩阵单元格(固定宽,内容左对齐;高度只由按钮自己决定)。
|
||||
fn cell<'a>(ui: &'a Ui, content: Element<'a, DemoMessage>) -> Element<'a, DemoMessage> {
|
||||
iced::widget::container(content)
|
||||
.width(Length::Fixed(cell_width(ui)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 文字按钮的一格(强制成 `state`)。
|
||||
fn text_button<'a>(ui: &'a Ui, variant: Variant, state: Interaction) -> Element<'a, DemoMessage> {
|
||||
// 标签用变体名 + 首字母大写:图上要能一眼对上左栏,且不引入 i18n 依赖(gallery 不是产品页)
|
||||
let label = match variant {
|
||||
Variant::Filled => "Filled",
|
||||
Variant::Tonal => "Tonal",
|
||||
Variant::Elevated => "Elevated",
|
||||
Variant::Outlined => "Outlined",
|
||||
Variant::Text => "Text",
|
||||
};
|
||||
|
||||
let lead = if variant == Variant::Text {
|
||||
Button::new(label, DemoMessage::Pressed("text"))
|
||||
} else {
|
||||
Button::new(label, DemoMessage::Pressed(label))
|
||||
.leading(IconKind::Play)
|
||||
.trailing(IconKind::ChevronRight)
|
||||
};
|
||||
|
||||
// 禁用格必须真的没有消息(语义正确),再强制成 Disabled 的画法
|
||||
let spec = if state.is_disabled() {
|
||||
Button::disabled(label)
|
||||
} else {
|
||||
lead
|
||||
}
|
||||
.state(state);
|
||||
|
||||
match variant {
|
||||
Variant::Filled => buttons::filled(ui, spec),
|
||||
Variant::Tonal => buttons::tonal(ui, spec),
|
||||
Variant::Elevated => buttons::elevated(ui, spec),
|
||||
Variant::Outlined => buttons::outlined(ui, spec),
|
||||
Variant::Text => buttons::text(ui, spec),
|
||||
}
|
||||
}
|
||||
|
||||
/// 图标按钮的一格(强制成 `state`)。
|
||||
fn icon_button<'a>(
|
||||
ui: &'a Ui,
|
||||
variant: IconVariant,
|
||||
state: Interaction,
|
||||
) -> Element<'a, DemoMessage> {
|
||||
let press = if state.is_disabled() {
|
||||
None
|
||||
} else {
|
||||
Some(DemoMessage::Pressed(variant.name()))
|
||||
};
|
||||
|
||||
buttons::icon(ui, variant, IconKind::Settings, press, Some(state))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的反馈类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::feedback;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 反馈类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"feedback · progress / snackbar / dialog / empty state",
|
||||
"12b §7/§12/§14/§15 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
feedback::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的输入类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::inputs;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 输入类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"inputs · text field / search / slider / menu",
|
||||
"12b §8/§9/§11 + 15 §20 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
inputs::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! `--screen=gallery` 的壳:**视觉验收 harness**(D30 / D39 §5),不是产品页面。
|
||||
//!
|
||||
//! ## 它是什么、不是什么
|
||||
//!
|
||||
//! - 是:**状态矩阵的取景框**。每个家族在自己的 `gallery/<family>.rs` 里摆出
|
||||
//! 「五变体 × 五态」的矩阵,一张截图就能判完该家族(浅/深各一张 = 10 张证据)。
|
||||
//! - 不是:页面。gallery 允许堆叠、不分页、不做空态,**难看不算问题**;
|
||||
//! 但**每个组件本身**必须按官方口径做到位。
|
||||
//!
|
||||
//! ## 并行地基(后续四条线怎么加东西)
|
||||
//!
|
||||
//! 五个家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`:
|
||||
//!
|
||||
//! ```text
|
||||
//! gallery/buttons.rs ← COMPBASE(本 mission)
|
||||
//! gallery/selection.rs ← 后续 mission(只改这个文件)
|
||||
//! gallery/inputs.rs ← 同上
|
||||
//! gallery/feedback.rs ← 同上
|
||||
//! gallery/navigation.rs ← 同上
|
||||
//! ```
|
||||
//!
|
||||
//! 加组件 = 只改自己那个文件里的 `section()`,**不用碰本文件**(这是「五条线并行不冲突」的
|
||||
//! 具体保证,也是 tower 会亲手验的一条)。
|
||||
//!
|
||||
//! ## DemoMessage 为什么独立
|
||||
//!
|
||||
//! 契约 §2:组件不 import `crate::app::Message`。gallery 用自己定义的消息枚举,
|
||||
//! 正好当「组件能脱离业务验收」的示范——`app.rs` 只做一次 `.map(Message::Demo)` 就接上了。
|
||||
//!
|
||||
//! ## 截图命令(D30)
|
||||
//!
|
||||
//! ```text
|
||||
//! unlucky-launcher-iced --software-renderer --screen=gallery \
|
||||
//! --screenshot out.png --screenshot-theme=light
|
||||
//! ```
|
||||
//!
|
||||
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
|
||||
|
||||
use iced::widget::{column, container, scrollable, text};
|
||||
use iced::{Alignment, Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::shot::flavor_slug;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::divider;
|
||||
use super::interaction::Interaction;
|
||||
use super::layout;
|
||||
|
||||
mod buttons;
|
||||
mod feedback;
|
||||
mod inputs;
|
||||
mod navigation;
|
||||
#[cfg(test)]
|
||||
mod probe;
|
||||
#[cfg(test)]
|
||||
mod scan;
|
||||
mod selection;
|
||||
|
||||
/// gallery 自己的消息(与业务 `Message` 解耦,契约 §2)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DemoMessage {
|
||||
/// 某个演示按钮被按下(`&'static str` = 演示 id,只用于日志)。
|
||||
Pressed(&'static str),
|
||||
}
|
||||
|
||||
impl DemoMessage {
|
||||
/// 演示 id(日志用;将来加变体时这里跟着改,`app.rs` 不用动)。
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
DemoMessage::Pressed(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// gallery 根视图(整窗:不带标题栏与导航轨,验收图上只有组件)。
|
||||
pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let page = ui.style(TypeRole::BodySmall);
|
||||
|
||||
let header = column![
|
||||
text("Component gallery")
|
||||
.size(ui.emphasized(TypeRole::TitleLarge).size_px)
|
||||
.font(ui.emphasized_font(TypeRole::TitleLarge))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
text(format!(
|
||||
"M3 Expressive component base · flavor={} · 1180×760 @100% DPI · \
|
||||
focus 与 pressed 同为 10%(令牌口径,图上一样是对的)",
|
||||
flavor_slug(ui)
|
||||
))
|
||||
.size(page.size_px)
|
||||
.font(ui.font_of(page))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X1));
|
||||
|
||||
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
|
||||
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
|
||||
let sections = column![
|
||||
buttons::section(ui),
|
||||
divider_proof(ui),
|
||||
selection::section(ui),
|
||||
inputs::section(ui),
|
||||
feedback::section(ui),
|
||||
navigation::section(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X8))
|
||||
.width(Length::Fill);
|
||||
|
||||
let body = scrollable(sections)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill);
|
||||
|
||||
// 页面留白走 `layout::inset`(左右/上下各 24):这是 Q8/Q9 那两个 helper 的第三个兄弟,
|
||||
// 存在意义是「只改内边距、不改尺寸」,也顺手避免把 Padding 的上右下左写反。
|
||||
let page_padding = ui.space(Space::X6);
|
||||
container(layout::inset(
|
||||
page_padding,
|
||||
page_padding,
|
||||
column![header, body].spacing(ui.space(Space::X4)),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 分隔线的视觉证据(§22):一条通栏 + 一条列表内缩进,各自带说明。
|
||||
///
|
||||
/// 单独一节而不是散落各处:`15 §22.4` 说「同屏同类线 ≤3」,这两条是**唯一**计入的两条。
|
||||
fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let caption = ui.style(TypeRole::LabelSmall);
|
||||
let label = |text_value: &'a str| {
|
||||
text(text_value)
|
||||
.size(caption.size_px)
|
||||
.font(ui.font_of(caption))
|
||||
.color(ui.color(Role::OnSurfaceVariant))
|
||||
};
|
||||
|
||||
column![
|
||||
heading(
|
||||
ui,
|
||||
"divider · full / list-inset",
|
||||
"15 §22:厚度 1px(stroke::HAIRLINE)· 颜色 OutlineVariant · 列表内左缩进 16 · 同屏 ≤3 条"
|
||||
),
|
||||
label("full (通栏,区块分隔)"),
|
||||
divider::full::<DemoMessage>(ui),
|
||||
label("list (左缩进 16)"),
|
||||
divider::list::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个家族小节的标题(`index.title`)+ 一行判据说明。
|
||||
///
|
||||
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
|
||||
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
|
||||
pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
|
||||
let note_style = ui.style(TypeRole::BodySmall);
|
||||
column![
|
||||
text(title)
|
||||
.size(ui.emphasized(TypeRole::TitleMedium).size_px)
|
||||
.font(ui.emphasized_font(TypeRole::TitleMedium))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
text(note)
|
||||
.size(note_style.size_px)
|
||||
.font(ui.font_of(note_style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。
|
||||
///
|
||||
/// 列宽 = 按钮最常见的自然宽 132(用作矩阵栅格,不是组件尺寸)。
|
||||
pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||||
Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| (state.name(), cell_width(ui)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸;216 = 5 列正好铺满 1092 内容宽)。
|
||||
pub(super) fn cell_width(ui: &Ui) -> f32 {
|
||||
ui.space(Space::X8) * 6.0
|
||||
}
|
||||
|
||||
/// 一个矩阵行的左侧标签(变体名)。
|
||||
pub(super) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text(name)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.width(Length::Fixed(cell_width(ui)))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的导航类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::navigation;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 导航类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"navigation · list item / tabs / drawer / app bar",
|
||||
"12b §4/§6/§16 + 15 §21 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
navigation::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训:纯函数返回对,图可能是错的)。
|
||||
//!
|
||||
//! `buttons::tests` 只能证明「几何结构体里的数字是 40/20/24」——M68 的药丸正是这样:单测全绿、
|
||||
//! 图上是 88×32(容器被 `center_x(Length::Fill)` 覆盖)。唯一能抓住这类错误的是**真渲染的像素**。
|
||||
//! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读
|
||||
//! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。
|
||||
//!
|
||||
//! 复现命令(在 worktree 根目录):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
|
||||
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery //! --screenshot target/m75/gallery-light.png --screenshot-theme=light
|
||||
//! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe
|
||||
//! ```
|
||||
//!
|
||||
//! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行宽 ≈9 ⇒ 圆角 full=20
|
||||
//! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26**
|
||||
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。
|
||||
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use super::scan::{matches, nearer, rgb, run_at, Shot};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 一条测试覆盖全部几何断言(每张图各跑一次)。
|
||||
#[test]
|
||||
fn gallery_geometry_matches_the_tokens() {
|
||||
let Some(path) = shot_path() else {
|
||||
eprintln!(
|
||||
"[probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
|
||||
需要先出一张真窗截图(命令见本文件模块文档)。"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let shot = Shot::load(&path).expect("读 gallery 截图");
|
||||
assert_eq!(
|
||||
(shot.width, shot.height),
|
||||
(1180, 760),
|
||||
"验收图必须是 D30 的 1180×760 @100% DPI"
|
||||
);
|
||||
|
||||
// 用左上角背景色判定深浅(页面 padding 区就是 `Surface`)
|
||||
let corner = shot.at(2, 2);
|
||||
let flavor = if matches(corner, rgb(ui(SchemeFlavor::Light).color(Role::Surface))) {
|
||||
SchemeFlavor::Light
|
||||
} else {
|
||||
SchemeFlavor::Dark
|
||||
};
|
||||
let ui = ui(flavor);
|
||||
|
||||
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
|
||||
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
|
||||
assert!(
|
||||
primary.len() >= 2,
|
||||
"至少该找到 Filled 按钮与 Filled IconButton 两块实心(实际 {})",
|
||||
primary.len()
|
||||
);
|
||||
|
||||
let mut label_button = None;
|
||||
for bbox in &primary {
|
||||
assert!(
|
||||
(39..=41).contains(&bbox.height()),
|
||||
"实心块高 {}(参考 40,12b 1.1)@ {:?}",
|
||||
bbox.height(),
|
||||
(bbox.left, bbox.top)
|
||||
);
|
||||
if bbox.width() > 60 {
|
||||
label_button = Some(*bbox);
|
||||
}
|
||||
if (39..=41).contains(&bbox.width()) {
|
||||
// 12b 2.1:IconButton 容器 40×40
|
||||
assert!(
|
||||
(39..=41).contains(&bbox.width()),
|
||||
"IconButton 应为 40×40,实测 {}×{}",
|
||||
bbox.width(),
|
||||
bbox.height()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let label_button = label_button.expect("没找到文字按钮的实心块");
|
||||
assert!(
|
||||
label_button.width() >= 58,
|
||||
"12b 1.7:最小宽 58,实测 {}",
|
||||
label_button.width()
|
||||
);
|
||||
|
||||
// 含抗锯齿的真实外框:中心列上「更接近填充色而不是底色」的连续行
|
||||
let fill = rgb(ui.color(Role::Primary));
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let center = (label_button.left + label_button.right) / 2;
|
||||
let mut true_top = label_button.top;
|
||||
while true_top > 0 && nearer(shot.at(center, true_top - 1), fill, surface) {
|
||||
true_top -= 1;
|
||||
}
|
||||
let mut true_bottom = label_button.bottom;
|
||||
while true_bottom + 1 < shot.height && nearer(shot.at(center, true_bottom + 1), fill, surface) {
|
||||
true_bottom += 1;
|
||||
}
|
||||
let true_height = true_bottom - true_top + 1;
|
||||
|
||||
// 圆角档位:**量顶部那一行的宽度**。药丸(r = 高/2)顶部只有几像素宽;
|
||||
// 若半径是 8(不是 full),顶行会是「几乎通栏」。这条比「数圆角行数」抗锯齿得多。
|
||||
let top_run = run_at(
|
||||
&shot,
|
||||
label_button.top,
|
||||
label_button.left,
|
||||
label_button.right,
|
||||
rgb(ui.color(Role::Primary)),
|
||||
);
|
||||
assert!(
|
||||
(39..=41).contains(&true_height),
|
||||
"可见高应 40(12b 1.1),实测 {true_height}"
|
||||
);
|
||||
assert!(
|
||||
(4..=20).contains(&top_run),
|
||||
"药丸圆角应 = 20(12b 1.2,顶部一行的填充宽约 sqrt(2*20*1)*2 ≈ 9),实测 {top_run}px; 若接近整宽说明半径是 8 而不是 full"
|
||||
);
|
||||
|
||||
// ---- Q9:IconButton 的字形不许撑满 40 盒 --- // ---- Q9:IconButton 的字形不许撑满 40 盒 -----------------------------------
|
||||
let tonal = rgb(ui.color(Role::SecondaryContainer));
|
||||
let tonal_boxes = shot.bbox_of(tonal);
|
||||
let icon_states = tonal_boxes
|
||||
.iter()
|
||||
.filter(|bbox| (39..=41).contains(&bbox.width()) && (39..=41).contains(&bbox.height()))
|
||||
.count();
|
||||
assert!(
|
||||
icon_states >= 2,
|
||||
"tonal IconButton 至少 2 个(实际 {icon_states})"
|
||||
);
|
||||
|
||||
let gear = rgb(ui.color(Role::OnSecondaryContainer));
|
||||
let mut measured_glyph = false;
|
||||
let mut glyph_width = 0;
|
||||
for bbox in tonal_boxes
|
||||
.iter()
|
||||
.filter(|bbox| (39..=41).contains(&bbox.width()))
|
||||
{
|
||||
if let Some(ink) = shot.ink_inside(bbox, gear) {
|
||||
let width = ink.width();
|
||||
if width > glyph_width {
|
||||
glyph_width = width;
|
||||
}
|
||||
assert!(
|
||||
(16..=26).contains(&width),
|
||||
"图标盒 24 的字形墨迹应 16~26 宽,实测 {width}(Q9 回归时会接近 40)"
|
||||
);
|
||||
measured_glyph = true;
|
||||
}
|
||||
}
|
||||
assert!(measured_glyph, "没量到 IconButton 的字形");
|
||||
|
||||
// ---- 22.1:分隔线 1px -----------------------------------------------------
|
||||
let outline = rgb(ui.color(Role::OutlineVariant));
|
||||
let mut runs = 0;
|
||||
for y in 0..shot.height {
|
||||
let mut longest = 0;
|
||||
let mut current = 0;
|
||||
for x in 0..shot.width {
|
||||
if matches(shot.at(x, y), outline) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
if longest > 300 {
|
||||
runs += 1;
|
||||
}
|
||||
}
|
||||
// 分隔线是 1px 高,所以「长横线」的行数 = 屏上分隔线条数(每条约 1 行)
|
||||
assert!(runs >= 2, "至少该有 2 条分隔线(实际 {runs} 行)");
|
||||
assert!(
|
||||
runs <= 4,
|
||||
"分隔线最多 2 条 × 1px,实测 {runs} 行(22.1:大于这个数说明线不是 1px)"
|
||||
);
|
||||
|
||||
// ---- 12b 1.8:禁用格必须与静息格不同 ------------------------------------
|
||||
let disabled_container =
|
||||
crate::app::components::interaction::disabled_colors(&ui, Role::Surface).0;
|
||||
assert!(
|
||||
shot.bbox_of(rgb(disabled_container))
|
||||
.iter()
|
||||
.any(|b| b.width() > 60),
|
||||
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[probe] OK flavor={:?}:
|
||||
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
|
||||
顶行填充宽 {}(full 圆角应为个位数,直角/8 圆角会接近整宽)
|
||||
实心块 {} 个(含 40×40 IconButton)
|
||||
分隔线长行 {} 行(每条 1px)
|
||||
禁用容器 (on-surface@12%) 命中
|
||||
字形墨迹 {}px(24 盒应 16~26)",
|
||||
flavor,
|
||||
label_button.width(),
|
||||
true_height,
|
||||
top_run,
|
||||
primary.len(),
|
||||
runs,
|
||||
glyph_width
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 截图像素扫描工具(`gallery::probe` 的底层,四个后续家族的 probe 也能复用)。
|
||||
//!
|
||||
//! 只做「按颜色找连通块 / 量外接框 / 量某行的连续长度」三件事,不含任何判据——
|
||||
//! 判据在同级 `probe.rs` 里,这样每条家族线可以自带 probe 而不用重写扫描逻辑。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use iced::Color;
|
||||
|
||||
/// 颜色比对的通道容差(抗锯齿/取整)。
|
||||
const TOLERANCE: u8 = 10;
|
||||
|
||||
/// iced 颜色 → 8bit RGB(截图里的原值)。
|
||||
pub(super) fn rgb(color: Color) -> [u8; 3] {
|
||||
[
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// 颜色是否落在容差内。
|
||||
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(a, b)| a.abs_diff(b) <= TOLERANCE)
|
||||
}
|
||||
|
||||
/// 一张图的像素访问器。
|
||||
pub(super) struct Shot {
|
||||
pub(super) width: usize,
|
||||
pub(super) height: usize,
|
||||
pixels: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
pub(super) fn load(path: &Path) -> Result<Self, String> {
|
||||
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
|
||||
let image = image::load_from_memory(&bytes)
|
||||
.map_err(|error| format!("解 PNG 失败:{error}"))?
|
||||
.to_rgb8();
|
||||
let (width, height) = (image.width() as usize, image.height() as usize);
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
pixels: image.pixels().map(|p| p.0).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
|
||||
self.pixels[y * self.width + x]
|
||||
}
|
||||
|
||||
/// 某种颜色的全部像素的外接框(连通块)。
|
||||
pub(super) fn bbox_of(&self, color: [u8; 3]) -> Vec<BBox> {
|
||||
let mut seen = vec![false; self.width * self.height];
|
||||
let mut boxes = Vec::new();
|
||||
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut stack = vec![(x, y)];
|
||||
seen[y * self.width + x] = true;
|
||||
let mut bbox = BBox::new(x, y);
|
||||
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
bbox.include(cx, cy);
|
||||
for (nx, ny) in [
|
||||
(cx + 1, cy),
|
||||
(cx.saturating_sub(1), cy),
|
||||
(cx, cy + 1),
|
||||
(cx, cy.saturating_sub(1)),
|
||||
] {
|
||||
let inside = nx < self.width && ny < self.height;
|
||||
if inside && !seen[ny * self.width + nx] && matches(self.at(nx, ny), color)
|
||||
{
|
||||
seen[ny * self.width + nx] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bbox.area() > 200 {
|
||||
boxes.push(bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boxes
|
||||
}
|
||||
|
||||
/// `bbox` 内某颜色的墨迹外接框(量字形用)。
|
||||
///
|
||||
/// 内缩 3px:不内缩会把按钮自身的抗锯齿边算成墨迹(实测把 24 盒字形量成 40)。
|
||||
pub(super) fn ink_inside(&self, bbox: &BBox, color: [u8; 3]) -> Option<BBox> {
|
||||
const MARGIN: usize = 3;
|
||||
let mut ink: Option<BBox> = None;
|
||||
for y in (bbox.top + MARGIN)..=(bbox.bottom - MARGIN) {
|
||||
for x in (bbox.left + MARGIN)..=(bbox.right - MARGIN) {
|
||||
if matches(self.at(x, y), color) {
|
||||
match &mut ink {
|
||||
Some(found) => found.include(x, y),
|
||||
None => ink = Some(BBox::new(x, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ink
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个外接框(闭区间)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub(super) struct BBox {
|
||||
pub(super) left: usize,
|
||||
pub(super) right: usize,
|
||||
pub(super) top: usize,
|
||||
pub(super) bottom: usize,
|
||||
}
|
||||
|
||||
impl BBox {
|
||||
pub(super) fn new(x: usize, y: usize) -> Self {
|
||||
Self {
|
||||
left: x,
|
||||
right: x,
|
||||
top: y,
|
||||
bottom: y,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn include(&mut self, x: usize, y: usize) {
|
||||
self.left = self.left.min(x);
|
||||
self.right = self.right.max(x);
|
||||
self.top = self.top.min(y);
|
||||
self.bottom = self.bottom.max(y);
|
||||
}
|
||||
|
||||
pub(super) fn width(&self) -> usize {
|
||||
self.right - self.left + 1
|
||||
}
|
||||
|
||||
pub(super) fn height(&self) -> usize {
|
||||
self.bottom - self.top + 1
|
||||
}
|
||||
|
||||
pub(super) fn area(&self) -> usize {
|
||||
self.width() * self.height()
|
||||
}
|
||||
}
|
||||
|
||||
/// `pixel` 更接近 `fill` 还是 `surface`。
|
||||
pub(super) fn nearer(pixel: [u8; 3], fill: [u8; 3], surface: [u8; 3]) -> bool {
|
||||
let dist = |a: [u8; 3], b: [u8; 3]| -> u32 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
|
||||
.sum()
|
||||
};
|
||||
dist(pixel, fill) < dist(pixel, surface)
|
||||
}
|
||||
|
||||
/// 某一行的水平区间 `[left, right]` 里,`color` 连续像素的最大长度(量药丸顶部宽度用)。
|
||||
pub(super) fn run_at(shot: &Shot, y: usize, left: usize, right: usize, color: [u8; 3]) -> usize {
|
||||
let (mut longest, mut current) = (0, 0);
|
||||
for x in left..=right {
|
||||
if matches(shot.at(x, y), color) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
longest
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! gallery 里的选择类小节(**桩**):后续 mission 只改本文件。
|
||||
//!
|
||||
//! 现在直接转调 `components::selection::demo`,把该家族的占位塞进五段结构里;
|
||||
//! 填充时把 `demo(ui)` 换成本家族的状态矩阵即可(列头/单元格工具见 `gallery/mod.rs`)。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::selection;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 选择类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"selection · checkbox / radio / switch / chip",
|
||||
"12b §13/§10 + 15 §18/§19 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
selection::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user