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:
AskaEth committed 2026-11-02 04:50:44 +08:00
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()
}